JUnit 5 的生命周期、常用注解和参数化测试是什么?
简化版
JUnit 5 由三部分组成:Platform(运行测试的基础平台)、Jupiter(JUnit 5 的编程/扩展模型,@Test 等新注解来自这里)、Vintage(兼容运行 JUnit 3/4 老测试)。生命周期默认 per-method——每个测试方法都创建一个新的测试类实例,避免测试间共享状态。常用注解:@Test、@BeforeEach/@AfterEach(每个方法前后)、@BeforeAll/@AfterAll(所有方法前后,默认要 static)。参数化测试(@ParameterizedTest + @ValueSource/@CsvSource/@MethodSource)用不同输入复用同一套断言逻辑,适合测边界值和多组规则。
详细版
JUnit 5 三大模块:
| 模块 | 作用 |
|---|---|
| Platform | 启动和运行测试的基础平台(IDE/构建工具对接它) |
| Jupiter | JUnit 5 的编程模型和扩展模型(@Test、@ExtendWith 等) |
| Vintage | 兼容运行 JUnit 3/4 的旧测试 |
核心注解与生命周期:
class OrderServiceTest {
@BeforeAll // 所有测试前 1 次(默认必须 static)
static void initExpensiveResource() { }
@BeforeEach // 每个测试方法前
void setUp() { /* 准备每个测试都要的新对象 */ }
@Test
void shouldCalculatePrice() { /* 断言业务结果 */ }
@AfterEach // 每个测试方法后
void tearDown() { /* 清理临时状态 */ }
@AfterAll // 所有测试后 1 次(默认 static)
static void cleanup() { }
}
参数化测试(数据驱动):
@ParameterizedTest
@CsvSource({ "1, 1, 2", "2, 3, 5", "-1, 1, 0" })
void shouldAdd(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b)); // 同一套断言,跑多组数据
}
@ParameterizedTest
@ValueSource(strings = { "", " ", " " })
void blankStringsAreInvalid(String s) {
assertFalse(validator.isValid(s));
}
常用输入源:@ValueSource(单参数简单值)、@CsvSource(少量表格数据)、@MethodSource(复杂对象/动态生成)、@EnumSource、@NullAndEmptySource。
完整版教学
一、JUnit 5 的组成(三大模块)
JUnit 5 不是一个单体,而是三个模块:
- JUnit Platform:运行测试的基础平台。IDE(IDEA)、构建工具(Maven Surefire、Gradle)都通过它来发现和执行测试。它定义了
TestEngine接口。 - JUnit Jupiter:JUnit 5 自己的编程模型和扩展模型。你日常写的
@Test、@ParameterizedTest、@BeforeEach、@ExtendWith等大多来自 Jupiter。 - JUnit Vintage:兼容层,让你能在 JUnit 5 平台上继续运行 JUnit 3/4 写的老测试,方便渐进迁移。
面试能说清「Platform 是平台、Jupiter 是新模型、Vintage 是兼容旧版」就到位了。
| 模块 | 面向谁 | 典型作用 |
|---|---|---|
| Platform | IDE/构建工具/测试引擎 | 发现、启动、执行测试 |
| Jupiter | 测试编写者 | 提供 JUnit 5 注解和扩展模型 |
| Vintage | 迁移中的老项目 | 在 JUnit 5 平台运行 JUnit 3/4 测试 |
JUnit 5 面试别只背注解,要先说清 Platform/Jupiter/Vintage 的分层,再谈生命周期和参数化测试。
二、默认生命周期为什么重要(per-method)
JUnit 5 默认生命周期是 per-method(PER_METHOD):每个测试方法执行前,都会创建一个新的测试类实例。
为什么这样设计?为了测试隔离——减少测试之间的状态污染。如果所有测试方法共用一个实例,一个测试改了实例字段,会影响后面的测试,导致测试互相干扰、结果依赖执行顺序(极难排查的问题)。每个方法新实例,就保证了实例字段不会在测试之间共享。
但注意:这只隔离了实例字段。如果你把可变状态放在 static 字段 或外部资源(数据库、文件)里,测试之间仍然可能互相影响。所以测试独立性不能只靠框架保证——你自己也要避免共享可变状态。
(可以用 @TestInstance(Lifecycle.PER_CLASS) 改成整个类共用一个实例,此时 @BeforeAll/@AfterAll 可以非 static,但要自己管好状态清理。)
PER_METHOD: testA -> new TestClass(), testB -> new TestClass()
PER_CLASS : testA/testB 共用同一个 TestClass 实例
三、Before / After 的执行边界
四个生命周期注解的边界:
@BeforeEach:每个测试方法前执行。适合准备每个测试都需要的、新的对象(如 new 一个被测对象、重置 mock),保证每个测试从干净状态开始。@AfterEach:每个测试方法后执行。适合清理临时状态(关资源、清 ThreadLocal)。@BeforeAll:所有测试方法之前只执行 1 次。适合昂贵且可共享的准备工作(启动 Testcontainers 容器、加载大配置)。默认要求是 static 方法(因为此时还没有实例)。@AfterAll:所有测试方法之后只执行 1 次,清理共享资源。
注意:@BeforeAll 里的共享资源要格外小心并发和清理——多个测试共用一个资源,如果测试有并发或资源有状态,可能互相影响。
四、断言要表达业务结果
断言不是只检查「不报错」。写测试时断言要表达真正的业务预期:
- 测金额计算,要断言金额值、舍入方式、异常分支、边界值,而不是「跑通了就行」。
- 用
assertEquals、assertTrue、assertThrows等明确断言预期。 assertAll可以把多个相关断言组合起来一起执行——这样第一个断言失败不会遮住后面的(普通断言第一个失败就停,看不到其他问题):
assertAll("order",
() -> assertEquals(100, order.getAmount()),
() -> assertEquals("PAID", order.getStatus()),
() -> assertNotNull(order.getPaidTime())
);
没有断言、或断言太弱的测试没有保护价值——它只是让覆盖率好看,实际测不出 bug。
五、参数化测试的价值
当同一段逻辑需要用多组输入验证时(边界值、等价类、多组业务规则),参数化测试把「测试结构」和「测试数据」分开,比复制十个类似的测试方法好维护得多:
@ValueSource:单个参数的简单值列表(字符串、数字)。@CsvSource:少量表格数据,每行一组输入(含期望值)——适合「输入→期望输出」的规则表。@MethodSource:从一个方法返回复杂对象或动态生成的数据,适合参数复杂的情况。@NullAndEmptySource:自动测 null 和空值(常见边界)。
一套断言逻辑 + 多组数据,既减少重复代码,又让「测了哪些 case」一目了然。
六、异常测试怎么写
用 assertThrows 验证异常,但要验得具体:
@Test
void shouldRejectNegativeAmount() {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class, // 验证具体异常类型
() -> account.withdraw(-100)
);
assertTrue(ex.getMessage().contains("金额")); // 验证异常消息/业务字段
assertEquals(1000, account.getBalance()); // 验证失败后状态没被错误修改
}
- 只断言「抛了 RuntimeException」太粗——会把完全不同的错误混在一起(可能是 NPE 而非业务异常)。要断言具体的异常类型。
- 还要验证异常后的状态——失败后账户余额有没有被错误扣减(异常不能留下脏数据)。
七、和 Spring 测试的边界(别滥用 @SpringBootTest)
纯单元测试不需要启动 Spring 容器——直接 new 被测对象、用 Mockito 替换依赖,更快更清晰。只有当你要验证框架行为时才用 Spring Test:
- Bean 装配、依赖注入是否正确 →
@SpringBootTest或切片测试。 - 事务、配置绑定 → Spring Test。
- MVC 请求 →
@WebMvcTest+ MockMvc。
反模式:把所有测试都写成 @SpringBootTest——每个测试都启动完整 Spring 上下文,慢到没人愿意跑,失去了单元测试「快速反馈」的意义。原则:能 new 就 new,非要框架才上 Spring Test,且优先用切片测试(@WebMvcTest 等)而非全量 @SpringBootTest。
八、常见误区与追问
- 误区:JUnit 5 只是 JUnit 4 换了一批注解。 JUnit 5 分为 Platform、Jupiter、Vintage,运行平台和编程模型都重新拆分了。
- 误区:per-method 能隔离所有状态。 它只隔离测试实例字段,static 字段、数据库、文件等外部状态仍要清理。
- 误区:参数化测试只是为了少写几个测试方法。 它的价值是把输入数据和断言逻辑分开,更适合系统覆盖边界值和等价类。
- 追问:
@BeforeAll默认为什么要 static? 默认 per-method 下执行@BeforeAll时还没有具体测试实例,所以要求静态方法。 - 追问:什么时候使用
@MethodSource? 当参数是复杂对象、动态生成数据或多字段组合超出简单 CSV 表达能力时使用。 - 追问:为什么不建议所有测试都用
@SpringBootTest? 它启动完整 Spring 上下文,反馈慢,容易把单元测试变成沉重的集成测试。
九、加强记忆
JUnit 5 = Platform(平台)+ Jupiter(新编程模型,@Test 来源)+ Vintage(兼容 JUnit 3/4)。生命周期默认 per-method(每个测试方法新建实例)——隔离实例字段避免污染,但 static 字段/外部资源仍会互相影响(独立性别只靠框架)。注解:@BeforeEach/@AfterEach(每方法前后,准备/清理)、@BeforeAll/@AfterAll(所有方法前后 1 次,默认 static,放昂贵共享资源)。断言要表达业务结果(值/舍入/异常/边界,assertAll 组合避免遮蔽、assertThrows 验具体异常类型+失败后状态)。参数化测试(@ParameterizedTest + @ValueSource/@CsvSource/@MethodSource)数据驱动、一套断言跑多组数据。别滥用 @SpringBootTest(能 new 就 new,慢反馈害死单测)。