学完这篇,你能把项目里的测试按「单元—集成—端到端」分层搭起来,并用 JaCoCo 把覆盖率变成一条能卡住合并请求的硬性门槛。
第一步:先把“测试”这个词拆开
很多项目的测试目录烂掉,是因为所有测试都叫“单元测试”:有启动 Spring 容器的、有连真实数据库的、有断言 HTTP 状态码的,跑一次要八分钟。先按职责分层:
| 层 | 测什么 | 工具 | 单次耗时目标 |
|---|
| 单元测试 | 纯业务逻辑、算法、校验 | JUnit 5 + Mockito + AssertJ | 毫秒级 |
| 集成测试 | 仓储、SQL、事务、序列化 | Spring Boot Test + Testcontainers | 秒级 |
| 契约测试 | 对外接口字段不漂移 | Spring Cloud Contract / 手写 JSON Schema 断言 | 秒级 |
| 端到端 | 关键链路(下单、支付回调) | RestAssured / Playwright | 分钟级 |
分层不是为好看,是为了让你在本地能几秒钟跑完单元测试、提交前能跑完全部集成测试,而端到端只在 CI 的合并后阶段跑。
注意:不要用 *Test 后缀混装所有层。约定 *Test 只放单元测试,*IT 放集成测试,否则 mvn test 会把慢测试一起拖进来,团队很快就会开始跳过测试。
第二步:把骨架和依赖配好
Maven 里加三块依赖,版本交给 dependencyManagement 统一管:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Surefire 插件让 mvn test 只捡单元测试:
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<includes><include>**/*Test.java</include></includes>
</configuration>
</plugin>
<plugin>
<artifactId>maven-failsafe-plugin</artifactId>
<configuration>
<includes><include>**/*IT.java</include></includes>
</configuration>
</plugin>
常用命令记三个:mvn test(只跑单元测试)、mvn -Dtest=OrderServiceTest test(跑单个类)、mvn verify(单元测试和集成测试一起跑)。
第三步:单元测试要“快”和“准”
三个习惯能解决八成问题。
只 mock 边界,不 mock 数据。 仓储、第三方 SDK、时间源、随机源是边界,DTO、实体、List 直接 new 出来用。when(repo.findById(1L)).thenReturn(Optional.of(new Order(...))) 这样写就够了,不必对 DTO 做 mock()。
固定不确定性。 时间用注入的 Clock,不要在业务代码里写 LocalDateTime.now();随机数抽成接口。否则测试会在某个月末、某个时区里偶发失败。
按行为命名。 shouldRejectWhenBalanceInsufficient() 比 testPay2() 有用得多。断言的写法用 AssertJ:
assertThatThrownBy(() -> service.pay(orderId, new BigDecimal("9999")))
.isInstanceOf(InsufficientBalanceException.class)
.hasMessageContaining("余额不足");
注意:verify(mock) 校验调用次数属于“实现细节断言”,改一行内部实现就红。除非是在测幂等、测缓存命中这类行为契约,别滥用。
第四步:集成测试用 Testcontainers,别用 H2
H2 能跑起来的 SQL,在 MySQL 上未必跑得起来(ON DUPLICATE KEY、JSON 函数、排序规则)。用 Testcontainers 起真库:
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", mysql::getJdbcUrl);
r.add("spring.datasource.username", mysql::getUsername);
r.add("spring.datasource.password", mysql::getPassword);
}
容器用 static 字段声明,一个测试类只启动一次,别每个方法都起容器。
注意:Testcontainers 依赖本机 Docker。CI 上要么用 DinD,要么挂载宿主机的 /var/run/docker.sock;本地 Windows 记得把 Docker Desktop 跑起来,不然 mvn verify 直接卡在拉镜像。
第五步:覆盖率接进构建,而不是看报告感动自己
JaCoCo 的配置入口在 pom.xml 的 <build><plugins>:
<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>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
跑 mvn verify 后报告在 target/site/jacoco/index.html,用浏览器直接打开。
三个配置细节决定这条门禁能不能长期活下去:
- 门禁看分支覆盖,不看行覆盖。 行覆盖 80% 往往意味着大量
if 的 else 分支根本没走过。
- 排除不该算的东西:DTO、
*Application、自动生成的 MapStruct/QueryDSL 代码,用 <excludes> 加包路径通配。
- 门禁设在“当前值往上一点”,比如现在 68% 就先设 70%,别一上来写 90%——那只会逼出一个
// jacoco skip 满天飞的仓库。
注意:覆盖率只证明“代码被执行过”,不证明“断言写对了”。想验证断言质量,加一层变异测试:mvn org.pitest:pitest-maven:mutationCoverage,看有多少变异体被杀掉。PIT 慢,放在夜间任务里跑就够。
第六步:CI 里串起来
流水线的三段跑法:
mvn -q test # 单元测试,1 分钟内
mvn -q verify # 集成测试 + JaCoCo 门禁
失败就阻断合并。覆盖率报告作为构建产物上传,方便回看趋势——一次从 72% 掉到 65% 的 PR,比一百条口头规范都管用。
小结
- 测试先分层:
*Test 单元、*IT 集成、端到端单独跑,靠 Surefire/Failsafe 的 includes 隔开。
- 单元测试只 mock 边界(仓储、外部 SDK、时间源),DTO 直接 new。
- 集成测试用 Testcontainers 起真库,容器声明成
static 复用。
- 覆盖率门禁看 BRANCH 而不是 LINE,排除 DTO 和生成代码,阈值小幅递增。
- 覆盖率证明不了断言质量,用 PIT 变异测试补一刀。
- 门禁落到 CI 的
verify 阶段,阻断合并,报告留档看趋势。