周五上午,2 号机上的代理交了一个 PR:给结算服务加一个 pricing.py,百分比折扣、年消费分层、退款授权、运费四段逻辑,外加 14 个单元测试。描述很规整,GitHub 上一行红字都没有:14 passed,coverage 100%。
轮到 4 号机审查。按我们定的规矩,覆盖率达标、测试全绿,审查代理就该放行。但我手痒,先在实现里改坏了一行——把退款窗口的 order.age_days <= 14 改成 < 14,等于第 14 天不让退了,一个真实的行为回退。重跑那 14 个测试:
14 passed in 0.03s
还是全绿。我又把分层的 >= 200000 改成 > 200000(正好 20 万的客户掉级)、把运费的 800 改成 801,分别重跑——每次都是 14 passed。一个 100% 覆盖率、全绿的测试套,我在它测的代码里连着埋了三个 bug,它一声没吭。
覆盖率回答的不是你以为的那个问题
覆盖率只回答一件事:这行代码在测试里跑过没有。它不回答那个真正要紧的问题:这行要是写错了,有没有哪条测试会因此变红。代理特别擅长把前者刷满——它会调用每个函数、走过每个分支,然后写一句谁都不会 fail 的断言。研究里这个差距量化得很吓人:有的 LLM 生成测试做到 100% 行覆盖率,变异得分只有 4%;另一批 200 个函数上 87% 的分支覆盖率,只能杀掉 31% 的变异体。失败模式很固定:只验 happy path、只 assert 结果 is not None、只 isinstance(x, int)、把实现跑一遍把返回值抄进 assert。
我把这个代理 PR 在机器上复刻了一遍,连同一个能量化"测没测住"的小工具。
在机器上量一量:覆盖率 100%,变异得分 32%
pricing.py 是 37 条语句的真实业务,每个分支对应一条规则(折扣满额才打、折扣封顶、退款 14 天窗口、运费按公斤向上取整)。代理风格的 14 个测试长这样:
def test_apply_coupon_returns_int():
assert isinstance(apply_coupon(10000, 20, 5000, 3000), int) # 只验类型
def test_can_refund_other_user():
o = Order(4, 7, 9900, 3, False)
assert can_refund(o, 8, False) is not None # 只验"有返回值"
def test_tier_silver():
assert tier_for_spend(100000) == "silver" # 值对,但边界一个没碰
coverage report 给出 100%。然后我写了个 80 行的变异测试器 mutate.py:把源码解析成 AST,对每个节点做一处小改动——比较运算符互换(<=↔<、>↔>=)、整数常量 ±1、and↔or、+↔-、*↔//、return X 改成 return None——每改一处就在临时目录里重跑整套测试。测试因此变红,这个"变异体"就算被杀死(说明有断言盯着这行);测试照样全绿,变异体存活(这行改了没人发现)。变异得分 = 杀死数 / 总数。
写这个器有个坑值得记:第一版我用 (行号, 列号, 节点类型) 去克隆树里定位要改的节点,结果 subtotal * percent // 100 这种嵌套表达式,外层 // 和内层 * 的行号列号完全相同,定位时命中了外层,等于把 // 改成 //——什么都没改,于是报了个假存活。改成"先序遍历下标唯一编号"才对。这正好是变异测试本身的一个教训:等价变异体和定位错误都会制造假存活,工具得先对自己诚实。
对那 14 个代理测试跑变异:
变异体总数 47 杀死 15 存活 32 变异得分 32%
--- 存活的变异体(测试没抓到)---
L21: 比较 GtE->Gt # 分层边界翻转,没测
L43: 比较 LtE->Lt # 退款窗口 14 天,没测
L43: 常量 14->15 # 窗口 ±1,没测
L49: 常量 1000->1001 # 运费第二公斤的门槛,没测
...(还有 28 条)
32%。覆盖率 100% 和变异得分 32% 之间那 68 个百分点,就是我刚才埋三个 bug 没人管的那片区域。

两道门,一道比一道难绕
第一反应是加个静态扫描,把那些明显的弱断言直接拦掉。我写了 assert_audit.py:一个测试函数如果通篇只有 is not None / is None、isinstance(...)、>= 0、assert True 这类恒真或极弱的断言,或者干脆没有断言,就标红。它在代理那套里拦下了 7 条。
但静态扫有个天花板,我专门造了一套"修过"的测试来撞它:把明显的弱断言全删掉,每条都改成 assert 具体数值 == ...——看起来规规矩矩。问题是那些值全是把实现跑一遍抄进去的,而且只走 happy path、不碰边界:
def test_coupon_1(): assert apply_coupon(10000, 20, 5000, 3000) == 8000
def test_tier_2(): assert tier_for_spend(100000) == "silver"
def test_ship_2(): assert shipping_fee(2500, False) == 1800
静态扫 0 条告警,全绿。但跑变异:
变异体总数 47 杀死 27 存活 20 变异得分 57%
57%——边界翻转、常量 ±1 这类变异体大批存活,因为这套测试从没在边界上取过值。静态扫拦不住这种"看起来有断言"的测试,只有变异测试拦得住。所以第二道门必须是变异得分阈值,而不是指望 lint 管到底。
人写的那套测试,断言踩的是业务规则而不是实现输出,而且每个边界都取了"边界点"和"边界点 ±1"两个值(第 14 天能退、第 15 天不能退)。同样 100% 覆盖率,变异得分 94%。
94% 为什么不是 100%,以及门该设在哪
strong 那套剩下 3 个存活变异体,我逐个手工验证了:if weight_g > 1000 改成 >= 1000,在 weight == 1000 时后面算出的额外公斤是 (1000 - 1000 + 999) // 1000 = 0,结果完全一样——这叫等价变异体,改了语法但行为不可能变,没有任何测试能杀死它。这类东西决定了变异得分天然到不了 100%。所以门不能卡"必须 100%",只能卡一个阈值(我设 70%),再让人去读那份存活清单,把真漏洞补上、把等价变异体在 PR 里标注清楚。
还有一个失败值得写下来:assert_audit.py 第一版还加了条"一个测试里超过 3 个断言就告警"(Assertion Roulette,出错说不清是哪条挂的)的规则,结果把人写的 test_tier_boundaries——一条测试里连验 4 个分层边界——误判成坏味道,把本该通过的 strong PR 拦了下来。多个边界断言挤在一条测试里是好事不是坏事。我把这条规则从"阻断"降成"仅提醒",strong PR 才通过。静态规则的误杀成本,往往比它拦下的真问题还高,所以能用执行结果(变异得分)判的,就别用静态规则硬卡。
落到 7 台机器上
把两道门接进 CI,关键是只对本次 PR 改到的 src/*.py 跑变异——全仓库变异太慢,但一个 PR 通常就动几个文件。review-gate.sh 做三件事:对改到的测试跑静态弱断言扫(有硬伤直接红)、对改到的源文件跑变异测试、得分低于阈值就红,并把存活清单打到日志里。我在一个真的 git 仓库里验证了它:代理那套 weak PR → exit 1(静态扫先红);人写的 strong PR → exit 0(变异 94% ≥ 70%,smell 只提醒)。

机器分工就顺了:1–3 号机写代码的代理,提交前本地跑 review-gate.sh;4–5 号机的审查不再逐行读全部 diff,而是先读机器给出的那份"存活变异体"清单——每一条都是一句具体的话"这行改了也没人会发现";7 号机在清单清空(或剩下的都标注为等价变异体)后合并。
反方:变异测试不是银弹
得把话说全。第一,变异得分只在"被测代码假定是对的、测试是回归护栏"这个场景里是可信信号;如果代码本身就带 bug、你指望测试去暴露它,有复现研究发现这时覆盖率和变异得分都不再和"抓真 bug"相关——别把 90% 变异得分当成"代码正确"的证明,它只证明"测试对改动敏感"。第二,变异测试慢,等价变异体还要耗人工去判,所以只能跑在改动文件上、设阈值而不是追满分。第三也是最容易忽略的:变异测试只保证"这行改了测试会红",不保证"断言里的那个期望值是对的"——如果代理把一个有 bug 的实现的输出抄进 assert,变异测试照样给高分。业务数值对不对,仍然得有人拿需求去核对。所以这两道门是测试强度的下限,不是正确性的上限。
判断与明天能抄的那一步
把"覆盖率 100% 全绿"从验收标准里拿掉——它只说明代码跑过,不说明被测住。换成:对本次 PR 改到的源文件跑变异测试,阈值 70%,审查第一件事是读存活清单而不是读整个 diff;裸的 is not None / isinstance / >= 0 断言在 CI 里直接红;每个带边界的函数,强制写"边界点"和"边界点 ±1"两条用例。脚本、CI 工作流和 AGENTS.md 片段打包在这里:mutation-gate-kit.tar.gz。今天先别改验收标准,只在你最关键的那个模块上跑一次变异,把它的变异得分抄在覆盖率旁边——那两个数字之间的差,就是你现在真实的暴露面。
Friday morning, the agent on machine 2 opened a PR: a new pricing.py for the billing service — percentage coupons, annual spend tiers, refund authorization, shipping fees — plus 14 unit tests. Tidy description, not a line of red on GitHub: 14 passed, coverage 100%.
Machine 4's turn to review. By our own rule, coverage met and tests green means the review agent should wave it through. But I poked at it first. I broke one line in the implementation — changed the refund window from order.age_days <= 14 to < 14, so day 14 no longer qualifies, a real behavior regression. Reran the 14 tests:
14 passed in 0.03s
Still green. Then I changed the tier boundary >= 200000 to > 200000 (a customer at exactly 200k drops a tier), and the shipping base 800 to 801, rerunning each time — 14 passed every time. A 100%-coverage, all-green suite, and I planted three bugs in the very code it tests without it making a sound.
Coverage answers a different question than you think
Coverage answers one thing: did this line run under test. It does not answer the one that matters: if this line were wrong, would any test go red because of it. Agents are superb at the former — they call every function, walk every branch, and then write an assertion nobody could fail. The research quantifies the gap starkly: some LLM-generated suites hit 100% line coverage with a 4% mutation score; another set reached 87% branch coverage across 200 functions but killed only 31% of mutants. The failure mode is consistent: happy-path only, assert result is not None, isinstance(x, int), or running the implementation once and copying its return value into the assert.
I reproduced this agent PR on the box, along with a small tool that quantifies "actually tested or not."
Measure it on the box: 100% coverage, 32% mutation score
pricing.py is 37 statements of real logic, each branch a rule (discount only above a minimum, discount capped, 14-day refund window, shipping rounded up per kilo). The agent-style 14 tests look like this:
def test_apply_coupon_returns_int():
assert isinstance(apply_coupon(10000, 20, 5000, 3000), int) # type only
def test_can_refund_other_user():
o = Order(4, 7, 9900, 3, False)
assert can_refund(o, 8, False) is not None # "returns something" only
def test_tier_silver():
assert tier_for_spend(100000) == "silver" # right value, no boundary touched
coverage report says 100%. Then I wrote an 80-line mutation tester, mutate.py: parse the source to an AST, make one small change per node — flip a comparison (<=↔<, >↔>=), shift an int constant ±1, swap and↔or, +↔-, *↔//, turn return X into return None — and after each change, rerun the whole suite in a temp dir. If a test goes red, the "mutant" is killed (an assertion was watching that line); if the suite stays green, the mutant survives (the line changed and nobody noticed). Mutation score = killed / total.
One pitfall is worth recording. My first version located the node to change by (lineno, col, node type) in the cloned tree. But in a nested expression like subtotal * percent // 100, the outer // and the inner * share the exact same line and column; the lookup hit the outer node, so changing // to // changed nothing — a false survivor. Numbering nodes by pre-order index fixed it. That is itself a lesson about mutation testing: equivalent mutants and locating bugs both manufacture false survivors, so the tool has to be honest about itself first.
Running mutation on those 14 agent tests:
mutants 47 killed 15 survived 32 mutation score 32%
--- surviving mutants (tests missed them) ---
L21: Compare GtE->Gt # tier boundary flip, untested
L43: Compare LtE->Lt # 14-day refund window, untested
L43: Constant 14->15 # window ±1, untested
L49: Constant 1000->1001 # the second-kilo shipping threshold, untested
... (28 more)
32%. The 68 points between 100% coverage and a 32% mutation score are exactly the region where I just planted three bugs unchallenged.

Two gates, each harder to dodge than the last
The first instinct is a static scan that blocks the obvious weak assertions. I wrote assert_audit.py: if a test function's only assertions are tautological or near-empty — is not None / is None, isinstance(...), >= 0, assert True — or it has no assertion at all, flag it red. It caught 7 in the agent suite.
But a static scan has a ceiling, and I built a "fixed" suite specifically to hit it: delete all the obvious weak assertions, make every one assert <concrete value> == ... — looks disciplined. The catch is that every value was lifted from running the implementation, and it only walks the happy path, never a boundary:
def test_coupon_1(): assert apply_coupon(10000, 20, 5000, 3000) == 8000
def test_tier_2(): assert tier_for_spend(100000) == "silver"
def test_ship_2(): assert shipping_fee(2500, False) == 1800
Static scan: 0 warnings, all green. But mutation:
mutants 47 killed 27 survived 20 mutation score 57%
57% — boundary flips and ±1 constant mutants survive in bulk, because this suite never takes a value at a boundary. The static scan cannot stop a test that "looks like it asserts"; only mutation can. So the second gate has to be a mutation-score threshold, not a hope that the linter covers everything.
The human-written suite asserts the rule rather than the implementation output, and takes two values at every boundary — the boundary point and ±1 (day 14 refundable, day 15 not). Same 100% coverage, mutation score 94%.
Why 94% isn't 100%, and where to set the gate
The strong suite leaves 3 surviving mutants, and I checked each by hand: if weight_g > 1000 changed to >= 1000 — at weight == 1000, the extra-kilo math downstream is (1000 - 1000 + 999) // 1000 = 0, so the result is identical. That's an equivalent mutant: syntax changed, behavior cannot. No test can kill it. These are why mutation score naturally never reaches 100%. So the gate cannot demand 100%; it sets a threshold (I use 70%) and then has a human read the survivor list — fix the real holes, annotate the equivalent mutants in the PR.
Another failure worth writing down: the first assert_audit.py also flagged "more than 3 assertions in one test" (Assertion Roulette — you can't tell which one failed). That misjudged the human test_tier_boundaries — four tier boundaries checked in one test — as a smell and blocked the strong PR that should have passed. Several boundary assertions in one test is a good thing, not a bad one. I demoted that rule from blocking to advisory, and the strong PR passed. A static rule's false-positive cost is often higher than the real problems it catches, so where an execution result (the mutation score) can judge, don't hard-gate on a static rule.
Onto the 7 machines
Wiring both gates into CI, the key is to run mutation only on the src/*.py this PR changed — whole-repo mutation is too slow, but a PR usually touches a few files. review-gate.sh does three things: run the weak-assertion static scan on changed tests (hard fault = red), run mutation on changed sources, and go red below threshold while printing the survivor list to the log. I verified it in a real git repo: the agent's weak PR → exit 1 (static scan red first); the human strong PR → exit 0 (mutation 94% ≥ 70%, smell advisory only).

The machine roles fall out cleanly: the coding agents on machines 1-3 run review-gate.sh locally before committing; review on machines 4-5 stops reading the whole diff line by line and reads the machine's "surviving mutants" list first — each entry is a concrete sentence, "change this line and nobody would notice"; machine 7 merges once the list is empty (or the remainder are annotated equivalent mutants).
Steelman: mutation testing is not a silver bullet
The full picture. First, mutation score is a trustworthy signal only in the setting where the code under test is assumed correct and the tests are regression guards; if the code itself is buggy and you want the tests to expose it, a replication study found coverage and mutation both stop correlating with real-bug detection — don't read a 90% mutation score as proof the code is correct, only that the tests are sensitive to change. Second, mutation is slow, and equivalent mutants cost human time to adjudicate, so run it on changed files and set a threshold rather than chasing a perfect score. Third and most easily missed: mutation only guarantees "change this line and a test goes red," not "the expected value in the assertion is right" — if the agent copied the output of a buggy implementation into an assert, mutation still scores high. Whether the business value is correct still needs a human checking against the requirement. So these two gates are a floor on test strength, not a ceiling on correctness.
The judgment and the step you can copy tomorrow
Drop "100% coverage, all green" as an acceptance bar — it only says the code ran, not that it's tested. Replace it with: run mutation testing on the source files this PR changed, threshold 70%, and make reading the survivor list — not the whole diff — the first thing review does; make bare is not None / isinstance / >= 0 assertions go red in CI; force two cases per bounded function, the boundary point and ±1. Scripts, the CI workflow and an AGENTS.md snippet are packaged here: mutation-gate-kit.tar.gz. Don't change the acceptance bar today; just run mutation once on your most critical module and write its mutation score next to the coverage number — the gap between those two is your real exposure right now.