覆盖率的谎言:100% 覆盖,0 个断言
「我们的测试覆盖率有 90%。」这句话听起来像一枚勋章,很多团队拿它当质量的证明。但它其实只测量了一件非常有限的事:你的代码有多少行,在测试跑的时候被执行到了。注意 —— 是「被执行到」,不是「被验证过」。这两者之间隔着一整个鸿沟,而 bug 就住在鸿沟里。这一章会让你亲眼看到一套100% 覆盖率、全绿的测试,是怎么稳稳地漏掉一个 bug 的。然后我们给覆盖率一个诚实的定位:它是个有用的反向指标,但绝不是「测够了」的证书。
覆盖率到底测量什么
代码覆盖率工具(JaCoCo、Kover、Istanbul)的工作方式很朴素:跑测试时,它给每一行代码做个标记 —— 「这行被执行了吗」。跑完,统计有多少行被执行过,除以总行数,就是行覆盖率。
关键在这个词:被执行(executed)。它和被验证(verified)是两回事:
一行代码「被执行了」,只说明测试路过了它。它完全没说这行代码算出的结果对不对 —— 那是 assert 的事,而覆盖率根本不看有没有 assert。
极端情况:一个连一句 assert 都没有的测试(只是把函数调了一遍),照样能贡献 100% 覆盖率。它执行了所有代码,验证了零行为。覆盖率对「这个测试有没有牙齿」一无所知。
眼见为实:100% 覆盖,bug 安然无恙
下面这个 Demo 里,被测函数是「满 100 打八折、满 50 打九折」,但实现把 >= 100 写成了 > 100(一个边界 bug)。测试用了三个输入(200、75、10),它们把每一行、每一个分支都执行到了。先点「跑这套 100% 覆盖的测试」。
结果:行覆盖率 100%,全绿。覆盖率报告会告诉你「这段代码测得很好」。可 bug 好端端地待在那儿。为什么?因为三个输入虽然覆盖了所有行,却没有一个正好等于 100 —— 而 bug 恰恰只在 total == 100 这一个点上发作。现在点「只补一个 total=100」,同样是 100% 覆盖率,bug 当场现形。
覆盖率衡量的是「代码被走过」,而边界 bug(第 9 章)的本质是「某个特定的输入值触发了错误决策」。同一行代码,用 total=200 走一遍是「覆盖了」,但只有用 total=100 走才暴露 bug。覆盖率把这两次执行看作完全一样。它天然对「输入值的选择」视而不见 —— 而那正是测试真正的功夫所在。
覆盖率的三个层次:越往下越诚实,但都不够
覆盖率不止一种,严格程度递增:
| 类型 | 要求 | 能骗过它的方式 |
|---|---|---|
| 行覆盖 | 每行至少执行一次 | 最松。一个没 assert 的测试就能刷满。 |
| 分支覆盖 | 每个 if 的真/假都走一次 | 比行覆盖强,但上面 Demo 里 100% 分支覆盖照样漏边界 bug。 |
| 条件覆盖 | 复合条件(a && b)里每个子条件都取过真假 | 最严,但极少有团队真的要求它。 |
结论很残酷:连最严格的覆盖率,都无法保证 bug 被发现,因为它测的始终是「代码路径走没走到」,而不是「行为对不对、边界值挑没挑对、断言强不强」。
那覆盖率到底有没有用?有,但只当反向指标
别误会 —— 覆盖率不是没用,是用法容易被搞反。它的正确用法是「反向」的:
覆盖率低 → 一定有问题。如果一个模块只有 30% 覆盖,那 70% 的代码根本没被任何测试碰过 —— 这是确凿的漏洞,值得警觉。覆盖率报告最好的用途,是找出那些一个测试都没有的代码。
覆盖率高 → 什么也证明不了。90% 覆盖,可能是 90% 的代码被扎实验证了,也可能是一堆没断言的假测试刷出来的。高覆盖率不能反推「测得好」。
一句话:用覆盖率找没测的地方,别用它宣布测够了。
把覆盖率定成 KPI,它就会反噬你
一旦覆盖率成了硬性指标,古德哈特定律就生效了 —— 当一个度量变成目标,它就不再是好度量。人们会为了那个数字,写出大量没有断言、只为执行代码的测试:把每个函数调一遍,不 assert 任何东西。覆盖率达标了,而测试套的价值是零 —— 甚至是负的(它们还得维护)。
更坏的是,这些假测试给了全团队虚假的安全感:「我们 80% 覆盖呢」,于是没人再去想那些真正难测的边界。第 1 章那句话在这里回响:一个不可能失败的测试,和没有测试一样 —— 而刷覆盖率,批量生产的正是这种测试。
AI 时代:覆盖率是它最容易糊弄的指标
你对 AI 说「把覆盖率提到 90%」,它会非常高效地照办 —— 生成一堆测试,把没覆盖的分支都走一遍。但这些测试里,有多少是真的在验证行为,有多少只是「调一下、assertNotNull 一下」来凑数?通常是后者居多(第 2 章那两个断言陷阱)。
于是你得到一个危险的组合:漂亮的覆盖率数字 + 一堆没牙齿的测试 + 一份全绿的 CI。三者叠加,让你以为这段 AI 代码被测得很扎实,实际上它几乎没被真正验证过。覆盖率恰恰是审查 AI 测试时最不能信的指标。
看得见的差别:一行复合条件
把行/分支/条件三种覆盖的差别,用一行代码钉死。看这个判断:
fun canWithdraw(active: Boolean, balance: Int): Boolean {
return active && balance > 0 // 复合条件
}
假设你只写了两个测试:canWithdraw(true, 100)(true)和 canWithdraw(false, 100)(false)。看看三种覆盖率各自怎么评价它:
| 覆盖类型 | 它问什么 | 这两个测试 |
|---|---|---|
| 行覆盖 | 这行执行过吗 | 100%(执行了) |
| 分支覆盖 | 整体结果 true / false 都出现过吗 | 100%(true 和 false 都有了) |
| 条件覆盖 | 每个子条件的真假都取过吗 | 没有!balance > 0 永远是 true —— 从没测过余额 ≤ 0 |
看到了吗:行覆盖和分支覆盖都给这套测试打了 100 分,但它从来没测过「余额不足」这个分支 —— 而那大概率正是最需要测的失败路径。条件覆盖(更严格的是 MC/DC,航空软件的标准)能抓到这个洞,但绝大多数团队的覆盖率报告默认只看行或分支。所以当你听到「我们 90% 覆盖」,先问一句:哪种覆盖?行覆盖的 90%,水分可能大得惊人。
把「反向指标」用对:看增量,别看总量
既然低覆盖率才有信息量,那实操上怎么用它才不落入「凑总数」的陷阱?有两个好用的姿势:
- 增量覆盖(diff coverage):别管整个项目的总覆盖率(那是历史包袱,而且鼓励刷分)。只看这个 PR 新增/改动的代码有没有被测到。一段新写的逻辑一行测试没有,这是当下、可行动的信号 —— 比"总覆盖率掉了 0.3%"有用得多。
- 按文件看,找那个 0%:覆盖率报告里最有价值的不是那个漂亮的总数,是那几个覆盖率接近 0 的文件 —— 它们是完全没人测过的黑洞。
反面是设一个「全项目必须 80%」的硬门槛(第上一节的古德哈特陷阱)。增量覆盖不逼你回头给老代码补一堆无意义的测试,只要求你「别让新债更糟」,方向健康得多。
那问题就来了:如果覆盖率骗人,我到底怎么知道一套测试有没有牙齿?有没有一个指标,量的是「验证」而不是「执行」?有。下一章,我们把代码故意改坏,看测试抓不抓得到 —— 变异测试,覆盖率的诚实版本。
1. 回到 Demo,确认那个反直觉的事实:100% 覆盖率的那套测试,和补了 total=100 的那套,覆盖率一样是 100%,但一个漏 bug 一个抓 bug。
2. 看你项目的覆盖率报告,别看总数,去找覆盖率最低的那几个文件 —— 那才是覆盖率真正有用的信息。
3. 随便挑一个"被覆盖"的复杂函数,问自己:覆盖它的那些测试,断言的是「结果对不对」,还是只是「没崩」?