Featured image of post JUnit 4 到 JUnit 5:Java 测试框架的迁移与实战

JUnit 4 到 JUnit 5:Java 测试框架的迁移与实战

JUnit 5 不只是『JUnit 4 的升级』,它是为现代 Java 重新设计的测试框架。一篇带你过完所有常用与进阶用法

为什么要从 JUnit 4 升级到 JUnit 5

很多团队的 pom.xml 里至今还躺着 junit:junit:4.xJUnit 4 已经停止主线维护多年,但因为足够"够用",迟迟没人动。

升级到 JUnit 5 不是为了赶时髦,而是它解决了 JUnit 4 时代积攒的几个核心痛点:

  • 架构臃肿:JUnit 4 把整个框架塞在一个 jar 里,运行器(Runner)和扩展点(Rule)冲突,不能叠加
  • 没有原生参数化测试:要靠 Parameterized Runner,写法别扭
  • 嵌套测试丑陋:要用 @RunWith(Enclosed.class)
  • 断言能力弱:复合断言、异常断言要靠第三方库
  • 不支持 Java 8+ Lambda:很多优雅写法用不上

JUnit 5 推倒重来,拆成三个模块:

  • Platform:测试引擎的标准接口,IDE/Maven/Gradle 都对它编程
  • Jupiter:你写的 @Test 用的就是它
  • Vintage:让 JUnit 4 测试在 Platform 上继续跑,平滑迁移

下面我们用一个个能跑的例子,把 JUnit 5 的常用与进阶用法过一遍。


一、最小可运行例子

1
2
3
4
5
6
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class CalculatorTest {
    @Test
    void add_should_return_sum() {
        Calculator c = new Calculator();
        assertEquals(5, c.add(2, 3));
    }
}

注意——类和方法都不再需要 public,包私有就够了,因为 JUnit 5 用反射访问。这是个微小但很贴心的改进。


二、生命周期注解

JUnit 4 → JUnit 5 注解对照:

JUnit 4JUnit 5
@Before@BeforeEach
@After@AfterEach
@BeforeClass@BeforeAll
@AfterClass@AfterAll
@Ignore@Disabled
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class LifecycleTest {

    @BeforeAll
    static void beforeAll() { /* 整个测试类前执行一次 */ }

    @BeforeEach
    void beforeEach() { /* 每个 @Test 前执行 */ }

    @Test
    void test1() { /* ... */ }

    @AfterEach
    void afterEach() { /* 每个 @Test 后执行 */ }

    @AfterAll
    static void afterAll() { /* 整个测试类后执行一次 */ }
}

@BeforeAll/@AfterAll 默认必须 static。如果不想写 static,把测试类加上 @TestInstance(Lifecycle.PER_CLASS),整个类用一个实例,方法就不必 static 了——但要注意此时测试方法之间会共享实例字段。


三、断言:丰富又优雅

JUnit 5 的 Assertions 是终于"现代化"了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Test
void richAssertions() {
    User u = userService.get(1L);

    // 基础
    assertNotNull(u);
    assertEquals("张三", u.getName());

    // 带 message(lambda 形式,断言失败前才计算字符串)
    assertEquals(20, u.getAge(), () -> "用户 " + u.getId() + " 年龄异常");

    // 一次性断言多个条件,全部都要执行
    assertAll("user properties",
        () -> assertEquals("张三", u.getName()),
        () -> assertEquals(20, u.getAge()),
        () -> assertTrue(u.getEmail().endsWith("@example.com"))
    );

    // 异常断言
    Exception ex = assertThrows(IllegalArgumentException.class,
            () -> userService.get(-1L));
    assertEquals("id must be positive", ex.getMessage());

    // 不抛异常断言
    assertDoesNotThrow(() -> userService.get(1L));

    // 超时断言(同步)
    assertTimeout(Duration.ofMillis(500), () -> userService.heavyTask());
    // 超时断言(一旦超时立刻终止任务)
    assertTimeoutPreemptively(Duration.ofMillis(500), () -> userService.heavyTask());
}

四、参数化测试:JUnit 5 的杀手锏

参数化测试是 JUnit 5 最值得单独学的特性。一个测试方法,多套输入,输出是多个独立测试用例——失败时 IDE 里能看到每个用例的结果。

需要额外依赖:

1
2
3
4
5
6
<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

1. @ValueSource 单参数

1
2
3
4
5
@ParameterizedTest
@ValueSource(strings = {"   ", "\t", "\n"})
void blank_strings_should_be_blank(String input) {
    assertTrue(StringUtils.isBlank(input));
}

2. @CsvSource 多参数

1
2
3
4
5
6
7
8
9
@ParameterizedTest(name = "[{index}] {0} + {1} = {2}")
@CsvSource({
    "1, 1, 2",
    "2, 3, 5",
    "10, 20, 30"
})
void add(int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}

name 属性让每个用例在 IDE 里有清晰的标签,强烈推荐。

3. @CsvFileSource 从 CSV 文件读

1
2
3
4
5
@ParameterizedTest
@CsvFileSource(resources = "/test-cases.csv", numLinesToSkip = 1)
void fromCsv(int a, int b, int expected) {
    assertEquals(expected, calculator.add(a, b));
}

适合和测试人员协作——他们维护 CSV,开发不动代码。

4. @MethodSource 复杂数据

参数复杂时用方法源:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@ParameterizedTest
@MethodSource("userTestData")
void create(UserDTO input, boolean expectedValid) {
    assertEquals(expectedValid, userValidator.isValid(input));
}

static Stream<Arguments> userTestData() {
    return Stream.of(
        Arguments.of(new UserDTO("张三", 20), true),
        Arguments.of(new UserDTO("", 20), false),
        Arguments.of(new UserDTO("李四", -1), false)
    );
}

5. @EnumSource 遍历枚举

1
2
3
4
5
@ParameterizedTest
@EnumSource(value = OrderStatus.class, mode = Mode.EXCLUDE, names = {"DELETED"})
void allActiveStatusesShouldDisplayCorrectly(OrderStatus s) {
    assertNotNull(s.getDisplayName());
}

五、嵌套测试:让用例分组更优雅

@Nested 让测试类按场景分层,IDE 里看上去就是一棵树:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class UserServiceTest {

    @Nested
    @DisplayName("当用户已存在时")
    class WhenUserExists {
        @BeforeEach void setup() { /* 准备已存在用户 */ }

        @Test void getById_returns_user() { ... }
        @Test void update_should_succeed() { ... }
    }

    @Nested
    @DisplayName("当用户不存在时")
    class WhenUserNotFound {
        @Test void getById_returns_null() { ... }
        @Test void update_should_throw() { ... }
    }
}

@DisplayName 把测试名换成中文/句子式描述,让测试报告读起来像需求文档


六、条件执行:根据环境跳过

JUnit 5 提供了一组 @EnabledOn* / @DisabledOn*

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@Test
@EnabledOnOs(OS.LINUX)
void only_on_linux() { ... }

@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void only_on_ci() { ... }

@Test
@DisabledIfEnvironmentVariable(named = "SKIP_HEAVY", matches = "true")
void heavy_test() { ... }

@Ignore 灵活得多。


七、扩展模型:替代 JUnit 4 的 Rule

JUnit 4 的 @Rule 不能多个叠加、不能跨类复用。JUnit 5 用统一的 Extension 模型替代:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock UserRepository repo;
    @InjectMocks UserService service;

    @Test
    void getById_returns_user() {
        when(repo.findById(1L)).thenReturn(Optional.of(new User()));
        assertNotNull(service.getById(1L));
    }
}

@ExtendWith 可以叠加多个,也可以在方法级生效,灵活性远超 @Rule

自定义 Extension 实现各种生命周期接口(BeforeEachCallbackAfterEachCallbackParameterResolver 等),可以做到"在每个测试方法前自动初始化数据"、“自动清理数据库”、“自动注入参数"等。


八、Spring Boot 测试整合

Spring Boot 2.2+ 默认就用 JUnit 5。不需要任何额外配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIT {

    @Autowired MockMvc mvc;
    @MockBean UserService service;

    @Test
    void getUser_should_return_200() throws Exception {
        when(service.get(1L)).thenReturn(new User(1L, "张三"));

        mvc.perform(get("/users/1"))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.name").value("张三"));
    }
}

值得一提的几个 Spring Boot 测试切片注解(瘦身的 @SpringBootTest):

注解用途
@WebMvcTest只加载 Controller 层
@DataJpaTest只加载 JPA 仓库
@JsonTest只测试 JSON 序列化
@DataRedisTest只加载 Redis 相关 Bean
@RestClientTest测试 RestTemplate / FeignClient

切片测试启动快得多,单测就用切片,集成测试再用 @SpringBootTest


九、JUnit 4 兼容:渐进式迁移

老项目里两套测试可以共存——保留 junit:junit 作为 Vintage 引擎,新增测试用 Jupiter:

1
2
3
4
5
6
<dependency>
    <groupId>org.junit.vintage</groupId>
    <artifactId>junit-vintage-engine</artifactId>
    <version>5.10.0</version>
    <scope>test</scope>
</dependency>

迁移建议:

  1. 先把 BUILD 配置好,让两套都能跑
  2. 新写的测试一律 Jupiter
  3. 改老代码时顺手把对应测试迁移过去
  4. 一两个迭代后,老测试全部迁完,移除 vintage 依赖

十、踩坑提醒

  1. @BeforeAll/@AfterAll 默认要 static——除非用 @TestInstance(PER_CLASS)
  2. @Test 不要再带 expected = Xxx.class——JUnit 5 没有这个属性,用 assertThrows
  3. 包还是 org.junit.jupiter.api——别混用 org.junit.*
  4. Maven Surefire 要 ≥ 2.22,否则跑不起来 Jupiter 测试
  5. 参数化测试单独依赖 junit-jupiter-params
  6. @SpringBootTest 不要乱用——它会启动整个上下文,单测应该用切片

小结

JUnit 5 不是"JUnit 4 的小升级”,是为 Lambda、模块化、现代 Java 重新设计的测试框架

把这一篇压一句:

写新测试用 Jupiter,老测试靠 Vintage 兼容,慢慢迁;高频场景吃透 assertAll / 参数化 / @Nested,这三个特性能把单测写得像文档。

测试本身不是负担,写得好的测试是最准确的需求说明书。JUnit 5 给了你写出这种文档的工具,能不能用好是另一回事。

使用 Hugo 构建
主题 StackJimmy 设计