第 09 章(节选) 真实环境与老代码测试
本篇来自《Go 程序员的 Java 修炼之路》第 09 章「第 09 章 单元测试与可测试性:敢改别人的代码」。 返回:第 09 章索引
09.5 Testcontainers:用真实的数据库和中间件测试
为什么需要它
H2 是个内存数据库,它跟 MySQL 的差异包括但不限于:
| 差异点 | H2 | MySQL 8 |
|---|---|---|
ON DUPLICATE KEY UPDATE | 支持(MySQL 兼容模式) | 原生 |
| JSON 类型 | 有自己的 JSON 函数 | JSON_EXTRACT 语法不同 |
| 默认事务隔离级别 | READ_COMMITTED | REPEATABLE_READ |
| 行锁 / 间隙锁 | 锁粒度不同 | 有间隙锁(InnoDB) |
| 索引行为 | 简化实现 | B+Tree,有最左前缀、索引下推 |
utf8mb4 排序规则 | 无 | 大小写不敏感,影响 LIKE 和 = |
| 窗口函数 / CTE | 部分支持 | 8.0 完整支持 |
| 时间精度 | 有差异 | DATETIME(3) 语义不同 |
一个真实事故(现象 → 排查 → 根因 → 修复 → 教训):
- 现象:订单去重功能上线,生产出现重复订单。本地测试全绿
- 排查:去重逻辑依赖
INSERT ... ON DUPLICATE KEY UPDATE order_no = VALUES(order_no),测试用 H2 跑的,全过 - 根因:H2 在 MySQL 兼容模式下对
ON DUPLICATE KEY UPDATE的锁行为和 MySQL 不同。MySQL 在并发插入同一order_no时会加间隙锁阻塞第二个事务;H2 不会。于是并发场景下的去重在生产失效,在测试里永远复现不出来 - 修复:Repository 的并发相关测试改用 Testcontainers 起真实 MySQL 8
- 教训:用 H2 测出来的通过,不等于生产能跑。 尤其是涉及并发、锁、事务隔离级别、数据库特有语法的地方
原理和基本用法
Testcontainers 做的事很直白:用 Docker 起一个真实的中间件容器,测试完自动销毁。
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>mysql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>@Testcontainers // JUnit 5 扩展,负责容器的启停
@SpringBootTest
class OrderRepositoryIT {
@Container // 静态字段 = 整个测试类共享一个容器
static MySQLContainer<?> mysql =
new MySQLContainer<>(DockerImageName.parse("mysql:8.0"))
.withDatabaseName("order_test")
.withUsername("test")
.withPassword("test");
@DynamicPropertySource // 容器起来后,把真实端口写进 Spring 配置
static void datasourceProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}
@Autowired OrderRepository repository;
@Test
void 保存后能查到() {
repository.save(new Order("NO-001", new BigDecimal("199")));
assertThat(repository.findByOrderNo("NO-001")).isPresent();
}
}三个关键点:
@Container放在static字段上 = 这个测试类里的所有测试共享一个容器(类级别生命周期)。放在实例字段上 = 每个测试方法起一个容器(慢十倍,几乎总是不对的)@DynamicPropertySource的方法必须static,它在 Spring 容器启动前被调用DockerImageName.parse()而不是直接传字符串 —— 后者在新版本已废弃,因为不做镜像兼容性检查
注意:@DynamicPropertySource 会让每个用了它的测试类拥有不同的属性,所以不同的容器端口 = 不同的上下文缓存 key。这就是为什么单例容器模式(下面)很重要 —— 端口一致,上下文就能复用。
单例容器模式(重要优化)
默认写法下,每个测试类都会起一个自己的 MySQL 容器。10 个测试类 = 10 个 MySQL = 每个启动 15-30 秒 = 内存爆炸。
解法一:抽象基类 + 静态字段(最常用)
public abstract class AbstractMysqlIT {
static final MySQLContainer<?> MYSQL =
new MySQLContainer<>(DockerImageName.parse("mysql:8.0"))
.withDatabaseName("order_test")
.withUsername("test")
.withPassword("test")
.withReuse(true); // 见下方说明
static {
if (!MYSQL.isRunning()) {
MYSQL.start(); // 手动启动,不靠 @Testcontainers 扩展
}
}
@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);
}
}因为 static final 字段在 JVM 里只初始化一次,所有继承这个基类的测试类共享同一个容器,而且 @DynamicPropertySource 拿到的 JDBC URL 完全一致 —— Spring 的上下文缓存也就命中了。
解法二:Testcontainers 的 Reuse 模式(跨 JVM 复用,本地开发神器)
~/.testcontainers.properties:
testcontainers.reuse.enable=true配上 .withReuse(true),容器在 JVM 退出后不会被销毁,下次跑测试直接复用。本地开发时能省掉每次 20 秒的启动时间。
注意:Reuse 模式下容器不会自动清理,数据会留在里面。 所以每个测试必须自己清理数据(或者用 @Transactional 回滚)。而且 CI 里一般不开 —— CI 环境每次都是干净的,开了反而会有脏数据问题。
解法三:Spring Boot 3.1+ 的 @ServiceConnection
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {
@Bean
@ServiceConnection // 自动把容器连接信息注入 Spring
MySQLContainer<?> mysqlContainer() {
return new MySQLContainer<>(DockerImageName.parse("mysql:8.0"));
}
}@ServiceConnection 帮你省掉手写 @DynamicPropertySource,Spring Boot 3.1 引入,3.2 后支持更多中间件(Kafka、Redis、RabbitMQ、Elasticsearch、MongoDB…)。如果你在用 Spring Boot 3.x,这是首选写法。
【思考】Testcontainers 需要 Docker,CI 里怎么用?
更进一步:你的 CI runner 本身就是一个容器。在容器里跑容器,会遇到什么问题?
参考答案
直接答案:三种方案 —— ①GitHub Actions / GitLab CI 提供的 Docker-in-Docker 或 socket 挂载;②Testcontainers Cloud(把容器跑到云端);③CI 镜像里预装 Docker + 挂载宿主的 docker.sock。国内环境还要额外处理镜像拉取慢的问题。
方案一:GitHub Actions(最简单,自带 Docker)
jobs:
test:
runs-on: ubuntu-latest # GitHub 的 runner 预装了 Docker
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: '17', distribution: 'temurin' }
- run: ./mvnw verify # Testcontainers 直接就能用方案二:GitLab CI 的 DinD
GitLab 的 docker runner 需要显式启用 privileged 模式:
test:
image: docker:24
services:
- docker:24-dind # Docker in Docker 服务
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "/certs/client"
script:
- ./mvnw verifyDinD 的三个真实代价:
- 需要 privileged 权限(
--privileged),在很多公司的安全基线里是被禁的 - 慢:每次 CI 都要拉镜像(MySQL 8 的镜像 500MB+)
- 不稳定:DinD 的容器网络在高并发下偶发问题,flaky 的来源之一
替代方案:挂载宿主 docker.sock(更快,但隔离性差)
test:
image: maven:3.9-eclipse-temurin-17
volumes:
- /var/run/docker.sock:/var/run/docker.sock # 直接共享宿主的 Docker
script:
- mvn verify好处是快(共享镜像缓存,不用重新拉),并且Testcontainers 检测到的”容器在容器里”的问题会少一些。坏处是所有 CI job 共享一个 Docker daemon,测试之间可能互相干扰(端口冲突、容器名冲突),而且安全性更差(能操作宿主 Docker = 基本上等于 root)。
方案三:Testcontainers Cloud
官方的托管服务:本地和 CI 都连到云端的 Docker,容器跑在云上。优点是不需要在 CI runner 里装 Docker,缺点是收费、且网络延迟影响测试速度。
国内环境的镜像问题(这是真痛点):
Testcontainers 默认从 Docker Hub 拉镜像。国内 CI 拉 mysql:8.0 经常超时或者几 KB/s。三个解法:
- 配镜像加速器。在 Docker daemon 的
/etc/docker/daemon.json里配registry-mirrors,Testcontainers 会走宿主 daemon 的配置:
{
"registry-mirrors": [
"https://<你的加速器地址>"
]
}- 用私有仓库的镜像名,把它写成配置项而不是硬编码:
static final String MYSQL_IMAGE = System.getProperty(
"test.mysql.image", "mysql:8.0"); // CI 里可以 -Dtest.mysql.image=registry.internal/mysql:8.0- CI 里预先 pull 好镜像(
docker pull mysql:8.0),Testcontainers 发现本地有就不会去拉。
代码锚点 —— Testcontainers 在容器里跑时的自检:
Testcontainers 有一个 Ryuk 组件负责清理僵尸容器,它会检测运行环境。遇到诡异的启动失败时,先确认三件事:
docker info # Docker daemon 可达吗
docker run --rm hello-world # 能起容器吗
ls -la /var/run/docker.sock # socket 存在且当前用户有权限吗再开启 debug 日志:
# src/test/resources/logback-test.xml 里把 Testcontainers 的日志级别调高
<logger name="org.testcontainers" level="DEBUG"/>更深一层:Testcontainers 把”集成测试”这件事的成本从”运维级”降到了”代码级”。
在它出现之前,Java 项目做集成测试要么靠一个共享的测试数据库(于是测试互相污染、CI 不能并行),要么靠 H2(于是不保真)。Testcontainers 的洞察是:Docker 已经让”起一个 MySQL”变成一条命令了,那为什么测试不能自己起一个用完就扔的?
这是”基础设施即代码”思路在测试领域的延伸。 代价是需要 Docker —— 但 2026 年这个代价已经几乎为零了。
对照 Go:Go 有官方的 testcontainers-go,用法几乎一样(@Container 对应 Go 里的 testcontainers.GenericContainer(ctx, req))。但 Go 社区用得更少,原因很实在:
- Go 的标准库
database/sql抽象做得好,很多人用 sqlite 内存库就够了(:memory:),起一个容器完全没必要 - sqlmock(
DATA-DOG/go-sqlmock)能拦在 driver 层验证 SQL,对于”测 SQL 对不对”这个目的,比真数据库更快更精确 ory/dockertest是 Go 社区的另一选择,比 testcontainers-go 轻量- Go 服务通常更简单,很多微服务只有一个数据库,没有 Kafka + Redis + ES 一整套需要编排
Java 项目往往依赖一整套中间件(MySQL + Redis + Kafka + ES + Zookeeper),这套东西用 H2 或内存实现模拟出来的保真度太低,所以 Testcontainers 在 Java 生态里的价值比在 Go 里高得多。再一次:工具的价值取决于它解决的问题在你这儿有多痛。
09.6 给老代码加测试:一套务实的流程
回到开场那个问题:3 万行、0 测试,怎么办。
核心原则
不要试图给所有代码补测试。先给”你马上要改的那部分”加。
3 万行全覆盖需要几个月,且ROI 递减 —— 你花三个月补完的测试里,有 80% 覆盖的是”一年也不会改一次”的代码。而你要改的那 200 行,恰恰是全项目风险最高的地方。
接缝(Seam)的概念
这个概念来自 Michael Feathers 的《修改代码的艺术》(Working Effectively with Legacy Code),是本章最值得记住的一个词。
接缝(seam)是代码中可以插入行为改变、而不需要修改该处代码的位置。
具体到 Java:
| 接缝类型 | 例子 | 你能做什么 |
|---|---|---|
| 方法参数 | createOrder(Cmd cmd) | 传不同的 cmd |
| 构造器参数 | new Service(repo, clock) | 传 mock / fake / fixed clock |
| 接口实现 | OrderRepository 有多个实现 | 换成内存实现 |
| 可覆盖的方法 | 非 final、非 private 的方法 | 子类覆盖 |
| 静态方法的调用点 | IdGenerator.nextId() | 抽成实例方法(就造出了一个新接缝) |
给老代码加测试的全部工作,本质就是”在正确的位置造出接缝”。 剩下的就是普通测试。
五步法
第一步:特征测试(Characterization Test)
先写一个测试,记录代码当前的实际行为 —— 哪怕那个行为是个 bug。
这一步反直觉,但它是整套方法里最重要的。为什么?因为老代码没有规范,只有行为。你不知道”当前行为”和”正确行为”的差距在哪,所以第一步是把当前行为固化下来,保证你的重构不会意外改变它。
// 特征测试:名字就写明"记录现状",不判断对错
@Test
@DisplayName("【特征测试】记录 createOrder 对金额为负数的当前处理")
void 记录当前行为_负数金额() {
Order order = service.createOrder(cmdWithAmount(new BigDecimal("-100")));
// 我也不知道为什么要这样,但当前代码就是返回了 -100 而不是报错
assertThat(order.getAmount()).isEqualByComparingTo("-100");
}这个测试第一次跑可能是红的(因为你连当前行为都猜错了)。那就把断言改成实际值 —— 你是在记录,不是在评判。
写完之后,这段行为就被保护住了。你后面怎么重构,只要这个测试还是绿的,就说明”负数金额的处理方式没变”。
第二步:提取依赖(用 IDE 的安全重构)
把 new XxxRepository() 提出来。关键是用 IDE 的自动化重构,不要手改。
IntelliJ 的操作:Refactor → Extract → Introduce Field / Introduce Parameter,或者直接在 new XxxRepository() 上按 Ctrl+Alt+P(引入参数)。IDE 会找到所有调用点并处理好。
// 改前
public Order createOrder(Cmd cmd) {
var repo = new OrderRepositoryImpl();
// ...
}
// 改后(IDE 自动完成,调用点自动补参数)
public Order createOrder(Cmd cmd, OrderRepository repo) {
// ...
}为什么必须用 IDE 重构? 因为手工改一个 300 行的类,你一定会漏掉某个分支。IDE 的重构是语义安全的,它知道作用域。
第三步:打破静态方法
| 现状 | 改法 |
|---|---|
LocalDateTime.now() | 注入 Clock,改成 LocalDateTime.now(clock) |
new Date() | Date.from(clock.instant()) |
System.currentTimeMillis() | clock.millis() |
UUID.randomUUID() / 静态 IdGenerator | 抽成接口 IdGenerator,注入 |
Math.random() / new Random() | 注入 RandomGenerator(Java 17) |
静态工具类 XxxUtils.foo() | 包一层实例方法(让调用点变成可替换的接缝) |
最后一行的”包一层”很重要,它是不改原工具类、只在调用方改动的折中:
public class OrderServiceImpl {
// 原来的:PriceUtils.calculate(items) —— 静态,焊死了
// 改成:一个可覆盖的实例方法,测试里可以覆盖掉
protected BigDecimal calculate(List<OrderItem> items) {
return PriceUtils.calculate(items); // 还是调原来的静态方法
}
}这个 protected 方法就是一个人造接缝。它不动 PriceUtils,但让你能在测试里覆盖掉算价逻辑。
第四步:拆分大方法
200 行的方法按 Refactor → Extract Method 拆开。先拆纯逻辑部分(不碰 IO 的那些),因为那部分最好测、也最可能有 bug。
拆的时候注意一条:只提取,不改逻辑。 这一步的目标是让代码变短,不是让它变对。改逻辑是下一步的事,而且要一个改一个地改 —— 每次改完跑一遍特征测试。
第五步:加断言(从最重要的业务规则开始)
现在你有了一堆小方法,可以开始写真正的测试了。优先级:
- 金额计算(错了就是钱的问题)
- 状态机(订单状态流转错了会卡单)
- 边界条件(空、0、负数、超大值、超长字符串)
- 异常路径(库存不足、支付失败、超时)
【思考】给一个 3 万行、0 测试的项目补测试,从哪开始?ROI 最高的是什么?
给你四个候选,排个序:①先补金额计算 ②先补你要改的那部分 ③先写几个端到端冒烟测试 ④先追求覆盖率数字
参考答案
直接答案:排序是 ③ > ② > ① > ④。端到端冒烟测试的”信心/成本”比最高,覆盖率数字最没用。
为什么端到端冒烟测试排第一?
一个 3 万行 0 测试的项目,你最大的恐惧不是”某个函数算错了”,是”我改完之后整个系统起不来 / 某个关键接口挂了”。几个端到端冒烟测试(比如”下单 → 支付 → 查询订单”的正常流程 + “库存不足”的异常流程)能在半天之内给你提供:
- 服务能启动、配置是对的
- 核心链路的 Bean 装配是对的
- 数据库 schema 和代码是对得上的
- 主要接口的输入输出契约没变
这一层保护的”面积”最大,成本最低。 5 个冒烟测试大概能覆盖 60% 的”改坏了会立刻炸”的风险。
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderSmokeTest extends AbstractMysqlIT {
@LocalServerPort int port;
TestRestTemplate rest = new TestRestTemplate();
@Test
void 下单支付查询全链路() {
var created = rest.postForEntity(url("/orders"), cmd(), OrderDto.class);
assertThat(created.getStatusCode().value()).isEqualTo(201);
Long id = created.getBody().id();
rest.postForEntity(url("/orders/" + id + "/pay"), null, Void.class);
var detail = rest.getForEntity(url("/orders/" + id), OrderDto.class);
assertThat(detail.getBody().status()).isEqualTo("PAID");
}
}为什么排第二的是”你要改的那部分”?
因为这是唯一有明确截止时间和明确收益的部分。改之前先写特征测试把现状钉住,改完测试还是绿的就是没改坏。这部分测试会在你改完之后立刻产生价值,而不是”也许将来有用”。
而且有个副作用很好:你会被逼着给那段代码造接缝,而造接缝本身就是改善设计。
为什么金额计算排第三(而不是第一)?
因为金额计算通常是纯函数,它最容易测,但它需要的前置工作也最多 —— 你得先把它从那个 200 行的方法里拆出来(第四步)。所以它是”做完第二、四步之后水到渠成的事”,不是起点。
不过一旦拆出来了,它的 ROI 极高:一个纯函数的测试写起来 10 分钟,而它守护的是”钱”。
为什么覆盖率数字排最后(或者说不该做)?
见下一个思考题。简短版:覆盖率是一个”找盲区”的工具,不是一个”目标”。 把覆盖率当 KPI 的结果一定是”写一堆没有断言的测试把数字刷上去”。
一个务实的节奏建议(四周):
| 周 | 做什么 | 产出 |
|---|---|---|
| 第 1 周 | 5-8 个端到端冒烟测试 + CI 跑起来 | 有了一条底线,敢改了 |
| 第 2 周 | 给”这次要改的模块”写特征测试 + 造接缝 + 重构 | 这次需求安全交付 |
| 第 3 周 | 把最痛的纯逻辑(金额、状态机)拆出来单测 | 核心逻辑有保护 |
| 第 4 周 | 配 JaCoCo,看哪些分支从没被测过,挑着补 | 知道盲区在哪 |
注意第 4 周才是 JaCoCo。 大部分人的错误是反过来 —— 先配覆盖率工具,然后对着数字焦虑。
更深一层:给老代码补测试,本质是在买保险,不是在还技术债。
买保险的逻辑是”保额 / 保费”比:保你最怕出事的地方,保你最可能出事的地方。你不会给”家里那把用了十年的塑料凳子”上保险,同理你不该给”三年没人动过的工具类”补测试。
判断标准只有一条:这段代码”被改”的概率 × “改坏了”的代价。 两个数都大的地方先补。你要改的地方,被改概率是 100%,所以它永远排在最前面。
覆盖率工具:JaCoCo
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.12</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals><goal>prepare-agent</goal></goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<goals><goal>report</goal></goals>
</execution>
</executions>
</plugin>mvn verify # 跑测试并生成报告
open target/site/jacoco/index.html # 报告入口怎么看报告(这是重点):
报告首页是一张包级别的表格,四列:
| 列 | 含义 |
|---|---|
| Missed Instructions | 行覆盖率。JaCoCo 数的是字节码指令,不是源码行 |
| Missed Branches | 分支覆盖率。这个最重要 |
| Missed Cxty | 圈复杂度未覆盖部分 |
| Missed Lines / Methods / Classes | 行/方法/类的覆盖 |
点进某个类,你会看到源码被染成三种颜色:
- 绿色:被执行过
- 黄色:部分分支被执行过(
if只走了 true,没走 false)← 重点看这个 - 红色:完全没执行
我的建议:不要看首页那个百分比,直接点进去按”Missed Branches”倒序排,找黄色的菱形标记。 JaCoCo 会给每个分支画一个小菱形,黄色菱形就是”这个 if 你只测了一边”。
这就是覆盖率报告唯一正确的用法:找盲区。
【思考】覆盖率 80% 意味着什么?
再给你一个事实:一个完全没有断言的测试,也能提供 100% 的行覆盖率。
参考答案
直接答案:几乎什么都不意味着。覆盖率只能证明”这些代码被执行过”,不能证明”这些代码的行为被验证过”。
先看那个反例:
@Test
void 覆盖率刷分() {
service.createOrder(cmd()); // 没有一句断言
}这个测试会让 createOrder 及其调用的所有代码行变绿。它是个 100% 覆盖、0% 有用的测试。 它唯一能抓到的 bug 是”抛异常了”。
所以覆盖率的分母是”代码行数”,分子是”被执行过的行数”—— 而验证强度完全不在这个比值里。
再看一个更微妙的:分支覆盖比行覆盖重要得多。
public BigDecimal calc(Order order) {
if (order.isVip()) { // 分支 1
return order.getAmount().multiply(VIP_RATE);
}
return order.getAmount(); // 分支 2
}只测 VIP 用户:行覆盖率可能到 100%(如果两行 return 都被别的用例走到了)或者只有一半,但分支覆盖率只有 50% —— 那个 if 的 false 分支没测过。
而 bug 恰恰藏在没走的那个分支里。 这就是为什么 JaCoCo 报告里黄色菱形比红色行更值得看。
覆盖率能告诉你什么(有用的部分):
- 哪些代码从来没被测过。 这是它唯一无可替代的价值 —— 一个 0% 覆盖的类,你完全不知道它能不能工作
- 哪些分支被漏掉了。 配合 JaCoCo 的黄色菱形
- 死代码。 一个永远 0% 覆盖的方法,可能根本没人调用,可以删
覆盖率不能告诉你什么:
- 断言写得对不对
- 边界条件测没测(空、0、负数、溢出、超长)
- 并发安全不安全
- 业务逻辑对不对(测试可能把错误的行为固化成”期望”了 —— 特征测试就是这么干的)
代码锚点 —— 如果你想给覆盖率设门槛,这么配:
<execution>
<id>check</id>
<goals><goal>check</goal></goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<!-- 分支覆盖率 60%,行覆盖率 80% -->
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.60</minimum>
</limit>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.80</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>注意我把 BRANCH 的门槛设得比 INSTRUCTION 低 —— 这是有意的:分支覆盖率本来就难做高,设太高会让团队开始写垃圾测试凑数。
更重要的是:把 check 设为只针对”新代码”或者”核心模块”,不要全项目一刀切。 JaCoCo 支持按包/类配置多个 rule。
更深一层:覆盖率数字一旦成为 KPI,它就立刻失效。
原因是古德哈特定律:“当一个指标成为目标,它就不再是一个好指标。”
团队被要求 80% 覆盖率 → 工程师写 assertEquals(1, 1) 式的测试凑数 → 覆盖率上去了 → 测试套件变成一堆噪音 → 没人看测试结果 → 真的 bug 也被忽略 → 团队得出结论”测试没用”。
正确的做法是:把覆盖率当成”雷达”而不是”分数”。 雷达的用途是”看看哪里是黑的”,不是”今天比昨天高了几分”。
对照 Go:Go 的 go test -cover 输出一个百分比,-coverprofile 出 profile,go tool cover -html 看染色源码。功能上比 JaCoCo 简单(没有分支覆盖的细分展示,虽然底层数据是有的),但理念一致。 差别在于 Go 社区几乎没人把覆盖率当 KPI —— 因为 Go 的标准工具链里没有”强制卡覆盖率”的机制(不像 JaCoCo 的 check goal 能直接让构建失败)。工具的设计会塑造团队的行为,哪怕那不是设计者的本意。