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()、工具类静态方法 |
| 手写 Fake | 用 HashMap 实现一个内存版 Repository | 同一依赖被十几个测试反复打桩时更省事 |
三个高频踩坑值得提前记住:其一,when(...) 打桩了却没被调用,Mockito 5 会抛 UnnecessaryStubbingException,这是提示你测试写多余了,别急着加 lenient() 掩盖;其二,verify 只能验证交互次数与参数,不能替代结果断言,两者都要写;其三,别 Mock 你自己要测的类,那等于什么都没测。
四、Spring Boot 切片测试:别动辄启动全上下文
Spring Boot 提供了一组”切片注解”,只加载某一层需要的 Bean,启动速度比 @SpringBootTest 快数倍。
| 注解 | 加载范围 | 典型用途 |
|---|---|---|
@WebMvcTest | Controller、参数校验、异常处理器,Service 需 @MockitoBean | 验证接口出入参、状态码、校验规则 |
@DataJpaTest | JPA 实体、Repository、事务(默认测试后回滚) | 验证复杂 JPQL、映射关系 |
@JsonTest | Jackson 序列化配置 | 验证日期格式、字段命名策略 |
@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 起已标记废弃,替换为 @MockitoBean(org.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 类,给它补三个测试:正常路径、异常路径、边界值。跑通之后你会发现,下一次改这段代码的心理成本会低很多。




