单元测试覆盖率 86% 还是天天出 P1:我删掉 47% 的测试之后,故障率降了四成
先说背景,免得你觉得我在哗众取宠。
前年冬天我接手了一个订单服务,Java 17 + Spring Boot 3.0,仓库里躺着 2147 个单元测试,JaCoCo 报出来的行覆盖率 86.3%,分支覆盖率 71%。CI 跑一轮 23 分钟,其中 19 分钟花在测试上。这套数字在季度汇报里非常漂亮,PM 看了点头,TL 看了点头。
然后那个季度我们出了 4 起 P1。一起是优惠券叠加算错,用户下了一单 3 分钱的订单;另一起是库存回滚没生效,超卖了 200 多件。凌晨三点盯着 Grafana 看曲线的时候,我脑子里只有一个问题:那 2147 个测试到底在干什么?
我先把 37 起故障做了归因
第一反应当然是「测试写得还不够多」。但我没敢直接动手,而是先把过去 12 个月所有 P1/P2 故障拉出来,一共 37 起,逐个看根因。分类结果大概是这样的:
| 根因类别 | 数量 |
|---|---|
| 配置/环境差异(多环境配置不一致、Feature Flag 配错) | 9 |
| 数据库/事务(死锁、隔离级别、连接池耗尽) | 8 |
| 序列化(时间格式、枚举、Long 精度丢失) | 7 |
| 并发(本地缓存、双重检查、线程池拒绝策略) | 6 |
| 第三方接口超时、熔断没兜住 | 4 |
| 纯业务逻辑算错 | 3 |
只有 3 起是纯业务逻辑算错。而这 3 起里面,有 2 起对应位置的单元测试是绿的。为什么绿?因为测试里 mock 出来的订单对象是 new Order(1L, Status.PAID),而生产环境里那个对象因为后来加了乐观锁字段 version,走的是完全不同的分支。测试从来没见过真的对象。
那一刻我意识到一件事:我们写的不是测试,是 Mock 的文档。
为什么 Mock 测试的保质期大概只有半年
先澄清,我不反对单元测试。我反对的是「用 mock 把 6 个协作者全隔离开,然后断言调用顺序」这种写法。真实的代码大概长这样(类名字段我改了,结构一模一样):
@Test
void shouldPayOrder() {
when(orderRepo.findById(1L)).thenReturn(Optional.of(order));
when(couponService.calc(any())).thenReturn(BigDecimal.TEN);
when(payClient.charge(any())).thenReturn(PayResult.success());
when(inventoryClient.lock(any())).thenReturn(true);
orderService.pay(1L);
verify(payClient, times(1)).charge(any());
verify(inventoryClient, times(1)).lock(any());
}
这个测试断言的是什么?是「pay 方法会先调 couponService,再调 payClient,再调 inventoryClient」。
问题是,这个顺序变了不算 bug。真正算 bug 的是:调用失败时有没有回滚库存、并发下会不会重复扣款、网络超时重试会不会重复下单。这些它一个都测不到。
更麻烦的是它的维护成本是反向的。生产代码给 Order 加个字段、给 payClient 换个签名、把 inventoryClient 拆成两个服务,这个测试就得改。改的时候大家默认「让 CI 变绿就行」,于是把 mock 返回值改成了让测试通过的样子,而不是让测试反映真实行为的样子。
这个过程重复三五次,测试还在跑,还在报绿,但它验证的东西已经和生产代码没有任何关系了。我管这个叫僵尸测试。它消耗的不是 CPU,是团队对红色的敏感度。
我们具体删了什么
先统计,不要先动手。 这是我唯一想强调的一条。我写了个脚本扫全仓测试文件,按四个维度排序:平均 mock 数量、断言类型分布、被覆盖的类名特征、以及 git blame 出来的存活时间。
按这四条筛出来 1007 个,占 47%。
- 纯 DTO / Mapper / getter-setter / equals-hashCode:约 380 个
- mock 数量 ≥ 4 且没有任何结果断言:约 310 个
- 只有 verify() 没有 assertThat() 的:约 240 个
- 测试方法本身超过 60 行、比被测代码还长:约 77 个
分批删的,每批删完观察一周。
补上的是 Testcontainers 集成测试,真起 PostgreSQL 16.2 和 Redis 7.2:
@Testcontainers
class OrderPayIT {
@Container
static PostgreSQLContainer<?> PG = new PostgreSQLContainer<>("postgres:16.2")
.withReuse(true);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", PG::getJdbcUrl);
}
}
withReuse(true) 必须在 ~/.testcontainers.properties 里加一行 testcontainers.reuse.enable=true,不加的话每次 CI 都要重新拉镜像起容器,一个测试类多花十几秒。这个坑我们踩了两天才找到,中间一直以为是 Docker 网络的问题。
集成测试从 68 个涨到 412 个,单条平均耗时 800ms 左右。
结果,以及一件我不想吹的事
半年后,季度 P1 从 4 起降到 1 起。
但我必须说清楚:同期我们还做了灰度发布、接了 OpenTelemetry 全链路追踪、把两个第三方调用从同步改成了带本地消息表的异步。所以这个 1 比 4,不能全算在测试改造头上。任何说「我改了个测试策略,故障率就降了 80%」的文章,你都可以怀疑一下。
还有一件更尴尬的:删测试之后的第 3 个月,有人改订单状态机,漏了一个 REFUNDING -> CLOSED 的转换。单元测试没抓到(那部分被我删了),集成测试当时只覆盖了正向流程,灰度环境跑了两小时才发现。
所以我们后来加了一条硬规则:任何被删掉的测试,对应的业务路径必须在 30 天内有一条集成测试或契约测试补上,否则这条路径进 CI 的必测清单。 这条规则是用一次真实事故换的,不是拍脑袋定的。
另外一个特别反直觉的点:删掉 1007 个测试,CI 只快了大概 40 秒。因为 mock 单元测试本来就跑得飞快,一个 10 到 15ms。真正把 23 分钟压到 9 分钟的是两件事——容器复用,以及按测试类分片在 8 个 runner 上并行跑。所以如果你删测试的动机是「让 CI 变快」,方向从一开始就错了,该去看构建缓存和并行策略。
我的观点,和一些边界
关于覆盖率,我的看法是:覆盖率是个搜索工具,不是验收标准。
它唯一有用的用法,是告诉你「哪块代码从来没人执行过」——那是风险区。至于「哪块代码覆盖率 95%」,这个信息基本没用,因为一行代码被执行过,不等于这一行被验证过。你可以写个测试把方法跑一遍然后什么都不断言,覆盖率照样涨。CI 里跑 jacocoTestCoverageVerification 卡门禁,卡住的往往是老老实实写测试的人,而不是会绕指标的人。
关于比例,我不太信经典的测试金字塔。Kent C. Dodds 在 2018 年提的 Testing Trophy 更接近我在实际项目里看到的样子。Google 在《Software Engineering at Google》第 11 章把小中大测试的比例写成大约 80/15/5,但那是 Google 的基础设施,他们有极强的构建系统和海量的测试选择工具,普通团队照搬会死。
我们现在大概是:小测试(无 IO、无 mock 或极少 mock)55%,中测试(Testcontainers + 单服务)40%,大测试(端到端)5%。这个比例不是算出来的,是删着删着自然形成的。
最后说什么情况下别学我:
- 做金融核心账务、或者要过认证的嵌入式/医疗设备软件。那种场景下冗余是有价值的,删之前请三思。
- 团队没有能力维护集成测试环境(CI 上跑不了 Docker 容器)。那你删完就是裸奔。这种情况下优先搭环境,而不是先删。
- CI 里的测试本来就没人看、红了也没人管。那你真正的问题不是测试太多,是没人对质量负责,删测试解决不了这个。
我到现在也不觉得「删掉 47% 的单元测试」这件事本身有多勇敢。它只是在纠正一个更早的错误——我们用覆盖率这个指标,替代了对系统真实行为的思考。指标好看了,思考就停了。
如果你也想动手,我建议的起手步骤是:先别删,先跑一遍统计脚本,把 mock 数量 ≥ 4 的测试挑出来看看它们到底在断言什么。看完 50 个,你大概就知道该删哪些了。