JUnit5
JUnit 5
目标:读完后能独立给 Maven 项目接入 JUnit 5,写出可靠的单元测试、参数化测试与异常测试,并能定位“IDE 能跑、Maven 不跑”等常见问题。
先理解 JUnit 5 的组成
JUnit 5 不是一个单独 JAR,而是三层体系:
| 组件 | 作用 |
|---|---|
| JUnit Platform | 发现、选择并启动测试;IDE、Maven、Gradle 都从这里接入 |
| JUnit Jupiter | JUnit 5 的注解、断言、扩展 API 与测试引擎 |
| JUnit Vintage | 兼容运行 JUnit 3/4 测试;新项目通常不需要 |
测试类写的是 Jupiter API,Surefire/IDE 通过 Platform 找到 Jupiter Engine,Engine 才真正执行测试。
Maven 项目接入
使用 BOM 统一 JUnit 组件版本:
代码块收起展开
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.2</version>
</plugin>
</plugins>
</build>junit-jupiter 聚合了 API、参数化测试和 Jupiter Engine。使用 Spring Boot 时通常由 spring-boot-starter-test 和父 POM/BOM 管版本,不要再重复塞入另一套 JUnit 版本。
常见测试目录:
src/test/java/: 测试代码src/test/resources/: 测试资源target/surefire-reports: Maven 单元测试报告
第一条测试
代码块收起展开
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
class CalculatorTest {
@Test
@DisplayName("两个正数相加")
void addsTwoPositiveNumbers() {
// Arrange
Calculator calculator = new Calculator();
// Act
int result = calculator.add(2, 3);
// Assert
assertEquals(5, result);
}
}JUnit 5 的测试类和测试方法可以是 package-private,不要求 public。推荐方法名表达行为,@DisplayName 补充人类可读说明。
生命周期
| 注解 | 执行时机 | 常见用途 |
|---|---|---|
@BeforeAll | 当前测试类所有测试之前一次 | 启动昂贵共享资源 |
@BeforeEach | 每个测试之前 | 创建干净对象、准备数据 |
@Test | 测试主体 | 验证一个行为 |
@AfterEach | 每个测试之后 | 释放本次测试资源 |
@AfterAll | 当前测试类所有测试之后一次 | 关闭共享资源 |
默认 @TestInstance(PER_METHOD):每个测试方法使用新的测试类实例,隔离更好;此时 @BeforeAll、@AfterAll 通常必须是 static。
代码块收起展开
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RepositoryTest {
@BeforeAll
void connectOnce() {
// PER_CLASS 下可以不是 static
}
}不要用生命周期共享可变业务状态,也不要让测试依赖执行顺序。每个测试应能单独运行。
核心注解
| 注解 | 用途 |
|---|---|
@Test | 普通测试 |
@DisplayName | 自定义显示名称 |
@Disabled | 暂时禁用,必须说明原因 |
@Tag | 给测试分类,如 unit、slow |
@Nested | 用内部类按场景组织测试 |
@RepeatedTest | 重复执行同一测试 |
@ParameterizedTest | 用多组输入运行同一逻辑 |
@TestFactory | 动态生成测试 |
@Timeout | 限制最长执行时间 |
@TempDir | 注入自动清理的临时目录 |
@ExtendWith | 注册 JUnit 5 扩展 |
断言
代码块收起展开
@Test
void validatesOrder() {
Order order = service.createOrder("book", 2);
assertAll(
() -> assertNotNull(order.id()),
() -> assertEquals("book", order.product()),
() -> assertEquals(2, order.quantity()),
() -> assertTrue(order.total().signum() > 0)
);
}常用断言:
assertEquals(expected, actual):相等;注意 expected 在前。assertTrue/assertFalse/assertNull/assertNotNull。assertAll:同组断言全部执行后统一报告。assertIterableEquals/assertArrayEquals:集合或数组。assertDoesNotThrow:明确要求不抛异常。assertTimeout:验证耗时上限。
浮点数比较应给误差:
代码块收起展开
assertEquals(0.3, 0.1 + 0.2, 1e-9);异常测试要拿到真正的异常对象:
代码块收起展开
@Test
void rejectsNegativeQuantity() {
IllegalArgumentException error = assertThrows(
IllegalArgumentException.class,
() -> service.createOrder("book", -1)
);
assertEquals("quantity must be positive", error.getMessage());
}参数化测试
简单数据使用 @ValueSource 或 @CsvSource:
代码块收起展开
@ParameterizedTest(name = "{0} 是否为偶数:{1}")
@CsvSource({
"2, true",
"3, false",
"0, true"
})
void detectsEvenNumber(int input, boolean expected) {
assertEquals(expected, Numbers.isEven(input));
}复杂对象使用 @MethodSource:
代码块收起展开
@ParameterizedTest
@MethodSource("invalidUsers")
void rejectsInvalidUser(User user, String message) {
ValidationException error = assertThrows(
ValidationException.class,
() -> validator.validate(user)
);
assertEquals(message, error.getMessage());
}
static Stream<Arguments> invalidUsers() {
return Stream.of(
Arguments.of(new User("", 20), "name is blank"),
Arguments.of(new User("Ada", -1), "age is invalid")
);
}其他来源包括 @NullSource、@EmptySource、@NullAndEmptySource、@EnumSource。参数化测试适合“规则相同、输入不同”的场景,不要把完全不同的行为硬塞进一个方法。
嵌套、重复与动态测试
代码块收起展开
@Nested
@DisplayName("余额不足时")
class WhenBalanceIsInsufficient {
@Test
void rejectsPayment() {
// 场景测试
}
}@Nested 让 Given/When 场景更清晰。@RepeatedTest(10) 适合验证重复执行行为,但不能证明随机算法正确;随机测试应固定 seed,失败时才能复现。
@TestFactory 返回 DynamicTest 集合或流,适合运行时才知道测试集合的场景。普通固定输入优先使用参数化测试,因为报告和失败定位更直观。
Tag、临时目录与超时
@Tag("slow")
@Test
@Timeout(2)
void finishesWithinTwoSeconds(@TempDir Path tempDir) {
// tempDir 在测试后自动清理
}mvn -Dgroups=unit test
mvn -DexcludedGroups=slow testassertTimeoutPreemptively 会在另一个线程执行代码,可能丢失 ThreadLocal、事务或安全上下文;涉及 Spring 事务时优先使用普通 assertTimeout 或框架级超时。
扩展模型与 Mockito
JUnit 5 用 Extension 统一替代 JUnit 4 的 Runner/Rule。Mockito 通过扩展接入:
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
OrderRepository repository;
@InjectMocks
OrderService service;
@Test
void savesCreatedOrder() {
when(repository.save(any())).thenAnswer(invocation -> invocation.getArgument(0));
Order order = service.create("book", 1);
assertEquals("book", order.product());
verify(repository).save(order);
verifyNoMoreInteractions(repository);
}
}Mock 适合隔离慢速或不可控边界,如数据库、网络、消息系统。不要 Mock 值对象、集合和被测类内部的每一个细节;测试应验证可观察行为,而不是锁死实现步骤。
测试分层
- 单元测试:数量最多、毫秒级、无外部 I/O。
- 组件/切片测试:验证一组协作对象或 Web/JPA 切片。
- 集成测试:连接真实数据库、容器或中间件,数量更少。
- 端到端测试:验证完整系统路径,最慢且维护成本最高。
JUnit 5 能承载各层测试,但是否加载 Spring Context、数据库或容器由项目测试策略决定。不要为了测一个纯函数启动整个应用。
Maven 执行方式
代码块收起展开
mvn test
mvn -Dtest=CalculatorTest test
mvn -Dtest=CalculatorTest#addsTwoPositiveNumbers test
mvn clean verifySurefire 通常执行单元测试;Failsafe 通常运行 *IT 集成测试并在 verify 阶段确认结果。只执行 package 可能漏掉 Failsafe 的最终校验。
常见问题
| 现象 | 优先检查 |
|---|---|
| Maven 显示 0 tests | 测试类命名、Surefire 版本、Jupiter Engine 是否存在 |
| IDE 能跑,Maven 不能 | mvn -v 的 JDK、有效 POM、IDE 自带运行器差异 |
@BeforeAll 报错 | 默认生命周期下方法是否为 static |
| 测试偶发失败 | 时间、随机数、共享状态、执行顺序、并发与外部服务 |
| 断言信息难读 | 是否一个测试塞了多个行为;是否该用 assertAll/参数化测试 |
| Spring 测试很慢 | 是否把本可纯单元测试的逻辑加载了整个 Context |
好测试检查清单
- 一个测试只验证一个清晰行为。
- Arrange / Act / Assert 边界清楚。
- 测试可独立运行,不依赖顺序和其他测试残留。
- 不访问真实时间、随机数和网络,或已通过注入控制。
- 正常路径、边界值、异常路径都有覆盖。
- 失败信息能直接说明业务行为哪里不符合预期。
- 没有长期无人处理的
@Disabled。 - 本地
mvn clean verify与 CI 结果一致。