Skip to content

单元测试 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 的常见坑

  1. 过度 mock:把被测类的内部逻辑也 mock 掉,测了个寂寞。
  2. mock 静态方法需要 mockito-inline(Mockito 3.4+)或 Mockito 5 内置。
  3. 不支持 mock final 类/方法(除非 inline mock maker)。
  4. 不要在 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 只用于紧急情况)。
  • 测试代码也是代码,要维护、要重构、要审查。

📚 官方文档与参考资料 ​

✏️ 练习与自测 ​

  1. @BeforeEach 和 @BeforeAll 有什么区别?为什么 @BeforeAll 方法必须 static?
  2. 为什么单元测试要用 Mockito?@Mock 和 @InjectMocks 分别做什么?
  3. 写一个参数化测试,用 @CsvSource 测试一个加法方法的多种输入。
  4. 什么是 FIRST 原则?为什么「测试要快、要独立」很重要?

本节小结

JUnit 5 = Platform + Jupiter + Vintage,生命周期是 BeforeAll → BeforeEach → Test → AfterEach → AfterAll,每个方法独立实例;Mockito 用字节码生成替身隔离外部依赖,when 打桩、verify 验证;测试遵循 FIRST 原则,只 mock 外部依赖、不 mock 自身逻辑,覆盖率看分支而非刷数字。