幕 IV · 谎言与陷阱CH 15进度 15/24

覆盖率的谎言: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,它就会反噬你

⚠ 陷阱 · 「必须达到 80% 覆盖率才能合并」

一旦覆盖率成了硬性指标,古德哈特定律就生效了 —— 当一个度量变成目标,它就不再是好度量。人们会为了那个数字,写出大量没有断言、只为执行代码的测试:把每个函数调一遍,不 assert 任何东西。覆盖率达标了,而测试套的价值是零 —— 甚至是负的(它们还得维护)。

更坏的是,这些假测试给了全团队虚假的安全感:「我们 80% 覆盖呢」,于是没人再去想那些真正难测的边界。第 1 章那句话在这里回响:一个不可能失败的测试,和没有测试一样 —— 而刷覆盖率,批量生产的正是这种测试。

AI 时代:覆盖率是它最容易糊弄的指标

▲ 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. 随便挑一个"被覆盖"的复杂函数,问自己:覆盖它的那些测试,断言的是「结果对不对」,还是只是「没崩」?