单元测试 JUnit
Java 中级 · 工程化
单元测试是重构的安全网。JUnit 5 是当前主流(JUnit 4 已停止演进),配合 Mockito 隔离外部依赖、AssertJ 写更可读的断言。本篇讲清 JUnit 5 的生命周期与原理、Mockito 如何「造假」,以及测试该怎么写才有价值。
一、JUnit 5 架构:三件套
JUnit 5 由三个子项目组成:
| 模块 | 作用 |
|---|---|
| JUnit Platform | 测试执行引擎的底层平台(对接 IDE/Maven/Gradle) |
| JUnit Jupiter | 新的编程模型与扩展(@Test、@BeforeEach 等,你日常用的) |
| JUnit Vintage | 兼容运行 JUnit 3/4 的老测试 |
xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>二、常用注解与生命周期
java
class UserServiceTest {
@BeforeAll
static void beforeAll() { } // 所有测试前一次(必须 static)
@BeforeEach
void setUp() { } // 每个测试方法前
@Test
@DisplayName("查询不存在的用户返回 null")
void testNotFound() { }
@AfterEach
void tearDown() { } // 每个测试方法后
@AfterAll
static void afterAll() { } // 所有测试后一次
}测试方法之间必须独立
JUnit 为每个测试方法创建新的测试类实例,所以成员变量不会跨方法污染。不要在测试间共享可变状态——这是「测试偶尔失败」的常见原因。
2.1 断言
java
import static org.junit.jupiter.api.Assertions.*;
@Test
void testAdd() {
assertEquals(3, calculator.add(1, 2));
assertTrue(calculator.isPositive(5));
assertNull(userRepo.findByName("nobody"));
assertThrows(IllegalArgumentException.class, () -> calculator.div(1, 0));
assertAll("批量断言", // 全部执行,一起报告
() -> assertEquals("Tom", user.getName()),
() -> assertEquals(18, user.getAge())
);
}2.2 参数化测试
java
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4})
void testIsPositive(int n) {
assertTrue(n > 0);
}
@ParameterizedTest
@CsvSource({"1, 2, 3", "0, 0, 0", "-1, 1, 0"})
void testAdd(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@MethodSource("provideUsers")
void testUserValid(User user, boolean valid) {
assertEquals(valid, validator.isValid(user));
}
static Stream<Arguments> provideUsers() {
return Stream.of(
Arguments.of(new User("Tom", 18), true),
Arguments.of(new User("", -1), false)
);
}2.3 条件测试与嵌套
java
@Nested
@DisplayName("当用户已登录时")
class WhenLoggedIn {
@Test @EnabledOnOs(OS.WINDOWS) void winOnly() { }
@Test @Disabled("暂时禁用,待修复 #123") void todo() { }
}三、Mockito:隔离外部依赖
单元测试应只测当前类,数据库、远程调用等用 mock 替代。
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock UserDao userDao; // 造一个假的 UserDao
@InjectMocks OrderService orderService; // 把 mock 注入被测对象
@Test
void testCreateOrder() {
// given:打桩
when(userDao.findById(1L)).thenReturn(new User("Tom", 18));
// when:执行
orderService.create(1L);
// then:验证行为
verify(userDao, times(1)).findById(1L);
verify(userDao, never()).delete(anyLong());
}
}| API | 作用 |
|---|---|
when(x).thenReturn(v) | 打桩:调用时返回 v |
when(x).thenThrow(e) | 调用时抛异常 |
doNothing() / doThrow() | void 方法打桩 |
verify(x, times(n)) | 验证调用次数 |
verify(x, never()) | 验证从未调用 |
ArgumentCaptor | 捕获参数做断言 |
spy(obj) | 部分真实、部分 mock |
java
// ArgumentCaptor:验证传进去的参数
ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
orderService.create(1L);
verify(orderDao).insert(captor.capture());
assertEquals("PENDING", captor.getValue().getStatus());3.1 Mockito 的原理
Mockito 内部也用了动态代理 / 字节码生成(ByteBuddy),运行时生成一个 mock 子类拦截方法调用,返回你打桩的默认值。
Mockito 的常见坑
- 过度 mock:把被测类的内部逻辑也 mock 掉,测了个寂寞。
- mock 静态方法需要
mockito-inline(Mockito 3.4+)或 Mockito 5 内置。 - 不支持 mock final 类/方法(除非 inline mock maker)。
- 不要在 mock 上做真实业务断言,mock 只是替身。
四、测试的层次与原则
FIRST 原则:
| 原则 | 含义 |
|---|---|
| Fast | 快(毫秒级),否则没人跑 |
| Independent | 独立,不依赖执行顺序 |
| Repeatable | 可重复,任何环境结果一致 |
| Self-validating | 自校验,用断言而非人工看输出 |
| Timely | 及时写(TDD 里先写测试) |
Spring Boot 测试分层
@SpringBootTest:启动完整容器,慢,用于集成测试。@WebMvcTest:只加载 Web 层(Controller),mock Service。@DataJpaTest/@MybatisTest:只测持久层,可配内存库(H2)。- 纯单元测试不要加 Spring 注解,直接 new +
@Mock。
五、测试覆盖率
用 JaCoCo 统计行覆盖/分支覆盖。
xml
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.11</version>
<executions>
<execution>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>别迷信覆盖率
覆盖率 100% 不等于没 BUG(可能只覆盖了 happy path,没覆盖边界)。追求核心逻辑的分支覆盖,而不是刷数字。断言质量比覆盖率更重要。
六、最佳实践
- 一个测试只验证一件事,方法名用
方法名_场景_期望(如withdraw_余额不足_抛异常)。 - 用 given/when/then 组织:准备数据 → 执行 → 断言。
- 只 mock 外部依赖(DB、RPC、第三方),不 mock 被测类自身逻辑。
- 遵循 FIRST,测试要快、独立、可重复。
- CI 里必跑测试,禁止跳过(
-DskipTests只用于紧急情况)。 - 测试代码也是代码,要维护、要重构、要审查。
📚 官方文档与参考资料
✏️ 练习与自测
@BeforeEach和@BeforeAll有什么区别?为什么@BeforeAll方法必须 static?- 为什么单元测试要用 Mockito?
@Mock和@InjectMocks分别做什么? - 写一个参数化测试,用
@CsvSource测试一个加法方法的多种输入。 - 什么是 FIRST 原则?为什么「测试要快、要独立」很重要?
本节小结
JUnit 5 = Platform + Jupiter + Vintage,生命周期是 BeforeAll → BeforeEach → Test → AfterEach → AfterAll,每个方法独立实例;Mockito 用字节码生成替身隔离外部依赖,when 打桩、verify 验证;测试遵循 FIRST 原则,只 mock 外部依赖、不 mock 自身逻辑,覆盖率看分支而非刷数字。