为什么要从 JUnit 4 升级到 JUnit 5
很多团队的 pom.xml 里至今还躺着 junit:junit:4.x。JUnit 4 已经停止主线维护多年,但因为足够"够用",迟迟没人动。
升级到 JUnit 5 不是为了赶时髦,而是它解决了 JUnit 4 时代积攒的几个核心痛点:
- 架构臃肿:JUnit 4 把整个框架塞在一个 jar 里,运行器(Runner)和扩展点(Rule)冲突,不能叠加
- 没有原生参数化测试:要靠
Parameterized Runner,写法别扭 - 嵌套测试丑陋:要用
@RunWith(Enclosed.class) - 断言能力弱:复合断言、异常断言要靠第三方库
- 不支持 Java 8+ Lambda:很多优雅写法用不上
JUnit 5 推倒重来,拆成三个模块:
flowchart LR
Platform["JUnit Platform
(运行平台)"]
Jupiter["JUnit Jupiter
(JUnit 5 编程模型)"]
Vintage["JUnit Vintage
(兼容跑 JUnit 3/4)"]
Platform --> Jupiter
Platform --> Vintage- 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 4 | JUnit 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 实现各种生命周期接口(BeforeEachCallback、AfterEachCallback、ParameterResolver 等),可以做到"在每个测试方法前自动初始化数据"、“自动清理数据库”、“自动注入参数"等。
八、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>
|
迁移建议:
- 先把 BUILD 配置好,让两套都能跑
- 新写的测试一律 Jupiter
- 改老代码时顺手把对应测试迁移过去
- 一两个迭代后,老测试全部迁完,移除 vintage 依赖
十、踩坑提醒
@BeforeAll/@AfterAll 默认要 static——除非用 @TestInstance(PER_CLASS)@Test 不要再带 expected = Xxx.class——JUnit 5 没有这个属性,用 assertThrows- 包还是
org.junit.jupiter.api——别混用 org.junit.* - Maven Surefire 要 ≥ 2.22,否则跑不起来 Jupiter 测试
- 参数化测试单独依赖
junit-jupiter-params @SpringBootTest 不要乱用——它会启动整个上下文,单测应该用切片
小结
JUnit 5 不是"JUnit 4 的小升级”,是为 Lambda、模块化、现代 Java 重新设计的测试框架。
把这一篇压一句:
写新测试用 Jupiter,老测试靠 Vintage 兼容,慢慢迁;高频场景吃透 assertAll / 参数化 / @Nested,这三个特性能把单测写得像文档。
测试本身不是负担,写得好的测试是最准确的需求说明书。JUnit 5 给了你写出这种文档的工具,能不能用好是另一回事。