Skip to content

异常测试与超时测试 ​

T1-008建议学进阶约 45 分钟JDK 21测试专项 A:单元测试 · JUnit 5

1. 本节定位 ​

正常路径的测试容易写,异常路径最容易漏——因为写代码时人本能地想"这条路不该走到"。

本节讲两类容易写错的断言:"应该抛异常" 和 "应该在限定时间内完成"。前者的坑在断言写法(很多人只验证了异常类型,没验证消息),后者的坑在两种超时断言的语义差别。

2. 学习目标 ​

完成本节后,你应该能够:

  • 用 assertThrows 同时验证异常类型与消息;
  • 用 assertDoesNotThrow 验证"不该抛异常"的路径并取回返回值;
  • 说清 assertTimeout 与 assertTimeoutPreemptively 的区别与各自适用场景;
  • 识别"吞掉异常"的假测试写法;
  • 判断异常消息该用"完全匹配"还是"包含匹配"。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

看三种"看起来在测异常,其实没测"的写法:

java
// 假测试 1:捕获了异常但什么都没断言
@Test
void should_throw() {
    try {
        calculator.payableInCents(0L, 1);
    } catch (IllegalArgumentException exception) {
        // 空实现:就算消息完全错、或者抛的是别的异常,这个测试也可能通过
    }
}
java
// 假测试 2:靠 fail() 兜底,但捕获范围过宽
@Test
void should_throw() {
    try {
        calculator.payableInCents(0L, 1);
        fail("应该抛异常");
    } catch (Exception exception) {
        // 捕获了 Exception:如果代码抛的是 NullPointerException,测试照样通过
    }
}
java
// 假测试 3:@Test(expected = ...) 是 JUnit 4 的写法,JUnit 5 里语义不同
// 它只能判断"有没有抛",无法校验消息,也无法定位是哪一行抛的

这三种写法都有一个共同问题:测试通过了,但它没有验证你以为它验证的东西。 本节用 assertThrows 把它们统一成一种准确、可读、可继续断言的写法。

5. 核心概念 ​

5.1 assertThrows:返回异常对象 ​

java
IllegalArgumentException exception = assertThrows(
        IllegalArgumentException.class,
        () -> calculator.payableInCents(0L, 1));
assertEquals("单价必须为正", exception.getMessage());

关键点:assertThrows 返回捕获到的异常对象,所以你能继续断言消息、原因、甚至是异常里的字段。这是它比"try-catch + fail"强的地方。

两个失败条件:

  1. 没有抛异常 → 测试失败;
  2. 抛出的异常类型不匹配(含子类判断) → 测试失败。

5.2 消息断言:完全匹配 vs 包含匹配 ​

java
assertEquals("单价必须为正", exception.getMessage());              // 完全匹配
assertTrue(exception.getMessage().contains("单价"));                // 包含匹配

怎么选:

策略适用风险
完全匹配消息是契约的一部分(面向用户的提示、错误码)改一个标点就红,维护成本高
包含匹配消息是给人看的诊断信息漏掉"消息整体被写错"的情况

实践建议:对错误码、枚举类型用完全匹配;对自由文本用包含匹配。更好的做法是让异常携带结构化的错误信息(如 ErrorCode 字段),断言那个字段——文本会变,语义不该变。

5.3 assertDoesNotThrow:验证"不该抛" ​

java
long payable = assertDoesNotThrow(() -> calculator.payableInCents(5_000L, 2));
assertEquals(9_000L, payable);

它和"直接调用"的区别:如果被测方法抛了异常,assertDoesNotThrow 会给出更清晰的失败信息("预期不抛异常,但抛出了 XXX"),而不是让异常直接冒泡、看起来像一个"错误"而不是"断言失败"。

更重要的是它能返回结果,于是"验证不抛异常"和"验证返回值"可以写在同一条链上。

5.4 两种超时断言(重点) ​

java
assertTimeout(Duration.ofMillis(500), () -> { ... });               // 版本一
assertTimeoutPreemptively(Duration.ofMillis(300), () -> { ... });   // 版本二
assertTimeoutassertTimeoutPreemptively
超时后等待任务执行完,再报告失败立即中断任务
执行线程与测试同一线程另开一个线程
能否访问测试的局部变量能(同线程)有风险(跨线程,只能安全访问 final/有效 final 的)
适用大多数情况(默认选它)任务可能永久卡住、必须立即止损时

为什么默认该用 assertTimeout:它在同一线程执行,不会引入并发问题;assertTimeoutPreemptively 因为要中断,会把任务放到别的线程——你的测试代码如果依赖了线程上下文(如 ThreadLocal、事务、静态状态),行为可能和真实运行不一样。

超时断言的定位要清楚:它不是性能测试,而是**"防止测试卡死"的保护网**。真正关心性能数字应该用基准测试工具,而不是在单元测试里比较毫秒。

5.5 一个测试一个异常场景 ​

java
// 好:一个测试只验证一个契约
@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());
}

为什么不合在一起:如果合成一个测试,第一个断言失败后面的就不执行了,你只知道"有一处不对"。分开写,报告会直接告诉你哪个契约破了。

6. 递进示例 ​

6.1 最小示例 ​

java
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(0L, 1));

6.2 业务示例:一次校验类型、消息与原因 ​

java
IllegalArgumentException exception = assertThrows(IllegalArgumentException.class,
        () -> calculator.payableInCents(100L, -5));
assertAll("异常信息",
        () -> assertEquals("数量必须为正", exception.getMessage()),
        () -> assertTrue(exception.getMessage().contains("数量")));

6.3 反例:捕获后不处理 ​

java
try {
    service.process(order);
} catch (Exception ignored) {
    // 测试永远通过——因为它把失败的可能性也吞掉了
}

这类代码在测试里出现,等于删掉了这个测试。 正确写法:要么用 assertThrows,要么让异常自然抛出(测试失败就是失败)。

7. 完整可运行代码 ​

文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/ExceptionAndTimeoutTest.java

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));
    }
}

运行命令:

powershell
cd labs
./mvnw -pl testing-unit test -Dtest=ExceptionAndTimeoutTest

预期输出:

text
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.083 s
  -- in cn.kb.learn.testing.ExceptionAndTimeoutTest

值得注意的一点:6 个测试总共只花了 0.083 秒——超时断言不是让测试变慢的负担,它只是在"真的超时"时才失败。

8. Debug 观察点 ​

  1. 在 assertThrows 的 lambda 里断点,按 F7(Step Into)观察它如何捕获异常并返回,而不是让异常冒泡——这是它与 try-catch 的关键区别;
  2. 在 assertTimeoutPreemptively 处断点,观察调试器的线程列表里多出一个线程——因为它把任务放到别的线程执行;对比 assertTimeout 则在当前线程;
  3. 把 assertThrows 的期望类型改成 ArithmeticException.class(错误的类型),重跑,观察失败信息:"Unexpected exception type thrown" 并同时给出期望类型与实际类型;
  4. 把 assertTimeout(Duration.ofMillis(500), ...) 的超时改成 Duration.ofNanos(1),重跑,观察失败信息——注意它是等任务跑完才报告的(这正是它与 Preemptively 的区别)。

9. 常见错误 ​

错误 1:只验证异常类型,不验证消息 ​

java
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(0L, 1));

问题:如果代码把"单价必须为正"改成了别的消息,或者把两个校验的异常调换了顺序,这个测试仍然通过。而这两个问题对使用者是有影响的。

修复:至少加一条消息断言(或断言结构化的错误码)。

错误 2:用 catch (Exception) 捕获过宽 ​

见 4 节"假测试 2"。修复:用 assertThrows(具体类型.class, ...),类型不对时它会失败。

错误 3:以为 assertThrows 能捕获子类 ​

java
assertThrows(IllegalArgumentException.class, () -> service.call());
// service 实际抛了 NullPointerException(不是 IllegalArgumentException 的子类)

会失败 ✓ 这是正确的行为。但如果期望的是父类(如 RuntimeException),子类抛出会通过——所以尽量不要用过于宽泛的异常类型做断言。

错误 4:在 assertTimeoutPreemptively 里访问测试的局部变量 ​

java
int counter = 0;
assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
    counter++;              // 危险:跨线程访问可变局部变量
});

问题:任务在另一个线程执行,counter 的可见性没有保证。修复:改用 assertTimeout,或用 AtomicInteger(但仍然要问"这个测试想验证什么")。

错误 5:把超时断言当性能测试 ​

java
assertTimeout(Duration.ofMillis(5), () -> service.process(data));     // 太紧

问题:CI 机器负载波动时,一个正常的 6 ms 调用会让构建随机失败——不稳定的测试比没有测试更糟。

修复:超时阈值给足余量(通常至少是期望值的 10 倍),只为拦截"卡死"而不是"变慢"。

10. 动手实验 ​

目标:把三种"假异常测试"改造成真测试。

步骤:

  1. 运行第 7 节的命令,核对 6 个测试通过;
  2. 把 capture_exception_and_assert_message 改成"try-catch + 空实现"的假测试形式,故意把消息断言删掉,然后在被测方法里把消息改成"价格必须为正",重跑——观察测试仍然通过,这就是"假测试"的破坏力;
  3. 写 assertThrows 版本的测试,重复第 2 步的改动——观察测试这次失败了;
  4. 给 OrderCalculator 加一个 payableWithRetry 方法(重试 3 次,每次间隔 10 ms),用 assertTimeout 验证它能在 100 ms 内完成;
  5. 把第 4 步的超时改成 1 ms,分别用 assertTimeout 与 assertTimeoutPreemptively 各跑一次,记录两者的耗时差异,解释为什么。

验收方式:能说出第 5 步中两者的行为差别,以及各自适合什么场景。

扩展任务:给被测方法设计一个"携带错误码的异常"(如 BusinessException(ErrorCode.INVALID_QUANTITY, "...")),并写测试断言 ErrorCode 字段而不是消息文本——说明这样做的好处。

11. 练习题 ​

基础题

  1. 判断:assertThrows 会返回捕获到的异常对象。(对 / 错)
  2. 填空:超时后等待任务结束再报告失败的是 ______;超时立即中断的是 ______。
  3. 简答:为什么异常消息建议用"包含匹配"而不是"完全匹配"?什么情况下应该用完全匹配?

代码阅读题

java
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));
}
  1. test1 会通过吗?为什么说它"测得太松"?
  2. test2 会通过吗?会给出什么失败信息?

编程题

  1. 写一个"下单服务"的异常测试:
    • 被测方法 String placeOrder(String userId, int quantity);
    • 规则:userId 为空抛 IllegalArgumentException("用户不能为空");quantity <= 0 抛 IllegalArgumentException("数量必须为正");quantity > 100 抛 IllegalStateException("单次最多 100 件");
    • 要求:每个异常场景一个独立测试;至少有一个测试同时断言类型与消息;
    • 用一个测试验证"合法输入不抛异常且返回订单号"。

综合任务

  1. 为一个"带重试的外部调用"设计测试:
    • 被测类 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("模拟失败");

12. 参考答案 ​

基础题
  1. 对。它返回捕获到的异常对象,所以可以继续断言消息、原因或异常携带的字段。
  2. assertTimeout;assertTimeoutPreemptively。
  3. 建议包含匹配的原因:消息文本属于"给人的诊断信息",措辞调整(加个标点、换个同义词)不应该让测试变红,否则维护成本高、团队会习惯性忽略失败。

该用完全匹配的场景:消息是契约的一部分——比如面向用户的提示文案(被产品经理验收过)、错误码文本、对外 API 的错误信息。更好的做法是把这些"契约信息"做成结构化的字段(错误码枚举),断言字段而不是字符串。

代码阅读题(第 4、5 题)

第 4 题:test1 会通过,但它"测得太松":

RuntimeException 是 NullPointerException 和 IllegalStateException 的共同父类,所以无论抛哪一个它都会通过。这意味着:如果代码实现变成了"订单为空时抛 IllegalStateException",这个测试依然绿——它没有区分这两种本该不同处理的情况。

第 5 题:test2 会失败(假设调用的是 ship(null)):

text
org.opentest4j.AssertionFailedError: Unexpected exception type thrown,
Expected :java.lang.IllegalStateException
Actual   :java.lang.NullPointerException

注意它的失败信息:JUnit 5 会同时给出期望类型与实际类型——这正是 assertThrows 相比"catch (Exception)"的价值:错误类型会被明确指出来。

编程题(第 6 题)
java
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):

java
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;
    }
}
综合任务(第 7 题)
java
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));
    }
}

参考实现:

java
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)。写测试时"参数取极端值"应该成为一种习惯。

13. 掌握标准 ​

  • [ ] 能用 assertThrows 同时验证异常类型与消息;
  • [ ] 知道异常消息该用完全匹配还是包含匹配,并说出理由;
  • [ ] 能说清 assertTimeout 与 assertTimeoutPreemptively 的区别与选择依据;
  • [ ] 能识别并改造三种"假异常测试";
  • [ ] 知道超时断言的定位是"防卡死",阈值要给足余量。

14. 扩展资源 ​

15. 下一步学习 ​

《AssertJ 与断言语义化》:让断言读起来像一句完整的期望。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引