跳到主要内容
约 8 分钟3 张卡

JUnit

JUnit 用可重复的测试验证程序的公开行为。一个有效测试需要明确的前置状态、目标动作,以及能够区分正确与错误结果的断言。本文先讲测试结构与断言,再说明参数化测试、状态隔离和超时的执行方式。示例采用 JUnit Jupiter 5.13.4。

1. 面试核心​

先记住这几条,再读正文
  • 行为断言。 每个测试从独立的前置状态执行目标动作,再用断言检查返回值、状态变化或失败结果。卡 · 测试结构
  • 异常类型匹配。 assertThrows 接受指定异常类型及其子类,assertThrowsExactly 要求抛出的异常类型完全匹配。
  • 参数化测试。 @ParameterizedTest 为同一规则的每组输入与期望结果分别执行一次测试。卡 · 参数化测试
  • 实例生命周期。 JUnit Jupiter 默认使用 PER_METHOD 生命周期,为每次测试创建独立的测试类实例。
  • 超时与线程。 assertTimeout 等待当前线程执行结束后判断超时,assertTimeoutPreemptively 在另一线程执行并尝试中断超时调用。卡 · 超时执行与线程边界

2. 测试结构​

测试结构应让读者看清准备了什么、执行了什么,以及怎样判断结果。下面用已有的 Money 类型演示异常断言,它的构造方法约定拒绝负数金额。

@Test
void rejectsNegativeAmount() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> new Money(new BigDecimal("-1.00"), CNY)
);

assertEquals("amount must be >= 0", exception.getMessage());
}

此例将异常消息视为稳定契约。若消息仅用于诊断,可只验证类型和业务字段。

可以把这个测试分成准备、执行、验证三部分:

  • Arrange:准备负数金额和币种。
  • Act:调用 Money 构造方法。
  • Assert:验证异常类型和可观察信息。

代码中的三部分应容易辨认,是否添加分段注释可以按复杂度决定。一个测试覆盖一个行为条件,多条断言可以共同说明该行为的结果。这里既检查异常类型,也检查异常携带的信息。

3. 断言​

断言用来判断执行结果是否符合预期。应根据结果的含义选择比较方式,例如相等、布尔条件、空值或集合元素:

assertEquals(expected, actual);
assertTrue(result.isValid());
assertNull(repository.findRaw(id));
assertIterableEquals(expectedItems, actualItems);

仅断言结果非 null 时,即使金额或状态错误,测试也会通过。expected 应来自明确常量、小型手算结果或独立性质。若复制生产算法来计算期望值,测试与生产代码就会保留同一种错误,让断言失去区分能力。

失败消息应帮助定位业务条件:

assertEquals(expected, actual, "discount for first-time customer");

4. 异常与多个结果的断言​

异常也是方法的可观察结果,测试需要检查异常类型以及其中的业务信息。assertThrows 接受指定异常及其子类,assertThrowsExactly 则要求类型完全匹配。两者都返回捕获的异常,便于继续检查字段:

DuplicateOrder exception = assertThrowsExactly(
DuplicateOrder.class,
() -> service.create(command)
);

assertEquals(command.idempotencyKey(), exception.key());

上例验证创建重复订单时的异常,并检查异常中的幂等键是否对应当前请求。

一次行为改变多个相关字段时,可以使用 assertAll,让一次运行报告多个断言失败:

assertAll(
() -> assertEquals(PAID, order.status()),
() -> assertEquals(paymentId, order.paymentId()),
() -> assertEquals(paidAt, order.paidAt())
);

assertAll 适合检查一项行为的多个相关结果。独立场景应拆成独立测试,否则一个场景的前置动作失败后,后续断言也会一起失败,干扰定位。

5. 参数化测试​

多组输入遵循同一条规则时,可以复用测试逻辑,只替换数据和期望结果。下面用同一方法检查十进制整数的位数:

@ParameterizedTest
@CsvSource({
"0, 1",
"1, 1",
"10, 2",
"99, 2"
})
void countsDecimalDigits(int input, int expectedDigits) {
assertEquals(expectedDigits, DecimalDigits.count(input));
}

四组参数分别运行,0 和 1 的期望位数为 1,10 和 99 的期望位数为 2。失败报告会指出是哪一组输入未满足预期。

参数化测试适合覆盖等价类与边界值。等价类指按同一规则处理的一组输入,边界值则是处理规则切换附近的值。可以按数据形态选择来源:

  • @ValueSource 提供简单的单参数数据,例如一组整数。
  • @NullSource、@EmptySource 分别提供空引用和空内容,用于检查缺失或空输入。
  • @CsvSource、@CsvFileSource 从内联 CSV 或文件提供多列数据。
  • @EnumSource 提供枚举值,适合遍历状态或选项。
  • @MethodSource 由工厂方法生成参数,适合复杂对象和需要清楚命名的案例。
  • 自定义 ArgumentsProvider 可以封装需要自行生成的参数。

复杂对象用 @MethodSource 表达时,比很长的 CSV 更容易看懂。默认 PER_METHOD 生命周期下,本类工厂方法需为 static。PER_CLASS 会复用测试实例,所以允许本类非静态工厂方法;外部工厂方法始终需要 static。

6. 生命周期与隔离​

测试应该从可重复的状态开始,换一个执行顺序也应得到同样结果。JUnit Jupiter 默认每个测试方法创建一个测试类实例,@BeforeEach 则在每次测试前准备依赖:

@BeforeEach
void setUp() {
repository = new InMemoryOrderRepository();
service = new OrderService(repository, fixedClock);
}

上例为每次测试创建新的内存 Repository,也就是保存和读取订单的对象。这样,前一个测试写入的订单就不会影响后一个测试。共享静态状态和数据库残留仍需单独清理,真实时钟也会让结果随执行时间变化。

昂贵资源可以在 @BeforeAll 建立,但每次测试都要重置业务数据。使用 PER_CLASS 时,所有测试复用同一个测试类实例,所以实例字段也需清理。参数化测试的每次调用都会分别执行 @BeforeEach,每组数据都能获得独立起点。

7. 时间、随机与并发​

时间、随机数和外部资源会让相同测试得到不同结果。要让失败能够重现,可以控制以下四类依赖:

  • 注入 Clock,让被测代码通过它读取时间,测试时固定当前时间。
  • 注入随机源并使用固定种子,让重复运行得到同样的随机序列。
  • 用 @TempDir 提供临时目录,隔离测试文件。
  • 由测试环境分配端口,减少端口被其他进程占用造成的失败。

比如,可以用固定时钟替代直接读取 Instant.now():

Clock clock = Clock.fixed(
Instant.parse("2026-08-30T00:00:00Z"),
ZoneOffset.UTC
);

被测代码通过这个 Clock 读取时间时,会始终得到示例指定的时间点。

并发测试应等待明确的完成信号,例如用 Future 取得任务结果,或用 CountDownLatch 等待任务完成。也可以用 Awaitility 轮询结果是否满足条件,并设置等待上限。单纯 sleep 一段时间只是在猜测任务何时完成,机器变慢后就会出现偶发失败。

assertTimeout 会等调用结束后检查耗时,具体执行方式见超时执行与线程边界。测试中的耗时断言用于发现等待异常,性能测量则需要专门的基准。

8. 测试命名​

测试失败时,名称应帮助读者定位条件与预期结果。比如,下面的名字直接说明取消订单的支付应被拒绝:

@Test
void payingCancelledOrderIsRejected() {}

相比 testPay1,这个名称能表达条件、行为与结果。@Nested 可以按方法或状态组织测试,@DisplayName 可以补充业务描述。方法名本身也应保留明确含义,便于搜索和定位失败用例。

9. 超时执行与线程边界​

超时断言是否切换线程,会影响测试何时失败,以及被测代码使用哪个线程的上下文。assertTimeout 在当前线程执行,等代码返回后再比较耗时。因此,代码永久阻塞时,这个断言也会持续等待。

assertTimeoutPreemptively 在另一线程执行被测代码,达到上限后测试可以提前失败。JUnit 会尝试中断执行线程,但被测代码仍需响应中断,才能及时停止。

Spring 的测试事务通过 ThreadLocal 绑定当前线程,也就是把事务信息保存在当前线程中。抢占式断言换了执行线程,数据库操作就会脱离原测试事务。如果这些操作在自己的事务中提交,外层测试回滚也无法撤销它们。

需要沿用当前线程的事务时,应选择同线程执行方式。确实需要抢占式超时时,则要单独设计执行线程的事务与中断处理。

10. 常见问题​

10.1 一个测试只能有一条断言吗?​

一个行为可以有多条相关断言,例如同时检查订单状态和支付时间。需要拆开的是独立动作或不同前置条件,因为它们有各自的失败原因。

10.2 私有方法怎样测试?​

10.3 参数化测试能替代普通测试吗?​

10.4 抢占式超时为什么会影响线程上下文?​

11 · 面试题卡片​

本页题目与参考回答

11.1 平时怎样使用 JUnit 编写单元测试​

出现公司:阿里巴巴、招银网络、众安保险

考察重点

  • 说明怎样为测试建立独立状态,以及断言如何区分正确与错误结果。
  • 说明时间、随机和外部依赖怎样变得可控。
  • 说明单元测试与启动完整 Spring 环境的区别。

相关内容:测试结构至测试命名。

参考回答

JUnit 测试从独立、可重复的状态执行目标行为,再用断言验证可观察结果。

每个测试都要明确准备、执行和验证三部分。整组用例分别覆盖正常结果、边界与失败后的状态。期望结果使用常量、手算结果或独立性质,避免复制生产算法。

时间通过固定 Clock 控制,随机源使用固定种子,测试顺序也应保持独立。纯业务规则可以直接构造对象,并通过窄接口替换外部依赖,无需启动 Spring 容器。数据库映射、事务和 Bean 配置则由使用真实组件的集成测试验证。

11.2 什么情况下使用参数化测试​

出现公司:字节跳动、网易雷火

考察重点

  • 说明怎样为同一规则选择等价类与边界值。
  • 说明数据源和测试逻辑如何保持可读。
  • 说明复杂场景为什么仍需要独立测试。

相关内容:参数化测试。

参考回答

同一条规则只改变输入和期望结果时,适合使用参数化测试。

例如金额边界、日期解析与状态枚举,都可以用多组数据验证。

简单值使用 @ValueSource 或 @CsvSource,复杂对象和需要清楚命名的案例使用 @MethodSource。每组数据都应有明确的期望结果,失败时能直接定位对应输入。准备、动作或断言路径明显不同的场景,则拆成独立测试。

11.3 assertTimeout 会中止慢调用吗,抢占式超时为何会影响事务测试?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:区分两种超时断言的执行线程,并解释中断处理和线程绑定事务对测试结果的影响。

相关内容:超时执行与线程边界。

参考回答

assertTimeout 会等代码结束后才判断超时,因此不会中止慢调用。

assertTimeoutPreemptively 在另一线程执行,到达上限后可提前失败,并尝试中断执行线程。被测代码要响应中断,才能及时结束。

Spring 测试事务绑定原线程,新线程无法自动沿用该事务。所以,抢占式断言中的数据库操作会脱离外层回滚范围。它们若已在自己的事务中提交,外层回滚也无法撤销。事务测试应让执行线程与事务的作用范围保持一致。

12. 参考资料​