主题
界面
主题
正常路径的测试容易写,异常路径最容易漏——因为写代码时人本能地想"这条路不该走到"。
本节讲两类容易写错的断言:"应该抛异常" 和 "应该在限定时间内完成"。前者的坑在断言写法(很多人只验证了异常类型,没验证消息),后者的坑在两种超时断言的语义差别。
完成本节后,你应该能够:
assertThrows 同时验证异常类型与消息;assertDoesNotThrow 验证"不该抛异常"的路径并取回返回值;assertTimeout 与 assertTimeoutPreemptively 的区别与各自适用场景;看三种"看起来在测异常,其实没测"的写法:
// 假测试 1:捕获了异常但什么都没断言
@Test
void should_throw() {
try {
calculator.payableInCents(0L, 1);
} catch (IllegalArgumentException exception) {
// 空实现:就算消息完全错、或者抛的是别的异常,这个测试也可能通过
}
}// 假测试 2:靠 fail() 兜底,但捕获范围过宽
@Test
void should_throw() {
try {
calculator.payableInCents(0L, 1);
fail("应该抛异常");
} catch (Exception exception) {
// 捕获了 Exception:如果代码抛的是 NullPointerException,测试照样通过
}
}// 假测试 3:@Test(expected = ...) 是 JUnit 4 的写法,JUnit 5 里语义不同
// 它只能判断"有没有抛",无法校验消息,也无法定位是哪一行抛的这三种写法都有一个共同问题:测试通过了,但它没有验证你以为它验证的东西。 本节用 assertThrows 把它们统一成一种准确、可读、可继续断言的写法。
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.payableInCents(0L, 1));
assertEquals("单价必须为正", exception.getMessage());关键点:assertThrows 返回捕获到的异常对象,所以你能继续断言消息、原因、甚至是异常里的字段。这是它比"try-catch + fail"强的地方。
两个失败条件:
assertEquals("单价必须为正", exception.getMessage()); // 完全匹配
assertTrue(exception.getMessage().contains("单价")); // 包含匹配怎么选:
| 策略 | 适用 | 风险 |
|---|---|---|
| 完全匹配 | 消息是契约的一部分(面向用户的提示、错误码) | 改一个标点就红,维护成本高 |
| 包含匹配 | 消息是给人看的诊断信息 | 漏掉"消息整体被写错"的情况 |
实践建议:对错误码、枚举类型用完全匹配;对自由文本用包含匹配。更好的做法是让异常携带结构化的错误信息(如 ErrorCode 字段),断言那个字段——文本会变,语义不该变。
long payable = assertDoesNotThrow(() -> calculator.payableInCents(5_000L, 2));
assertEquals(9_000L, payable);它和"直接调用"的区别:如果被测方法抛了异常,assertDoesNotThrow 会给出更清晰的失败信息("预期不抛异常,但抛出了 XXX"),而不是让异常直接冒泡、看起来像一个"错误"而不是"断言失败"。
更重要的是它能返回结果,于是"验证不抛异常"和"验证返回值"可以写在同一条链上。
assertTimeout(Duration.ofMillis(500), () -> { ... }); // 版本一
assertTimeoutPreemptively(Duration.ofMillis(300), () -> { ... }); // 版本二assertTimeout | assertTimeoutPreemptively | |
|---|---|---|
| 超时后 | 等待任务执行完,再报告失败 | 立即中断任务 |
| 执行线程 | 与测试同一线程 | 另开一个线程 |
| 能否访问测试的局部变量 | 能(同线程) | 有风险(跨线程,只能安全访问 final/有效 final 的) |
| 适用 | 大多数情况(默认选它) | 任务可能永久卡住、必须立即止损时 |
为什么默认该用 assertTimeout:它在同一线程执行,不会引入并发问题;assertTimeoutPreemptively 因为要中断,会把任务放到别的线程——你的测试代码如果依赖了线程上下文(如 ThreadLocal、事务、静态状态),行为可能和真实运行不一样。
超时断言的定位要清楚:它不是性能测试,而是**"防止测试卡死"的保护网**。真正关心性能数字应该用基准测试工具,而不是在单元测试里比较毫秒。
// 好:一个测试只验证一个契约
@Test
void rejects_zero_unit_price() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> calculator.payableInCents(0L, 1));
assertEquals("单价必须为正", exception.getMessage());
}
@Test
void rejects_negative_quantity() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> calculator.payableInCents(100L, -1));
assertEquals("数量必须为正", exception.getMessage());
}为什么不合在一起:如果合成一个测试,第一个断言失败后面的就不执行了,你只知道"有一处不对"。分开写,报告会直接告诉你哪个契约破了。
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(0L, 1));IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> calculator.payableInCents(100L, -5));
assertAll("异常信息",
() -> assertEquals("数量必须为正", exception.getMessage()),
() -> assertTrue(exception.getMessage().contains("数量")));try {
service.process(order);
} catch (Exception ignored) {
// 测试永远通过——因为它把失败的可能性也吞掉了
}这类代码在测试里出现,等于删掉了这个测试。 正确写法:要么用 assertThrows,要么让异常自然抛出(测试失败就是失败)。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/ExceptionAndTimeoutTest.java
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import static org.junit.jupiter.api.Assertions.assertAll;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
@DisplayName("异常与超时断言")
class ExceptionAndTimeoutTest {
private final OrderCalculator calculator = new OrderCalculator();
@Test
@DisplayName("assertThrows 返回异常对象,可继续断言消息")
void capture_exception_and_assert_message() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.payableInCents(0L, 1));
assertEquals("单价必须为正", exception.getMessage());
}
@Test
@DisplayName("一次校验异常类型、消息与消息片段")
void assert_exception_type_and_message() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.payableInCents(100L, -5));
assertAll("异常信息",
() -> assertEquals("数量必须为正", exception.getMessage()),
() -> assertTrue(exception.getMessage().contains("数量")));
}
@Test
@DisplayName("assertDoesNotThrow:正常路径不该抛异常,并能取回返回值")
void valid_input_does_not_throw() {
long payable = assertDoesNotThrow(() -> calculator.payableInCents(5_000L, 2));
assertEquals(9_000L, payable);
}
@Test
@DisplayName("assertTimeout:限时断言,超时会失败")
void completes_within_timeout() {
String result = assertTimeout(Duration.ofMillis(500),
() -> calculator.payableInCents(5_000L, 2) + " 分");
assertEquals("9000 分", result);
}
@Test
@DisplayName("assertTimeoutPreemptively:超时立即中断任务")
void preemptive_timeout_interrupts_task() {
assertTimeoutPreemptively(Duration.ofMillis(300), () -> Thread.sleep(10));
}
@Test
@DisplayName("抛出了异常但类型不符时,测试同样失败")
void wrong_exception_type_would_fail() {
// 这里期望的是 IllegalArgumentException,实际抛出的正是它,所以通过
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(-1L, 1));
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=ExceptionAndTimeoutTest预期输出:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 s
-- in cn.kb.learn.testing.ExceptionAndTimeoutTest值得注意的一点:6 个测试总共只花了 0.083 秒——超时断言不是让测试变慢的负担,它只是在"真的超时"时才失败。
assertThrows 的 lambda 里断点,按 F7(Step Into)观察它如何捕获异常并返回,而不是让异常冒泡——这是它与 try-catch 的关键区别;assertTimeoutPreemptively 处断点,观察调试器的线程列表里多出一个线程——因为它把任务放到别的线程执行;对比 assertTimeout 则在当前线程;assertThrows 的期望类型改成 ArithmeticException.class(错误的类型),重跑,观察失败信息:"Unexpected exception type thrown" 并同时给出期望类型与实际类型;assertTimeout(Duration.ofMillis(500), ...) 的超时改成 Duration.ofNanos(1),重跑,观察失败信息——注意它是等任务跑完才报告的(这正是它与 Preemptively 的区别)。assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(0L, 1));问题:如果代码把"单价必须为正"改成了别的消息,或者把两个校验的异常调换了顺序,这个测试仍然通过。而这两个问题对使用者是有影响的。
修复:至少加一条消息断言(或断言结构化的错误码)。
catch (Exception) 捕获过宽 见 4 节"假测试 2"。修复:用 assertThrows(具体类型.class, ...),类型不对时它会失败。
assertThrows 能捕获子类 assertThrows(IllegalArgumentException.class, () -> service.call());
// service 实际抛了 NullPointerException(不是 IllegalArgumentException 的子类)会失败 ✓ 这是正确的行为。但如果期望的是父类(如 RuntimeException),子类抛出会通过——所以尽量不要用过于宽泛的异常类型做断言。
assertTimeoutPreemptively 里访问测试的局部变量 int counter = 0;
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
counter++; // 危险:跨线程访问可变局部变量
});问题:任务在另一个线程执行,counter 的可见性没有保证。修复:改用 assertTimeout,或用 AtomicInteger(但仍然要问"这个测试想验证什么")。
assertTimeout(Duration.ofMillis(5), () -> service.process(data)); // 太紧问题:CI 机器负载波动时,一个正常的 6 ms 调用会让构建随机失败——不稳定的测试比没有测试更糟。
修复:超时阈值给足余量(通常至少是期望值的 10 倍),只为拦截"卡死"而不是"变慢"。
目标:把三种"假异常测试"改造成真测试。
步骤:
capture_exception_and_assert_message 改成"try-catch + 空实现"的假测试形式,故意把消息断言删掉,然后在被测方法里把消息改成"价格必须为正",重跑——观察测试仍然通过,这就是"假测试"的破坏力;assertThrows 版本的测试,重复第 2 步的改动——观察测试这次失败了;OrderCalculator 加一个 payableWithRetry 方法(重试 3 次,每次间隔 10 ms),用 assertTimeout 验证它能在 100 ms 内完成;assertTimeout 与 assertTimeoutPreemptively 各跑一次,记录两者的耗时差异,解释为什么。验收方式:能说出第 5 步中两者的行为差别,以及各自适合什么场景。
扩展任务:给被测方法设计一个"携带错误码的异常"(如 BusinessException(ErrorCode.INVALID_QUANTITY, "...")),并写测试断言 ErrorCode 字段而不是消息文本——说明这样做的好处。
基础题
assertThrows 会返回捕获到的异常对象。(对 / 错)代码阅读题
class OrderService {
void ship(Order order) {
if (order == null) {
throw new NullPointerException("订单不能为空");
}
if (order.items().isEmpty()) {
throw new IllegalStateException("订单不能没有商品");
}
}
}
@Test
void test1() {
assertThrows(RuntimeException.class, () -> service.ship(null));
}
@Test
void test2() {
assertThrows(IllegalStateException.class, () -> service.ship(null));
}test1 会通过吗?为什么说它"测得太松"?test2 会通过吗?会给出什么失败信息?编程题
String placeOrder(String userId, int quantity);userId 为空抛 IllegalArgumentException("用户不能为空");quantity <= 0 抛 IllegalArgumentException("数量必须为正");quantity > 100 抛 IllegalStateException("单次最多 100 件");综合任务
RetryTemplate:<T> T execute(Supplier<T> action, int maxAttempts, Duration delay);前 maxAttempts - 1 次抛异常时重试,最后一次仍失败则抛出;@ParameterizedTest 覆盖 maxAttempts 为 1、3、5 的场景(用计数器模拟"第几次成功");assertTimeout 验证 3 次重试 + 10 ms 间隔能在 200 ms 内完成;maxAttempts = 0 或 delay 为负数时的行为(你决定是抛异常还是使用默认值,但必须写出对应测试);提示 1(方向):第 4 题的关键是 RuntimeException 是 NullPointerException 与 IllegalStateException 的共同父类。
提示 2(关键点):第 7 题的重试逻辑需要一个"会被调用的计数器",用 AtomicInteger 或简单的 int[] 包一层都能模拟。
提示 3(接近答案):第 7 题的"第几次成功"用计数器判断:if (count.incrementAndGet() < succeedAt) throw new IllegalStateException("模拟失败");
assertTimeout;assertTimeoutPreemptively。该用完全匹配的场景:消息是契约的一部分——比如面向用户的提示文案(被产品经理验收过)、错误码文本、对外 API 的错误信息。更好的做法是把这些"契约信息"做成结构化的字段(错误码枚举),断言字段而不是字符串。
第 4 题:test1 会通过,但它"测得太松":
RuntimeException 是 NullPointerException 和 IllegalStateException 的共同父类,所以无论抛哪一个它都会通过。这意味着:如果代码实现变成了"订单为空时抛 IllegalStateException",这个测试依然绿——它没有区分这两种本该不同处理的情况。
第 5 题:test2 会失败(假设调用的是 ship(null)):
org.opentest4j.AssertionFailedError: Unexpected exception type thrown,
Expected :java.lang.IllegalStateException
Actual :java.lang.NullPointerException注意它的失败信息:JUnit 5 会同时给出期望类型与实际类型——这正是 assertThrows 相比"catch (Exception)"的价值:错误类型会被明确指出来。
package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
@DisplayName("下单服务的异常契约")
class OrderServiceExceptionTest {
private final OrderServiceForExceptionDemo service = new OrderServiceForExceptionDemo();
@Test
@DisplayName("用户为空时抛出 IllegalArgumentException")
void rejects_blank_user() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.placeOrder("", 1));
assertEquals("用户不能为空", exception.getMessage());
}
@Test
@DisplayName("数量为 0 时抛出 IllegalArgumentException")
void rejects_zero_quantity() {
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
() -> service.placeOrder("u1", 0));
assertEquals("数量必须为正", exception.getMessage());
}
@Test
@DisplayName("超过 100 件时抛出 IllegalStateException(不是 IllegalArgumentException)")
void rejects_quantity_above_limit() {
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> service.placeOrder("u1", 101));
assertEquals("单次最多 100 件", exception.getMessage());
}
@Test
@DisplayName("合法输入返回订单号")
void returns_order_id_for_valid_input() {
String orderId = service.placeOrder("u1", 10);
assertEquals("ORDER-u1-10", orderId);
}
}注意第三个测试的异常类型:IllegalArgumentException 表示"参数本身不合法",IllegalStateException 表示"当前状态不允许这个操作"。"超过限购数量"属于业务规则,用后者更准确——测试断言了类型,就等于把"这个区分"固化成了契约。
参考实现(OrderServiceForExceptionDemo):
class OrderServiceForExceptionDemo {
String placeOrder(String userId, int quantity) {
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("用户不能为空");
}
if (quantity <= 0) {
throw new IllegalArgumentException("数量必须为正");
}
if (quantity > 100) {
throw new IllegalStateException("单次最多 100 件");
}
return "ORDER-" + userId + "-" + quantity;
}
}package cn.kb.learn.testing;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeout;
@DisplayName("重试模板")
class RetryTemplateTest {
private final RetryTemplate retryTemplate = new RetryTemplate();
@ParameterizedTest(name = "最多重试 {0} 次,第 {1} 次成功")
@CsvSource({"1, 1", "3, 2", "3, 3", "5, 4"})
void retries_until_success(int maxAttempts, int succeedAt) {
AtomicInteger calls = new AtomicInteger();
Supplier<String> action = () -> {
if (calls.incrementAndGet() < succeedAt) {
throw new IllegalStateException("模拟失败");
}
return "结果";
};
assertEquals("结果", retryTemplate.execute(action, maxAttempts, Duration.ZERO));
assertEquals(succeedAt, calls.get());
}
@Test
@DisplayName("超过最大次数后抛出最后一次异常")
void throws_after_exhausting_attempts() {
AtomicInteger calls = new AtomicInteger();
IllegalStateException exception = assertThrows(IllegalStateException.class,
() -> retryTemplate.execute(() -> {
calls.incrementAndGet();
throw new IllegalStateException("一直失败");
}, 3, Duration.ZERO));
assertEquals("一直失败", exception.getMessage());
assertEquals(3, calls.get());
}
@Test
@DisplayName("3 次重试加间隔能在 200 毫秒内完成")
void completes_within_time_budget() {
String result = assertTimeout(Duration.ofMillis(200),
() -> retryTemplate.execute(() -> "立即成功", 3, Duration.ofMillis(10)));
assertEquals("立即成功", result);
}
@Test
@DisplayName("maxAttempts 为 0 时应拒绝并给出明确异常")
void rejects_zero_attempts() {
assertThrows(IllegalArgumentException.class,
() -> retryTemplate.execute(() -> "结果", 0, Duration.ZERO));
}
}参考实现:
class RetryTemplate {
<T> T execute(Supplier<T> action, int maxAttempts, Duration delay) {
if (maxAttempts < 1) {
throw new IllegalArgumentException("重试次数必须为正");
}
RuntimeException lastFailure = null;
for (int attempt = 0; attempt < maxAttempts; attempt++) {
try {
return action.get();
} catch (RuntimeException exception) {
lastFailure = exception;
if (attempt < maxAttempts - 1 && !delay.isZero()) {
try {
Thread.sleep(delay.toMillis());
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IllegalStateException("重试被中断", interrupted);
}
}
}
}
throw lastFailure;
}
}为什么"重试次数"最适合参数化:它天然是一个"同结构、不同数据"的矩阵——每个 maxAttempts 都要验证"调用次数是否正确"和"返回值是否正确"这两条相同的断言。如果写成 4 个独立测试方法,你会把同一套断言复制 4 遍,而参数化只是多写了一行数据。
另外注意第 4 个测试的边界:maxAttempts = 0 是个很容易漏的输入(循环一次都不执行,lastFailure 为 null,直接 throw null 会抛 NullPointerException)。写测试时"参数取极端值"应该成为一种习惯。
assertThrows 同时验证异常类型与消息;assertTimeout 与 assertTimeoutPreemptively 的区别与选择依据;assertThrows / assertTimeout 的完整签名《AssertJ 与断言语义化》:让断言读起来像一句完整的期望。
暂无