100% 覆盖率为何仍有 Bug:.dotfiles 的 Mutation Testing 变异测试入门
100% 覆盖率为何仍有 Bug:.dotfiles 的 Mutation Testing 变异测试入门
一句话看懂问题:覆盖率和"捉虫能力"是两回事
很多开发者都有过这种错觉:测试覆盖率 100%、测试全部绿色,代码就是安全的。但现实是,覆盖率(Code Coverage)只告诉你"哪些代码被执行过",变异测试(Mutation Testing)才告诉你"你的测试能不能发现真正的 Bug"。
.dotfiles 这个开源项目(以 CLAUDE.md 开发指南闻名的个人 dotfiles 仓库)内置了一个 mutation-testing 技能,专门解决"100% coverage but bugs still slip through"这一经典痛点。仓库里还配了一个完整的练习场 checkout-core:一个纯计价逻辑的购物结算模块,行覆盖率 100%,却因为弱断言放走了 20 个"变异体"。下面我们就用它来做一次 Mutation Testing 入门。
一个真实事故:100% 覆盖率的订单算错了钱
在 evals/skills/fixtures/mutation-testing-workspace 里,checkout-core 提供小计、折扣、运费和订单汇总四类纯函数,没有 UI、没有 IO,是学习变异测试的理想小样本。
它的事故场景很生活化:一位顾客购物车恰好凑满 £100.00,却被按原价收费——批量折扣只在上超过 £100 时生效,没有覆盖"恰好等于"这一侧。而当时:
- 行覆盖率 100%;
- src/checkout.test.ts 里的测试全部通过。
问题出在测试的"弱"。典型的弱断言长这样(摘自 src/checkout.test.ts):
it("charges for small domestic orders", () => {
expect(shippingPence(order())).toBeTypeOf("number"); // 只断言"是数字"
});
it("combines the parts", () => {
expect(totalPence(order())).toBeGreaterThan(0); // 只断言"大于 0"
});
这类断言几乎不可能失败:>= 被改成 >、* 被改成 /、&& 被改成 ||……测试照样全绿。弱断言 + 覆盖率 = 虚假的安全感,这正是变异测试要戳破的泡沫。
什么是 Mutation Testing:给代码"动手术"
变异测试的思路可以理解为"故意制造 bug,看测试会不会抓出来":
- 生成变异体(Mutants):工具自动在生产代码里引入小改动,比如把
subtotal >= BULK_THRESHOLD_PENCE改成subtotal > BULK_THRESHOLD_PENCE; - 跑测试:对每个变异体执行你的测试套件;
- 判定结果:测试失败,说明变异体被"杀死"(Killed),测试有效;测试通过,说明变异体"存活"(Survived),这里藏着一个你的测试发现不了的 bug。
一句话记住:一个存活变异体 = 一个你的测试抓不到的潜在 Bug。
checkout-core 基线运行 44 个变异体:24 个被杀死、20 个存活,其中 1 个是"等价变异体"。这些数字来自仓库的评估脚本 evals/skills/mutation-testing-assertions.js,它固定了基线(44 个变异体、20 个存活),并规定修复后存活数必须降到"1 个等价 + 最多 1 个可判断"才算通过。
读懂变异测试报告:4 种结果,4 种处理方式
跑完 Stryker 后,报告里的每个变异体都会归入 4 类。这是 claude/.claude/skills/mutation-testing/SKILL.md 中给出的分类表:
| 类别 | 含义 | 你应该做什么 |
|---|---|---|
| Killed | 变异后测试失败 | 不用管,测试有效 |
| Survived | 变异后测试仍通过 | 补强测试(除非是等价变异体) |
| No Coverage | 没有测试执行到这段代码 | 补行为测试 |
| Equivalent | 变异后行为完全一样 | 不用管,但它必须被"点名"并说明理由 |
⚠️ 新手最容易踩的坑:把所有存活变异体都当缺陷。像
if (items.length === 0) return 0;被改成if (false)后,空数组的reduce本来就返回 0——行为没有任何差异,这就是 Equivalent(等价变异体),没有任何测试能杀死它。正确的做法是记录一行理由,而不是徒劳地写测试。
动手:用 Stryker 给 checkout-core 做变异测试
JS/TS 项目的标准起点是 Stryker 里已经声明了 @stryker-mutator/core 和 @stryker-mutator/vitest-runner,但故意没有配置文件和脚本——这正是仓库设计的教学缺口,让你走完"从零到跑通"的全流程。
1. 最小起步配置
技能文档推荐 stryker.config.mjs 起步配置:
export default {
testRunner: "vitest", // 优先用项目自己的测试运行器
coverageAnalysis: "perTest",
reporters: ["html", "clear-text", "progress"],
mutate: [
"src/**/*.{ts,tsx,js,jsx}",
"!src/**/*.test.{ts,tsx,js,jsx}", // 只变异生产代码,排除测试
"!src/**/*.d.ts"
]
};
两条黄金规则:
- 只变异生产代码:测试、fixture、快照、生成文件、
dist等一律排除; - 首次配置不要写
break阈值:没有基线之前,任何阈值要么是"第一天就红",要么是摆设。先跑出分数,再定阈值。
2. 三个常用命令:全量、增量、按分支 diff
技能建议一次性加齐三条脚本(SKILL.md):
{
"mutation": "stryker run",
"mutation:incremental": "stryker run --incremental",
"mutation:diff": "node scripts/stryker-diff.mjs <review-base>"
}
pnpm mutation:全项目跑,适合首次建立基线、改测试基础设施或上 CI 门禁;pnpm mutation:incremental:带缓存的增量跑,日常反馈最快;pnpm mutation:diff [base]:只对当前分支相对基线(默认 main)改动过的生产文件做变异,是 PR 门禁的主力。
其中 mutation:diff 有两个值得学习的设计细节(来自 evals/skills/tests/mutation-testing-quality.yaml 的验收要求):
- 脏工作区直接拒绝:
git diff <base>...HEAD不包含未提交内容,所以工作区不干净时必须停下并说明,而不是悄悄给你一个"假完整"的结果。验收测试 diff-script.test.ts 会专门制造一个未跟踪文件来验证这条拒绝逻辑; - 用 Node 辅助脚本而非一坨 shell:解析 NUL 分隔的
git diff --name-only -z输出、过滤测试文件、调用仓库本地 Stryker 二进制——这些用 shell 拼接极其脆弱。
3. 杀死那 4 个"肇事"变异体
验收测试 boundaries.test.ts 直接模拟了 4 个会放走真实 bug 的变异,并逐一要求"变异后你的套件必须变红":
| 变异体 | 后果 | 怎么补测试 |
|---|---|---|
>= → >(批量折扣门槛) |
恰好 £100 不打折 | 在 10_000 和 9_999 两侧各断言精确值 |
&& → ||(会员+码折扣) |
非会员凭码也能打折 | 三种组合(有码非会员/会员无码/两者都有)分别断言 |
>= → >(免邮门槛) |
恰好 £50 仍收运费 | 5_000 免邮、4_999 收 399 分 |
* → /(小计计算) |
单价乘以数量变成除以 | 用非恒等值:250 × 3 = 750,而不是 100 × 1 |
规律非常清晰:边界值测两侧、布尔组合测全、拒绝恒等值(1、0、空集合)、断言精确结果而非"是数字/大于 0/不抛错"。这些也正是 SKILL.md 列出的"立即修复"清单:缺失的边界测试、弱断言、单侧分支覆盖。
什么时候跑变异测试?PR 就绪门禁,而不是每次提交
这是 .dotfiles 里这套方法最容易被误解的一点:变异测试不放在 RED-GREEN-REFACTOR 内循环里。
它的工作流(SKILL.md)是:
每个 TDD 增量:
RED → GREEN → REFACTOR,只靠变异规则"想"测试点,不跑 Stryker
阶段收尾的 PR 就绪门禁:
① 全量基线跑 1 次(整项目仅这一次)
② 为每个值得杀死的存活变异体补强测试
③ 之后只做聚焦重跑:--mutate <file>:<start>-<end> / --incremental / diff 脚本
④ 最多再来 1 次全量做最终验证,交出一份"已分诊"的报告
原因很实际:变异测试的成本随代码库增长,每次提交都跑会让反馈环越来越慢。门禁内最多两次全量跑(基线 + 最终验证),其余检查全部聚焦到具体文件或行范围,比如:
pnpm exec stryker run --incremental --force --mutate src/checkout.ts:27-32
一个容易浪费时间的细节:基线跑完的存活列表已经打印在报告里,不要为了"再看一眼"重跑全量——把第一次输出存下来(tee 或 json reporter)慢慢分诊。
新手实践清单
把上面的内容压缩成一张可操作的清单:
- 装:
@stryker-mutator/core+ 对应测试运行器插件(vitest/jest/mocha),配置里testRunner指向项目真实运行器; - 划范围:
mutate只覆盖生产代码;把.stryker-tmp/和报告目录加进.gitignore; - 跑基线:第一次不写
break阈值,记下分数; - 分诊:每个存活变异体先问"它等价吗?"——没有任何输入能产生不同行为,就记为 Equivalent 并写一行理由;
- 杀:为剩下的存活体写最小行为测试(断言可观察结果,不迎合实现细节),再聚焦重跑到全灭;
- 固化:加
mutation/mutation:incremental/mutation:diff三条脚本,把 diff 跑放进 PR 门禁。
💡 想感受"弱测试"被揭穿的完整过程,可以直接对照仓库里的两份评估文件:mutation-testing-quality.yaml 描述了两个教学案例("覆盖率 100% 却放走了线上 bug"与"从零搭起门禁"),mutation-testing-assertions.js 则用可执行断言规定了什么叫"通过"。
常见问题(FAQ)
Q:变异分数(Mutation Score)要追求 100% 吗? 不要把它当虚荣指标。优先处理改动区域和高风险代码(金额、权限、资格判定、数据丢失)里的存活变异体,整体分数作为趋势信号即可。
Q:Equivalent 变异体算失败吗? 不算,但必须显式点名并说明理由。只列清单不做分类,不是"已分诊"的报告。
Q:没有 Stryker 能跑吗? 可以手工做:挑一个边界条件手动改动、跑测试、记录、还原。手工变异更慢,但适合教学或工具覆盖不到的场景,流程与自动跑一致。
Q:和覆盖率报告冲突吗? 不冲突。覆盖率是"执行"的度量,变异测试是"检测"的度量。先有覆盖率,再用变异测试回答"这些被执行的行,断言真的在保护行为吗?"
小结
回到开头的问题:为什么 100% 覆盖率仍有 Bug? 因为断言弱——toBeTypeOf("number") 和 toBeGreaterThan(0) 测不出任何具体行为,覆盖率数字依然漂亮。而 .dotfiles 仓库给出的答案是:用 mutation-testing 技能 在 PR 就绪时跑一次 Stryker 门禁,把 20 个存活变异体逐一"杀死或分类",让测试套件真正具备"抓虫"能力。覆盖率证明代码被执行,变异测试证明测试守得住行为——两者缺一不可。