Java 单元测试实战:JUnit 5 与 Mockito 指南

Java 单元测试是工程质量的第一道闸门,但真实团队里它常常是最先被砍掉的环节:需求排期紧、测试写起来比业务代码还费劲、跑一次要等两分钟连数据库。问题的根源往往不是不会用 JUnit,而是分层没想清楚、依赖没隔离掉。本文按”单元测试 → 依赖 Mock → Spring 切片测试 → Testcontainers 集成测试 → 覆盖率门禁”的顺序,给出一套可直接复制到项目里的实践。

文中示例基于 JDK 17 + Spring Boot 3.x + JUnit 5(Jupiter)+ Mockito 5.x + Testcontainers 1.20.x,所有命令与配置均可直接运行。如果你的项目还在 Spring Boot 2.x,先看这篇 Spring Boot 3 升级踩坑实录 再回来落地测试体系。

一、为什么你的单测总是写不下去

先别急着写第一个 @Test。绝大多数”单测半途而废”的项目,都踩了下面三个误区中的至少一个。

常见误区症状正确做法
把集成测试当单元测试写每个测试都 @SpringBootTest,启动一次上下文 20 秒,跑全量要 10 分钟业务逻辑用纯 JUnit + Mockito,只在必要时启动上下文
依赖真实数据库本地能跑、CI 一定挂;测试之间脏数据互相污染Repository 层用 Testcontainers,Service 层直接 Mock
追求覆盖率数字给 getter/setter 写测试,覆盖率 80% 但线上照样出 bug只测有分支、有边界、有算术的代码,覆盖率作为下限而非目标

单元测试的三条硬指标

一个测试能不能叫”单元测试”,用三个指标判断:(单个方法级测试应在 10ms 内跑完)、独立(不依赖执行顺序、不依赖外部网络与数据库)、可重复(今天跑和三个月后跑结果一致,不受当前日期、随机数、机器时区影响)。任何一条不满足,它就是集成测试,应该放到另一个 Maven profile 里跑,而不是混在快速反馈回路里。

二、JUnit 5 核心:断言、参数化与生命周期

Spring Boot 3 的 spring-boot-starter-test 已经打包了 JUnit 5、Mockito、AssertJ 和 JSONassert,一般不需要额外引入依赖,只在用到 Testcontainers 时补几个坐标。

<!-- pom.xml:测试依赖只需这些 -->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<!-- 集成测试用真实中间件时再加 -->
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>mysql</artifactId>
  <version>1.20.4</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>

1. 用 AssertJ 写可读的断言

JUnit 自带的 assertEquals 够用但可读性差,AssertJ 的链式断言在失败时给出的信息更精确。重点是 assertThrows——业务代码的异常分支往往才是线上真正会走到的路径。

import static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertThrows;

class CouponCalculatorTest {

    private final CouponCalculator calculator = new CouponCalculator();

    @Test
    @DisplayName("满减券:订单金额达到门槛时正常抵扣")
    void shouldDeductWhenAmountReachesThreshold() {
        Money result = calculator.apply(Money.of("100.00"),
                                        Coupon.fullCut("20.00", "99.00"));
        assertThat(result.value()).isEqualByComparingTo("80.00");
    }

    @Test
    @DisplayName("满减券:金额不足门槛应原价返回,不抛异常")
    void shouldKeepOriginalWhenBelowThreshold() {
        Money result = calculator.apply(Money.of("50.00"),
                                        Coupon.fullCut("20.00", "99.00"));
        assertThat(result.value()).isEqualByComparingTo("50.00");
    }

    @Test
    @DisplayName("券面额大于订单金额应拒绝,避免负数应付")
    void shouldRejectWhenCouponExceedsAmount() {
        IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
            () -> calculator.apply(Money.of("10.00"), Coupon.fullCut("20.00", "0.00")));
        assertThat(ex).hasMessageContaining("券面额");
    }
}

2. 参数化测试:一次覆盖所有边界

金额、分页、状态机这类”同一逻辑多组输入”的场景,用 @ParameterizedTest 比复制粘贴三个方法优雅得多,也更容易在评审时看出边界是否遗漏——这一点在 代码评审文化 里同样重要。

@ParameterizedTest(name = "[{index}] 金额={0} 券={1} 期望={2}")
@CsvSource({
    "100.00, 20.00, 80.00",
    "99.00,  20.00, 79.00",
    "98.99,  20.00, 98.99",   // 差一分不满门槛,边界
    "0.01,   20.00, 0.01"     // 极小金额
})
void fullCutBoundary(String amount, String cut, String expected) {
    Money result = calculator.apply(Money.of(amount), Coupon.fullCut(cut, "99.00"));
    assertThat(result.value()).isEqualByComparingTo(expected);
}

@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void blankCouponCodeShouldBeRejected(String code) {
    assertThatThrownBy(() -> Coupon.byCode(code))
        .isInstanceOf(IllegalArgumentException.class);
}

生命周期上记住一点:JUnit 5 默认每个测试方法都会新建一个测试类实例,所以字段天然隔离,不需要在 @BeforeEach 里手工重置。只有标注 @TestInstance(PER_CLASS) 时才需要自己清理状态。

三、Mockito:把外部依赖挡在测试之外

Service 层的测试之所以难写,是因为它依赖 Repository、RPC 客户端、消息队列。Mockito 的作用就是把这些依赖替换成”听话的替身”,让测试只关注业务分支。

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock  private OrderRepository orderRepository;
    @Mock  private InventoryClient inventoryClient;
    @InjectMocks private OrderService orderService;

    @Test
    void shouldRollbackWhenInventoryInsufficient() {
        // given:库存客户端返回不足
        when(inventoryClient.tryLock(1001L, 2)).thenReturn(false);

        // when / then
        assertThatThrownBy(() -> orderService.create(new OrderCmd(1001L, 2)))
            .isInstanceOf(InsufficientStockException.class);

        // 关键:验证没有落库,避免"库存失败但订单已生成"的脏数据
        verify(orderRepository, never()).save(any());
        verify(inventoryClient, times(1)).tryLock(1001L, 2);
    }

    @Test
    void shouldPersistOrderWithSnapshotPrice() {
        when(inventoryClient.tryLock(anyLong(), anyInt())).thenReturn(true);
        when(orderRepository.save(any(Order.class)))
            .thenAnswer(inv -> inv.getArgument(0));

        orderService.create(new OrderCmd(1001L, 2));

        // ArgumentCaptor:断言"传进去的对象长什么样"
        ArgumentCaptor<Order> captor = ArgumentCaptor.forClass(Order.class);
        verify(orderRepository).save(captor.capture());
        assertThat(captor.getValue().getStatus()).isEqualTo(OrderStatus.CREATED);
        assertThat(captor.getValue().getSnapshotPrice()).isNotNull();
    }
}

Mock、Spy、Stub 怎么选

手段行为适用场景
@Mock全部方法默认返回 null / 0 / false,需显式 when绝大多数外部依赖(DB、RPC、MQ)
@Spy默认走真实实现,只对指定方法打桩只想替换一两个方法的遗留大类
mockStatic拦截静态方法(需 mockito-inline,5.x 已默认开启)LocalDateTime.now()、工具类静态方法
手写 FakeHashMap 实现一个内存版 Repository同一依赖被十几个测试反复打桩时更省事

三个高频踩坑值得提前记住:其一,when(...) 打桩了却没被调用,Mockito 5 会抛 UnnecessaryStubbingException,这是提示你测试写多余了,别急着加 lenient() 掩盖;其二,verify 只能验证交互次数与参数,不能替代结果断言,两者都要写;其三,别 Mock 你自己要测的类,那等于什么都没测。

四、Spring Boot 切片测试:别动辄启动全上下文

Spring Boot 提供了一组”切片注解”,只加载某一层需要的 Bean,启动速度比 @SpringBootTest 快数倍。

注解加载范围典型用途
@WebMvcTestController、参数校验、异常处理器,Service 需 @MockitoBean验证接口出入参、状态码、校验规则
@DataJpaTestJPA 实体、Repository、事务(默认测试后回滚)验证复杂 JPQL、映射关系
@JsonTestJackson 序列化配置验证日期格式、字段命名策略
@SpringBootTest完整上下文端到端冒烟,全项目控制在 5 个以内
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired private MockMvc mockMvc;
    // Spring Boot 3.4+ 用 @MockitoBean 替代已废弃的 @MockBean
    @MockitoBean private OrderService orderService;

    @Test
    void shouldReturn400WhenQuantityIsZero() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"skuId": 1001, "quantity": 0}
                    """))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.message").value(containsString("quantity")));
    }

    @Test
    void shouldReturn200WithOrderNo() throws Exception {
        when(orderService.create(any())).thenReturn(new OrderVO("ORD20260829001"));

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    {"skuId": 1001, "quantity": 2}
                    """))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.orderNo").value("ORD20260829001"));
    }
}

注意 @MockBean 在 Spring Boot 3.4 起已标记废弃,替换为 @MockitoBeanorg.springframework.test.context.bean.override.mockito 包下)。持久层测试涉及的 N+1 与批量写入问题,可对照 MyBatis 与 JPA 性能优化 一起验证——把”查询次数”写成断言,性能回归才拦得住。

五、Testcontainers:用真实中间件跑集成测试

H2 内存库能让测试跑起来,但方言差异会让”本地全绿、生产报错”。Testcontainers 用 Docker 起一个真实 MySQL/Redis,测试结束自动销毁,是目前最可靠的集成测试方案。前提是 CI 机器有 Docker 环境,容器基础可参考 Docker 入门实战

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0.39")
            .withDatabaseName("shop")
            .withReuse(true);   // 本地复用容器,二次运行秒起

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
        registry.add("spring.flyway.enabled", () -> "true");
    }

    @Autowired private OrderRepository repository;

    @Test
    void shouldFindByStatusWithRealMySqlDialect() {
        repository.save(Order.newOrder(1001L, 2));
        List<Order> list = repository.findByStatus(OrderStatus.CREATED);
        assertThat(list).hasSize(1)
                        .first().extracting(Order::getSkuId).isEqualTo(1001L);
    }
}

三点工程化建议:命名上把集成测试统一以 IT 结尾,用 Maven Failsafe 插件在 verify 阶段单独跑,不拖慢 mvn test;本地开启 ~/.testcontainers.properties 里的 testcontainers.reuse.enable=true,容器复用能把启动从 20 秒降到 2 秒;schema 交给 Flyway 管理并设 ddl-auto=validate,这样迁移脚本本身也被测试覆盖。

六、覆盖率门禁:让 CI 替你守住底线

测试写了没人看等于没写。用 JaCoCo 设一条最低线,低于阈值直接构建失败,比在评审里反复提醒有效得多。

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.12</version>
  <executions>
    <execution><goals><goal>prepare-agent</goal></goals></execution>
    <execution>
      <id>check</id><phase>verify</phase>
      <goals><goal>report</goal><goal>check</goal></goals>
      <configuration>
        <rules>
          <rule>
            <element>BUNDLE</element>
            <limits>
              <limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.60</minimum></limit>
              <limit><counter>BRANCH</counter><value>COVEREDRATIO</value><minimum>0.50</minimum></limit>
            </limits>
          </rule>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>
# 本地:只跑快测试,秒级反馈
mvn -q test

# 提交前:跑集成测试 + 覆盖率门禁
mvn verify

# 报告位置
open target/site/jacoco/index.html

# 只跑单个类 / 单个方法
mvn test -Dtest=OrderServiceTest
mvn test -Dtest='OrderServiceTest#shouldRollbackWhenInventoryInsufficient'

mvn verify 挂到流水线上,PR 未过门禁不允许合并,具体配置方式见 GitHub Actions CI/CD 实战。阈值建议从 60% 行覆盖起步、每季度上调 5 个点,一上来就要求 80% 只会逼出一堆无意义的断言测试。

七、可以直接抄的五条纪律

  • 命名讲人话shouldRollbackWhenInventoryInsufficient 优于 test1,配合 @DisplayName 让报告可读。
  • 一个测试只测一件事:断言超过五条通常意味着该拆成两个测试。
  • 先写失败用例:修 bug 时先补一个能复现的测试,再改代码,这样回归被永久锁住——这也是技术复盘里最有价值的改进项之一。
  • 杜绝时间与随机:注入 Clock 而非直接调 LocalDateTime.now(),否则跨月跑一定挂。
  • 快慢分离:单元测试进 mvn test,集成测试进 mvn verify,本地反馈永远保持在 30 秒内。

另外别忽视 GC 与内存对测试稳定性的影响:并行跑测试时堆设置过小会随机 OOM,参数调优可参考 JVM 内存模型与 GC 调优。前端同学想搭同一套质量闸门,可看 前端自动化测试:Vitest 与 Playwright 实战,思路完全一致。

结语

Java 单元测试的难点从来不在 API,而在分层判断:哪一层用纯 JUnit、哪一层 Mock 掉依赖、哪一层必须起真实容器。把这条线划清楚,测试就从”额外负担”变成”重构时的安全网”。建议今天先做一件事——挑一个最近改过三次以上的 Service 类,给它补三个测试:正常路径、异常路径、边界值。跑通之后你会发现,下一次改这段代码的心理成本会低很多。

上一篇 技术复盘实战:用 Postmortem 把故障变成团队资产
下一篇 Arthas 线上诊断实战:Java 不停机排障指南