第 09 章(节选) 真实环境与老代码测试

本篇来自《Go 程序员的 Java 修炼之路》第 09 章「第 09 章 单元测试与可测试性:敢改别人的代码」。 返回:第 09 章索引

09.5 Testcontainers:用真实的数据库和中间件测试

为什么需要它

H2 是个内存数据库,它跟 MySQL 的差异包括但不限于:

差异点H2MySQL 8
ON DUPLICATE KEY UPDATE支持(MySQL 兼容模式)原生
JSON 类型有自己的 JSON 函数JSON_EXTRACT 语法不同
默认事务隔离级别READ_COMMITTEDREPEATABLE_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();
    }
}

三个关键点:

  1. @Container 放在 static 字段上 = 这个测试类里的所有测试共享一个容器(类级别生命周期)。放在实例字段上 = 每个测试方法起一个容器(慢十倍,几乎总是不对的)
  2. @DynamicPropertySource 的方法必须 static,它在 Spring 容器启动前被调用
  3. 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 verify

DinD 的三个真实代价:

  1. 需要 privileged 权限(--privileged),在很多公司的安全基线里是被禁的
  2. 慢:每次 CI 都要拉镜像(MySQL 8 的镜像 500MB+)
  3. 不稳定: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。三个解法:

  1. 配镜像加速器。在 Docker daemon 的 /etc/docker/daemon.json 里配 registry-mirrors,Testcontainers 会走宿主 daemon 的配置:
{
  "registry-mirrors": [
    "https://<你的加速器地址>"
  ]
}
  1. 用私有仓库的镜像名,把它写成配置项而不是硬编码:
static final String MYSQL_IMAGE = System.getProperty(
    "test.mysql.image", "mysql:8.0");      // CI 里可以 -Dtest.mysql.image=registry.internal/mysql:8.0
  1. 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 社区用得更少,原因很实在:

  1. Go 的标准库 database/sql 抽象做得好,很多人用 sqlite 内存库就够了(:memory:),起一个容器完全没必要
  2. sqlmock(DATA-DOG/go-sqlmock)能拦在 driver 层验证 SQL,对于”测 SQL 对不对”这个目的,比真数据库更快更精确
  3. ory/dockertest 是 Go 社区的另一选择,比 testcontainers-go 轻量
  4. 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。

拆的时候注意一条:只提取,不改逻辑。 这一步的目标是让代码变短,不是让它变对。改逻辑是下一步的事,而且要一个改一个地改 —— 每次改完跑一遍特征测试。

第五步:加断言(从最重要的业务规则开始)

现在你有了一堆小方法,可以开始写真正的测试了。优先级:

  1. 金额计算(错了就是钱的问题)
  2. 状态机(订单状态流转错了会卡单)
  3. 边界条件(空、0、负数、超大值、超长字符串)
  4. 异常路径(库存不足、支付失败、超时)

【思考】给一个 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 报告里黄色菱形比红色行更值得看。

覆盖率能告诉你什么(有用的部分):

  1. 哪些代码从来没被测过。 这是它唯一无可替代的价值 —— 一个 0% 覆盖的类,你完全不知道它能不能工作
  2. 哪些分支被漏掉了。 配合 JaCoCo 的黄色菱形
  3. 死代码。 一个永远 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 能直接让构建失败)。工具的设计会塑造团队的行为,哪怕那不是设计者的本意。