学完概念该动手了。JUnit 5 从 2017 年发布至今,已经成为 Java 测试的事实标准。这可能是你第一次见到一套“官方重写、彻底改造”的测试框架——从架构到 API,都是全新的。这篇文章带你从零开始,把 JUnit 5 用起来。
JUnit 5 的架构
JUnit 5 不是你想象中“一个 jar 包搞定一切”的框架。它由三个模块组成:
┌─────────────────────────────────────────┐
│ JUnit 5 平台 │
│ (junit-platform-engine) │
│ 负责发现和运行测试 │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Jupiter │ │ Vintage │ │
│ │ (新 API) │ │ (兼容 JUnit 3/4)│ │
│ │ junit-jupiter │ │ junit-vintage │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────┘
- JUnit Platform:底层平台。IDE、Maven、Gradle 通过它来发现和执行测试
- JUnit Jupiter:我们写测试用的新 API(
@Test、@BeforeEach等) - JUnit Vintage:兼容层。让你在老旧的 JUnit 3/4 测试和新的 JUnit 5 测试共存
一个项目可以同时跑 JUnit 4 旧测试和 JUnit 5 新测试,平滑迁移——这就是 Vintage 的意义。
快速上手
Maven 配置
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>2.22.0</version>
</plugin>
</plugins>
</build>
注意:maven-surefire-plugin 必须 2.22.0 或以上,否则不识别 JUnit 5 的测试。
Gradle 配置
dependencies {
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.3.1'
testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.3.1'
}
test {
useJUnitPlatform()
}
跑第一个测试
mvn test
输出:
-------------------------------------------------------
T E S T S
-------------------------------------------------------
Running com.example.CalculatorTest
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
全绿,你写下了人生第一组 JUnit 5 测试。
核心注解
JUnit 5 的注解名称更直观了,一眼看出生命周期:
| 注解 | JUnit 4 对应 | 作用 |
|---|---|---|
@Test |
@Test |
标记一个测试方法 |
@BeforeEach |
@Before |
每个测试方法执行前运行 |
@AfterEach |
@After |
每个测试方法执行后运行 |
@BeforeAll |
@BeforeClass |
所有测试方法执行前运行一次(必须是 static) |
@AfterAll |
@AfterClass |
所有测试方法执行后运行一次(必须是 static) |
@Disabled |
@Ignore |
跳过该测试 |
区别在命名:BeforeEach vs Before——“Each”告诉你它是每个测试方法前都跑,“All”告诉你是全部跑一次。
class LifecycleDemoTest {
@BeforeAll
static void initAll() {
System.out.println("全局初始化——只跑一次");
}
@BeforeEach
void init() {
System.out.println("每个测试前");
}
@Test
void test1() {
System.out.println("测试 1");
}
@Test
void test2() {
System.out.println("测试 2");
}
@AfterEach
void tearDown() {
System.out.println("每个测试后");
}
@AfterAll
static void tearDownAll() {
System.out.println("全局清理——只跑一次");
}
}
输出:
全局初始化——只跑一次
每个测试前
测试 1
每个测试后
每个测试前
测试 2
每个测试后
全局清理——只跑一次
每个测试方法之间是隔离的——这点很重要。JUnit 会为每个 @Test 方法创建一个新的测试类实例,所以测试之间不会有状态污染。
断言
断言是测试的灵魂——期望什么,实际是什么,对不上就失败。
基础断言
import static org.junit.jupiter.api.Assertions.*;
@Test
void basicAssertions() {
assertEquals(5, calculator.add(2, 3));
assertTrue(list.isEmpty());
assertFalse(condition);
assertNull(result);
assertNotNull(user);
assertSame(expected, actual); // 同一个引用
assertNotSame(a, b);
}
分组断言
当你有多个断言要跑,希望它们全部失败时也能看到所有失败原因(而不止第一个):
@Test
void groupedAssertions() {
assertAll("user",
() -> assertEquals("张三", user.getName()),
() -> assertEquals(25, user.getAge()),
() -> assertTrue(user.isActive())
);
}
assertAll 会执行所有断言,即使第一个失败了也不会阻止后面的。你会看到完整的失败信息,而不是修完一个才发现下一个又错了。
异常断言
JUnit 4 用 @Test(expected=...) 的问题是:你只能“期望某方法抛异常”,但无法断言异常消息、异常发生在哪一行。JUnit 5 的 assertThrows 解决了这个问题:
@Test
void exceptionTesting() {
Exception exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(10, 0)
);
assertEquals("除数不能为零", exception.getMessage());
}
assertThrows 返回捕获的异常对象,你可以接着断言它的 message、cause 等。
超时断言
@Test
void timeoutNotExceeded() {
String result = assertTimeout(
Duration.ofSeconds(1),
() -> service.heavyComputation()
);
assertEquals("done", result);
}
超过 1 秒就失败。防止测试被死循环或网络阻塞卡住。
参数化测试
一个测试方法,多组输入数据——这就是参数化测试的价值:用一张数据表驱动多次测试执行。
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"0, 0, 0",
"-1, 1, 0",
"100, 200, 300"
})
void add(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
每组 CsvSource 数据会生成一次测试执行。输出报告:
[1] 1,1 → 2 √
[2] 2,3 → 5 √
[3] 0,0 → 0 √
[4] -1,1 → 0 √
[5] 100,200 → 300 √
如果第 3 组挂了,IDE 里可以直接知道是哪组数据的问题,不用一行行排查。
其他数据源
// 单值测试
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void shouldBeBlank(String input) {
assertTrue(StringUtils.isBlank(input));
}
// 方法提供数据
@ParameterizedTest
@MethodSource("provideNumbers")
void testWithMethodSource(int input, boolean expected) {
assertEquals(expected, NumberUtils.isEven(input));
}
static Stream<Arguments> provideNumbers() {
return Stream.of(
Arguments.of(2, true),
Arguments.of(3, false),
Arguments.of(0, true)
);
}
// 枚举所有枚举值
@ParameterizedTest
@EnumSource(DayOfWeek.class)
void testIsWorkday(DayOfWeek day) {
// ...
}
嵌套测试
当你的被测类有多个场景时,用嵌套测试把相关测试组织在一起:
@DisplayName("用户服务测试")
class UserServiceTest {
UserService service = new UserService();
@Nested
@DisplayName("注册功能")
class Register {
@Test
@DisplayName("合法的用户名和密码,注册成功")
void shouldRegisterWithValidInput() {
assertTrue(service.register("user1", "pass123"));
}
@Test
@DisplayName("用户名为空,抛出异常")
void shouldThrowWhenUsernameEmpty() {
assertThrows(IllegalArgumentException.class,
() -> service.register("", "pass123"));
}
}
@Nested
@DisplayName("登录功能")
class Login {
@Test
@DisplayName("正确的凭证,登录成功")
void shouldLoginWithCorrectCredentials() {
assertTrue(service.login("user1", "pass123"));
}
@Test
@DisplayName("错误的密码,登录失败")
void shouldFailWithWrongPassword() {
assertFalse(service.login("user1", "wrong"));
}
}
}
IDE 里会以树形结构展示:
用户服务测试
├── 注册功能
│ ├── ✓ 合法的用户名和密码,注册成功
│ └── ✓ 用户名为空,抛出异常
└── 登录功能
├── ✓ 正确的凭证,登录成功
└── ✓ 错误的密码,登录失败
@Nested 里的类还可以有自己的 @BeforeEach / @AfterEach,外层的生命周期先执行,内层的后执行。
显示名称
@DisplayName 让你用中文、空格、特殊符号描述测试意图:
@Test
@DisplayName("当用户名为空时 → 抛出 IllegalArgumentException")
void test1() { /* ... */ }
报告中会显示这个描述,而不是 test1 这样的无意义方法名。对团队协作来说,这比被迫用驼峰写英文方法名友好得多。
条件执行
根据运行环境决定是否跳过一个测试:
@Test
@EnabledOnOs(OS.MAC)
void onlyOnMac() { /* ... */ }
@Test
@EnabledOnOs(OS.WINDOWS)
void onlyOnWindows() { /* ... */ }
@Test
@EnabledOnJre(JRE.JAVA_9)
void onlyOnJava9() { /* ... */ }
@Test
@EnabledIfSystemProperty(named = "env", matches = "ci")
void onlyInCI() { /* ... */ }
从 JUnit 4 迁移
如果你有大量 JUnit 4 的测试代码,可以渐进式迁移:
- 在
pom.xml中同时引入junit-vintage-engine和junit-jupiter-engine - 老的 JUnit 4 测试不改,继续跑
- 新测试用 JUnit 5 API 写
<!-- 兼容 JUnit 4 旧测试 -->
<dependency>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
<version>5.3.1</version>
<scope>test</scope>
</dependency>
一个 mvn test,JUnit 4 和 JUnit 5 的测试一起跑,各自绿各自红,互不干扰。
小结
JUnit 5 不是 JUnit 4 的“升级版”,而是 Java 测试框架的重新思考:
- 模块化架构:Platform + Jupiter + Vintage,各司其职
- 更人性化的 API:
@BeforeEach、@DisplayName、assertThrows - 原生参数化测试:
@CsvSource、@MethodSource开箱即用 - 嵌套测试:按场景层层组织,树形报告
- 和平迁移:Vintage 引擎让新旧测试共存
下一篇,我们会遇到一个现实问题:被测对象依赖了数据库、HTTP 服务、文件系统——这些外部依赖怎么处理?这时候 Mockito 就该上场了。
每天前进一小步,就是一个新的高度!