覆盖率与假绿灯:识别无效测试
覆盖率是最容易制造安全感的指标。一个 assert err == nil 的测试就能把行覆盖率推到 100%,而它连返回值是不是空的都没检查。要处理三件事:三种覆盖率口径各自骗你在哪里、无效测试长什么样、怎么让 AI 审查测试本身。
三种口径与它们的欺骗性
| 口径 | 统计对象 | 常用工具 | 典型欺骗 |
|---|---|---|---|
| 行覆盖(Line) | 被执行到的代码行 | coverage.py 默认、JaCoCo、Istanbul | 一行里写三个条件,走通一条就算这行已覆盖 |
| 语句覆盖(Statement) | 被执行到的可执行语句 | go test -cover、-coverprofile | 短路表达式与空分支也算走通 |
| 分支覆盖(Branch) | 判断的真/假两侧、循环体进出 | --cov-branch、JaCoCo 分支计数 | a && b 只统计整个表达式的真/假,不展开条件组合 |
| 条件/路径覆盖(MC/DC、Path) | 条件组合与执行路径 | 少数安全行业工具 | 组合数指数增长,日常项目基本不跑 |
# Go:语句覆盖统计 + 未覆盖行定位
go test ./... -covermode=atomic -coverprofile=cover.out
go tool cover -func=cover.out | tail -5 # 总覆盖率与函数级明细
go tool cover -html=cover.out # 在浏览器里看哪些行是红的
# Python:加 --cov-branch 才是分支覆盖,默认只有行覆盖
pytest --cov=src --cov-branch --cov-report=term-missing
一个 100% 行覆盖却什么都没验证的例子:
func Discount(u User) int {
if u.Vip && u.Age > 60 { // 只测 Vip=false,这一行就算被"覆盖"了
return 20
}
return 0
}
只调用 Discount(User{Vip: false, Age: 0}),行覆盖率是 100%,但 Age > 60 从来没有被求值为真。这就是口径的欺骗性:覆盖率统计的是「代码被执行过」,不是「行为被验证过」。
无效测试的七种长相
| 模式 | 代码气味 | 检出方法 | 修法 |
|---|---|---|---|
| 只断言不报错 | 测试体里只有调用,没有断言 | 注释掉被测函数的返回值,看它是否还绿 | 断言可观察结果(返回值、状态、副作用表) |
| 断言恒真 | assertTrue(x != null)、len(list) >= 0 | 把断言手动改成「必须为假」,看原断言是否本来就不会失败 | 断言具体值与边界区间 |
| 同源期望值 | 期望值由被测实现的同一套逻辑算出来 | 看断言右侧是否调用了被测方法或同一私有函数 | 期望值手写字面量 |
| 只断言交互 | verify(mock).save(any()) | 改内部实现(行为不变)是否变红 | 断言最终状态,交互断言只用于副作用本身 |
| 异常断言过宽 | assertRaises(Exception) | 让实现抛出另一种异常,看是否仍然通过 | 断言具体异常类型与消息 |
| 顺序依赖 | 单独跑某条用例就失败 | 随机顺序执行、单条执行 | 每个用例自造数据,不依赖同文件里的其他用例 |
| 休眠等待 | sleep(2) 等异步结果 | 看执行时长与偶发失败率 | 轮询到条件成立,带超时上限 |
再补一条最容易被忽略的:黄金文件(Golden File)被随手更新。快照类测试的价值在于 diff 必须人工确认,一旦养成「红了就重新生成一次」的习惯,它就退化成了一条永不失败的测试。
让 AI 审查测试本身
审查测试和审查代码是两套提示词:前者关心「断言的可失败性」,后者关心正确性。
下面这些测试全部通过。请审查「测试本身」的质量,不要审查被测代码的实现风格。要求:
1) 逐条列出弱断言与无效断言,格式:文件:行号 | 断言内容 | 为什么它可能永远为真 | 改成什么;
2) 找出「期望值由被测实现的同一套逻辑算出来」的地方——这类测试会跟着 bug 一起错;
3) 找出只断言 err == nil、len(x) > 0、对象非空这类存在性断言,并说明它漏掉了什么;
4) 找出互相依赖、必须按顺序执行才能通过的用例,写清依赖了哪个状态;
5) 给出 3 条「把实现故意改错后测试仍然会通过」的具体改法:改哪一行、为什么不会变红;
6) 这套测试行覆盖率 92%,请直接回答:剩下最容易出问题、却没有被任何断言覆盖的行为是什么?
不要输出「建议补充更多边界用例」这类空话,每条都要引用具体代码。
覆盖率的正确用法是当「下限警报」而不是「目标」:--cov-fail-under 的作用是阻止覆盖率倒退(棘轮,Ratchet),门禁应该盯「本次改动的增量覆盖率」(diff 覆盖率)而不是全仓库数字——历史包袱不该由一次新提交还清。
常见坑
| 坑 | 后果 | 规避 |
|---|---|---|
| 把覆盖率当考核指标 | 有人写出无断言的测试冲数字 | 门禁盯增量覆盖率,并配合变异测试或人工抽查 |
| 用覆盖率证明「测好了」 | 假绿灯,线上照样出事 | 覆盖率只说明代码被执行过,不说明行为被验证过 |
| 只测顺利路径 | 分支口径很高,错误路径没走 | 强制要求每条 if err != nil 至少一条用例 |
| 为凑覆盖率补测试 | 维护成本上升、信心下降 | 覆盖率下降要写理由,而不是无脑补用例 |
| 把变异测试设成必跑门禁 | 执行太久,团队直接绕过 | 只对核心模块抽样运行 |
| 删掉失败的测试 | 红灯消失,问题保留 | 失败用例要么修实现,要么修用例并说明理由 |
小结:覆盖率是「执行过」的度量,不是「验证过」的度量——行覆盖看得见语句、看不见条件,分支覆盖看得见条件、看不见期望值;识别无效测试靠四板斧:让断言有失败的可能(改坏实现看它是否变红)、期望值手写、断言状态而非交互、用例之间互不依赖;可以让 AI 按清单审查测试本身,但最终只有「故意改坏代码,测试会不会红」这个实验能给出结论。