你的 Agent 测试全绿了。那就用 Stryker 只对它的改动做变异测试,看看测试能不能发现
(本段仅包含标题,按原样翻译标题。)
我的书:《AI Agents Pocket Guide》;另著有《Thinking in Go》系列两本 ——《Complete Guide to Go Programming》和《Hexagonal Architecture in Go》。我的项目:Hermes IDE | GitHub —— 一款面向使用 Claude Code 等 AI 编程工具交付代码的开发者的 IDE。关于我:xgabriel.com | GitHub
Agent 新文件的覆盖率是 100%,它的两个测试也都通过了。它加了一条折扣规则,并为此写了两个测试。你正准备合并。
规则和测试长这样:
// src/discount.ts
export function discount(total: number): number {
if (total >= 100) {
return total * 0.1;
}
return 0;
}
// src/discount.test.ts
import { expect, test } from "vitest";
import { discount } from "./discount";
test("big orders get a discount", () => {
expect(discount(150)).toBeGreaterThan(0);
});
test("small orders get none", () => {
expect(discount(50)).toBe(0);
});
每行都跑到了,每个分支也跑到了。现在把 >= 改成 >,两个测试照样通过——因为没有任何测试检查金额刚好等于 100 的情况。再把 * 改成 /,依然全绿:1500 也大于 0。两个实实在在的 bug,零个失败的测试。
覆盖率只能告诉你某一行被执行过,却不会告诉你:如果这一行写错了,测试会不会失败。变异测试(mutation testing)测的正是后者,而且你可以只让它针对 Agent 改过的那部分代码运行。
什么是变异体,为什么「存活」才是关键词
变异测试工具会对你的源码做小幅、刻意的修改,一次只改一处,然后拿测试去跑每一个改过的版本。每个改过的版本就叫一个变异体(mutant)。StrykerJS 是 JavaScript 和 TypeScript 领域最知名的工具。它支持的变异类型就包括上面那两处改动,还有更多:a >= b 会变成 a > b 或 a < b,算术运算符会被调换,if 条件会被替换成 true 或 false,整个代码块会被清空。
Stryker 的用词很直白。一个变异体被「杀死」(Killed)是指「在这个变异体生效期间,至少有一个测试失败了」。它「存活」(Survived)是指「在这个变异体生效期间所有测试都通过,这个变异体活了下来,说明你缺一个能发现它的测试」。没有覆盖率则意味着根本没有测试执行到那段代码。
变异得分(mutation score)等于被检测出的变异体数量(被杀死加上超时)除以有效变异体总数。
存活变异体(survivors)就是这样一份行号清单:这些地方即使埋入一个可信的 bug 也照样能上线,而你的测试仍然会全绿通过。用它当评审清单,比盯着覆盖率数字有用得多;对于 agent 为了让自己的改动通过而写的测试,这正是应该问的问题。被捕获的变异体会被测试抓住,存活下来的则直接溜出门外。
这和 diff 覆盖率是两回事。diff-cover 会“自动找出需要测试覆盖的 diff 行”,它能抓到的是完全没有测试跑过的行。前面那些形同虚设的测试,在 diff-cover 眼里能拿满分。变异测试抓的是跑了但从未被真正断言过的行。
第一步:安装 Stryker 和 Vitest 运行器
StrykerJS 当前主版本是 10(v10.1.1,2026 年 10 月 23 日发布),v10.0.0 已经不再支持 Node.js 20,需要 Node 22 或更高版本。
按照入门指南,可以用 npm init stryker@latest 跑交互式配置。如果你已经确定用 Vitest,可以直接装这两个包:
npm i -D @stryker-mutator/core \
@stryker-mutator/vitest-runner
然后在仓库根目录放一个精简的 stryker.config.json:
{
"testRunner": "vitest",
"reporters": ["clear-text", "progress", "html", "json"],
"thresholds": { "high": 80, "low": 60, "break": 60 }
}
这里有几项配置值得注意,参考配置文档:
json报告器会写出reports/mutation/mutation.json,第三步要用它。html报告器会写出reports/mutation.html。- 设置了
break之后,一旦得分低于该值,Stryker 会以退出码 1 结束,脚本或 CI 任务就能据此判定失败。 - Vitest 运行器始终使用按测试覆盖分析(per-test coverage analysis),每个变异体只跑覆盖到它的测试;它不支持 Vitest 的浏览器模式。
配置里不要写 mutate,第二步的脚本会提供它。
第二步:只对 agent 分支改动的文件做变异
agent 的成果在它自己的分支上。用三点 diff 就能拿到该分支从 main 分叉之后的所有改动。Git 文档对它的解释是:git diff A...B“等价于 git diff $(git merge-base A B) B”,这样期间合入 main 的提交就不会混进来。
#!/usr/bin/env bash
# scripts/mutate-diff.sh: 只突变当前分支的改动
set -euo pipefail
base="${1:-main}"
files=$(git diff --name-only --diff-filter=d \
"$base"...HEAD -- 'src/*.ts' \
':!*.test.ts' ':!*.spec.ts' | paste -sd, -)
if [ -z "$files" ]; then
echo "自 $base 以来没有改动的源文件。"
exit 0
fi
npx stryker run --mutate "$files"
每部分的作用:
--diff-filter=d会剔除已删除的文件。在 Git 中,小写过滤字母表示排除该状态,所以没必要让 Stryker 去突变一个已经不存在的文件。:!路径过滤会排除测试文件。这一点很关键,因为根据 Stryker 文档,命令行上的--mutate值会“完全替换”配置文件中的值,这样一来 Stryker 默认跳过测试文件的行为就不再生效。paste -sd, -用逗号把路径拼接起来,正好是--mutate接受的列表格式。
三点对比(...)比较的是提交,因此会漏掉 agent 还没提交的改动。如果想把这些也算上,可以改成拿合并基(merge base)和工作区做 diff:
git diff --name-only $(git merge-base main HEAD)
新建但还没跟踪的文件仍然不会出现,除非先 git add。
如果某个分支只改了 600 行文件里的一个函数,还可以把范围收得更窄。mutate 接受 src/app.js:1-11 这样的行范围,所以当按文件跑太慢时,可以把 git diff -U0 的各个 hunk 转成行范围。不过按文件跑本身是个不错的默认值:改动周围那些未被改动的代码里如果还有存活突变体(survivor),就说明 agent 的修改有多依赖那些没人真正测过的代码。
如果 agent 在迭代过程中要反复跑,加上 --incremental。Stryker 会把结果存到 reports/stryker-incremental.json,下次运行时复用,这样只有受新改动影响的突变体才会重新测试。
第 3 步:把存活突变体整理成可执行的清单
HTML 报告适合浏览。但如果要把工作交回给 agent,你需要的是纯文本行。JSON 报告遵循突变测试报告 schema:files 以路径为键,每个突变体有 mutatorName、带从 1 开始计数的 start.line 的 location、可选的 replacement,以及 status。
# scripts/survivors.jq
.files
| to_entries[]
| .key as $file
| .value.mutants[]
| select(.status == "Survived"
or .status == "NoCoverage")
| "\($file):\(.location.start.line)"
+ " \(.mutatorName)"
jq -r -f scripts/survivors.jq \
reports/mutation/mutation.json
每行输出一个文件名、行号、变异类型,以及替换掉原代码的那段代码。拿折扣函数手工推演一遍,你应该会看到两个幸存者,分别在 if 行和它里面的 return 行:一个是 >= 的边界,一个是 * 换成 /。取反的比较、true/false 条件、清空的代码块,都会让两个测试中的一个挂掉,所以全被杀死。
每个幸存者都在告诉你缺了哪条断言。加一个正好取 100 的测试,就能杀死边界变异体。断言具体金额而不是「大于零」,就能杀死算术变异体:
test("discount starts at exactly 100", () => {
expect(discount(100)).toBeCloseTo(10);
expect(discount(99)).toBe(0);
});
test("discount is ten percent", () => {
expect(discount(150)).toBeCloseTo(15);
});
这里用 toBeCloseTo,是因为乘 0.1 在浮点数里不总是精确(3 * 0.1 得到 0.30000000000000004),而且费率以后可能还会改。
并不是每个幸存者都代表缺测试。有些变异是等价的:改完的代码行为一样,任何测试都杀不死。日志行和防御性默认值常落在这类里。把这份清单当成待审的问题,而不是 agent 必须清零的待办列表。
第 4 步:落地之前,先在 agent 的 worktree 里跑一遍
这部分正好是 Hermes IDE 的用武之处——Hermes 是我做的,所以请带着这点来看。在 Hermes 里按 ⌘N(Windows 和 Linux 上是 Ctrl+Shift+N),每个任务都会在自己的 hermes/<name> 分支、独立的 git worktree 里启动。这正是 mutate-diff.sh 想要的:分支上只有该 agent 的改动,在那里跑 Stryker 既不碰你的主检出版本,也不碰别的 agent 的目录。
Review Desk 的 Worktrees 标签页:~/code 下为 orders-api、web-app、orders-types 各开一个 Hermes worktree,每个都显示自己的 hermes/ 分支、任务、存在时长和磁盘占用。在任务所属的 worktree 里打开一个普通终端,跑那两个脚本就行。
如果希望每次任务都自动跑一遍,Hermes 会从 .hermes/worktree.toml 里读取 done_when 列表,作为任务默认的检查项(recipe 文档)。这样一来,什么时候算任务完成,由你自己的检查项决定:
# .hermes/worktree.toml
setup = ["npm ci"]
done_when = ["npm test", "bash scripts/mutate-diff.sh"]
动手之前先考虑成本。即便只对 diff 做突变,再加上按测试的覆盖率,一次突变运行也会反复执行测试好多遍。改动大的时候,跑几分钟很正常。折中的办法是:在 Land 之前手动跑一次。然后把存活的变异体交回去。
Review Desk(⌘G,Windows 和 Linux 上是 Ctrl+Shift+G)可以按轮次或按文件查看任务的改动,在那里写的行内评论会回到写这行代码的智能体。比如你在 discount.ts 里 if 那一行留一句:「把 >= 改成 > 的变异体存活了,在正好 100 的位置补一个测试」,智能体拿到的就是一个具体、可验证的任务,而不是笼统的「把测试写好点」。另一种做法是把 survivors.jq 的输出直接粘进智能体的终端,当作下一步的提示词。
上面这张图是 orders-api 中一个 Claude Code 任务的 Review Desk(⌘G)按轮次视图:列出了两轮,第 1 轮 src/db.ts 的 diff、改动文件列表,以及根据各轮次草拟的提交信息。
当你 rerun 回来,之前约定要修掉的存活变异体都消失了,Land 就会提交、开一个拉取请求,或者在本地合并回任务起始的那个分支,并且这一步可以撤销。
第 1 到第 3 步都不需要 Hermes。git worktree add -b agent/discount ../discount 一样能给你一个隔离的分支,那些脚本在那里照样能跑。
它能说明什么,不能说明什么
diff 上拿到高变异分数,说明当代码以 Stryker 懂得的方式发生变化时,智能体的测试会有反应。它并不说明代码实现了需求单里要的功能。一套测试可以杀掉每一个变异体,却仍然把错误的业务规则写死,所以你还是得读 diff。
它也不能替代基于属性的测试——那类测试专门去找能打破不变量的输入。突变测试是给你已有的测试打分;属性测试则生成新的测试:用属性测试去找你没想到的输入,用 Stryker 检查你对已想到的那些输入写下的断言。
它真正能廉价给你的,是一个不再盲信「测试全过」的办法——尤其是当同一个 agent 既写代码又写测试的时候。如果这篇文章对你有用:agent 给自己打分,是护栏设计里的核心难题之一——检查必须来自产生这次变更的闭环之外。《AI Agents Pocket Guide》讲了这一点,也讲了让自主 agent 保持诚实的其他模式,篇幅很短。如果你想让每个 agent 的改动各占一个分支,并在合入前走一道评审,Hermes IDE 采用 BSL 1.1 源码可用许可,支持 macOS、Windows 和 Linux。想看看内部实现,可以到 GitHub 上翻。
作者其他作品:《AI Agents Pocket Guide》手册;《Thinking in Go》两本套系——Go 编程完全指南 + Go 六边形架构。
我的项目:Hermes IDE | GitHub —— 一款面向使用 Claude Code 及其他 AI 编程工具交付的开发者打造的 IDE。
关于我:xgabriel.com | GitHub
Agent 新增文件的覆盖率是 100%,它的两个测试也都通过了。它加了一条折扣规则,并为这条规则写了两个测试。你正要合并。
规则和它的测试:
// src/discount.ts
export function discount ( total :