2. JUnit 5核心机制

JUnit 5 并不是一个单一 jar 包,而是由 JUnit Platform、JUnit Jupiter、JUnit Vintage 三层组成的测试框架。Platform 负责发现与执行测试,Jupiter 提供新的编程模型与扩展模型,Vintage 则用于兼容 JUnit 3/4 的老用例。理解这一分层,是理解注解、断言、生命周期与参数化测试如何协同工作的前提。本节围绕 Jupiter 的核心机制展开,重点说明它们在真实工程中如何组合使用,而不是孤立地记忆 API。

注解:测试的声明式入口

JUnit 5 通过注解把方法、类、参数标记为测试模型的一部分。最常用的注解包括:

  • @Test:标记一个测试方法,方法不能有返回值,通常也不应声明为 private。
  • @BeforeEach / @AfterEach:在每个测试方法前后执行,适合准备和清理独立于用例的轻量资源。
  • @BeforeAll / @AfterAll:在所有测试方法前后各执行一次,方法必须是 static(除非使用 @TestInstance(PER_CLASS))。
  • @DisplayName:为类或方法提供可读名称,报告中会替代方法名显示。
  • @Disabled:临时禁用测试,并建议写明原因。
  • @Nested:在内部类中组织相关测试,形成层次化结构。
  • @Tag:为测试打标签,配合构建工具按标签筛选执行。
  • @ParameterizedTest 与 @TestFactory:分别用于参数化测试和动态测试。

工程中常见的误区是把 @BeforeAll 当作“全局初始化”滥用。若初始化成本高且与用例无关,应放到测试套件级别或使用扩展;若初始化与每个用例状态相关,则更适合 @BeforeEach。注解的选择本质上是在表达“这段逻辑与测试生命周期的哪一段绑定”。

断言:从布尔判断到组合断言

JUnit 5 的断言集中在 org.junit.jupiter.api.Assertions,相比 JUnit 4 增加了 assertAll、assertThrows、assertTimeoutPreemptively 等更贴近工程需求的 API。基本断言如 assertEquals、assertTrue、assertNull 用于单点判断;assertThrows 用于验证异常类型与消息;assertAll 则允许把多个断言组合成一个“软断言”集合,即使其中一个失败,其余断言仍会执行并汇总报告。

@Test
void shouldRejectNegativeAmount() {
    IllegalArgumentException ex = assertThrows(
        IllegalArgumentException.class,
        () -> account.withdraw(-1)
    );
    assertEquals("amount must be positive", ex.getMessage());
}

@Test
void shouldMatchUserProfile() {
    User user = userService.load(42L);
    assertAll("user profile",
        () -> assertEquals("alice", user.getName()),
        () -> assertEquals("ACTIVE", user.getStatus()),
        () -> assertTrue(user.getRoles().contains("ADMIN"))
    );
}

需要强调的是,断言消息应描述“期望什么”,而不是“发生了什么”。例如 assertEquals(expected, actual, "余额应为 100") 比“余额不对”更有助于定位问题。对于浮点数比较,应使用带 delta 的重载;对于集合,应优先比较内容而非引用。

生命周期:实例化策略与执行顺序

JUnit 5 默认对每个测试方法创建一个新的测试类实例,这保证了用例之间的隔离。若确实需要共享实例状态,可使用 @TestInstance(Lifecycle.PER_CLASS),此时 @BeforeAll 和 @AfterAll 可以是非 static 方法。执行顺序方面,JUnit 5 默认不保证方法顺序,但可以通过 @TestMethodOrder 配合 @Order 显式指定。工程中应尽量避免依赖顺序,因为顺序依赖往往意味着用例之间存在隐式耦合。

生命周期与断言的配合点在于:@BeforeEach 中准备的数据应被视为“每个用例的初始状态”,而不是“全局可变状态”。如果多个用例共享同一个可变对象,即使断言正确,也可能因为执行顺序不同而出现偶发失败。

参数化测试:用数据驱动覆盖边界

参数化测试通过 @ParameterizedTest 与数据源注解组合,把同一段测试逻辑应用到多组输入上。常用数据源包括 @ValueSource、@CsvSource、@CsvFileSource、@MethodSource 和 @EnumSource。其中 @MethodSource 最灵活,可以返回 Stream、Iterable 或 Arguments 流,适合构造复杂对象。

@ParameterizedTest(name = "金额 {0} 应被拒绝")
@ValueSource(ints = {-1, 0, Integer.MIN_VALUE})
void shouldRejectInvalidAmount(int amount) {
    assertThrows(IllegalArgumentException.class,
        () -> account.withdraw(amount));
}

@ParameterizedTest
@CsvSource({
    "100, 20, 80",
    "100, 100, 0",
    "50, 0, 50"
})
void shouldWithdrawCorrectly(int initial, int amount, int expected) {
    Account acc = new Account(initial);
    acc.withdraw(amount);
    assertEquals(expected, acc.getBalance());
}

参数化测试的价值在于把“边界值、等价类、异常输入”显式地表达为数据表,而不是复制多份测试方法。工程中建议为每组数据提供可读的 name,并在数据量较大时使用 @CsvFileSource 从外部文件加载,便于非开发人员参与维护。

动态测试:运行时生成用例

动态测试通过 @TestFactory 返回 DynamicTest 或 DynamicContainer 的流,在运行时生成测试用例。它适合用例数量取决于外部数据、配置或文件内容的场景,例如校验一批 JSON 配置、遍历目录中的样例文件。与参数化测试的区别在于:参数化测试在编译期确定数据源结构,动态测试则在运行时决定用例集合。

@TestFactory
Stream<DynamicTest> shouldValidateAllConfigs() {
    return configLoader.loadAll().stream()
        .map(cfg -> dynamicTest(
            "配置 " + cfg.name(),
            () -> assertTrue(validator.isValid(cfg))
        ));
}

动态测试的失败信息需要特别设计,因为用例名称是运行时生成的,应包含足够的上下文,例如配置文件名或数据标识,否则报告难以定位。

机制协同关系

下面这张图把注解、断言、生命周期、参数化与动态测试放在同一条执行链上,帮助理解它们各自的位置与交互关系。

JUnit 5 Jupiter 核心机制协同视图 生命周期轨道 @BeforeAll @BeforeEach @Test 执行 @AfterEach @AfterAll 测试来源 @Test 普通用例 @ParameterizedTest 数据驱动 数据源 @ValueSource / @CsvSource @MethodSource / @CsvFileSource 动态生成 @TestFactory DynamicTest / DynamicContainer 断言层 Assertions assertEquals / assertThrows / assertAll / assertTimeout 测试报告:通过 / 失败 / 跳过

从图中可以看到,生命周期轨道负责“何时执行”,测试来源与数据源负责“执行什么”,断言层负责“如何判定”,三者共同决定最终报告。工程化使用 JUnit 5 的关键,不是记住所有注解,而是根据测试目标选择正确的机制组合:独立用例用 @Test + @BeforeEach,边界覆盖用 @ParameterizedTest + @CsvSource,运行时数据用 @TestFactory,异常路径用 assertThrows,多字段校验用 assertAll。这样写出的测试既清晰,也更容易在持续集成中稳定运行。