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 抢占式超时为什么会影响线程上下文?
因为它在另一线程执行,而普通 ThreadLocal 保存的数据属于原线程。测试事务等上下文需要按新线程设计,超时后也要确认被测代码会响应中断。具体事务影响见超时执行与线程边界。
11 · 面试题卡片
本页题目与参考回答
11.1 平时怎样使用 JUnit 编写单元测试
出现公司:阿里巴巴、招银网络、众安保险
考察重点
- 说明怎样为测试建立独立状态,以及断言如何区分正确与错误结果。
- 说明时间、随机和外部依赖怎样变得可控。
- 说明单元测试与启动完整 Spring 环境的区别。
参考回答
JUnit 测试从独立、可重复的状态执行目标行为,再用断言验证可观察结果。
每个测试都要明确准备、执行和验证三部分。整组用例分别覆盖正常结果、边界与失败后的状态。期望结果使用常量、手算结果或独立性质,避免复制生产算法。
时间通过固定 Clock 控制,随机源使用固定种子,测试顺序也应保持独立。纯业务规则可以直接构造对象,并通过窄接口替换外部依赖,无需启动 Spring 容器。数据库映射、事务和 Bean 配置则由使用真实组件的集成测试验证。
11.2 什么情况下使用参数化测试
出现公司:字节跳动、网易雷火
考察重点
- 说明怎样为同一规则选择等价类与边界值。
- 说明数据源和测试逻辑如何保持可读。
- 说明复杂场景为什么仍需要独立测试。
相关内容:参数化测试。
参考回答
同一条规则只改变输入和期望结果时,适合使用参数化测试。
例如金额边界、日期解析与状态枚举,都可以用多组数据验证。
简单值使用 @ValueSource 或 @CsvSource,复杂对象和需要清楚命名的案例使用 @MethodSource。每组数据都应有明确的期望结果,失败时能直接定位对应输入。准备、动作或断言路径明显不同的场景,则拆成独立测试。
11.3 assertTimeout 会中止慢调用吗,抢占式超时为何会影响事务测试?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:区分两种超时断言的执行线程,并解释中断处理和线程绑定事务对测试结果的影响。
相关内容:超时执行与线程边界。
参考回答
assertTimeout 会等代码结束后才判断超时,因此不会中止慢调用。
assertTimeoutPreemptively 在另一线程执行,到达上限后可提前失败,并尝试中断执行线程。被测代码要响应中断,才能及时结束。
Spring 测试事务绑定原线程,新线程无法自动沿用该事务。所以,抢占式断言中的数据库操作会脱离外层回滚范围。它们若已在自己的事务中提交,外层回滚也无法撤销。事务测试应让执行线程与事务的作用范围保持一致。