关于 mutation-testing
mutation-testing 是用于评估测试能否发现错误的技能,方法是有意引入代码变异。它涵盖 TypeScript/JavaScript 的 Stryker 和 Python 的 mutmut,并说明如何解读存活变异体及识别手写测试框架的误导性结果。
主要功能
- 01解释变异体被杀死与存活的含义
- 02指导配置和运行 Stryker
- 03指导配置和运行 mutmut
- 04解读变异分数与存活变异体
- 05指出手写测试框架的常见陷阱
适用场景
- 01找出未能验证行为的测试
- 02识别缺失的断言和边界情况
- 03调查测试套件中的存活变异体
- 04评估自定义测试框架的变异结果
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ laurigates/ claude-plugins --skill mutation-testing安装后在 Claude Code 中输入/mutation-testing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
REFERENCE.mdreferences/hand-rolled-harness-failures.mdtriaging-large-survivor-sets.md详细介绍
变异测试
变异测试方面的专业知识——通过引入刻意的代码变异,验证测试是否确实能捕获错误。
何时使用此技能
| 在以下情况下使用此技能…… | 在以下情况下改用其他技能…… |
|---|---|
| 验证测试的有效性 | 编写单元测试(使用 vitest-testing) |
| 查找薄弱或不充分的测试 | 分析测试异味(使用 test-quality-analysis) |
| 设置 Stryker 或 mutmut | 编写 E2E 测试(使用 playwright-testing) |
| 提高变异分数 | 生成测试数据(使用 property-based-testing) |
| 检查测试能否捕获真实错误 | 仅设置代码覆盖率 |
核心专业知识
变异测试概念
- 变异体:自动引入的小型代码更改(变异)
- 被杀死:测试在存在变异时失败(好——测试捕获了错误)
- 存活:测试在存在变异时通过(不好——测试薄弱)
- 覆盖率:测试执行了变异代码,却未能捕获变异
- 分数:被杀死的变异体所占百分比(目标为 80% 以上)
变异测试揭示的问题
- 未实际验证行为的测试
- 缺失的断言或边界情况
- 过于宽松的断言
- 死代码或不必要的逻辑
- 需要加强测试的部分
TypeScript/JavaScript(Stryker)
安装
# Using Bun
bun add -d @stryker-mutator/core
# For Vitest
bun add -d @stryker-mutator/vitest-runner
# For Jest
bun add -d @stryker-mutator/jest-runner
运行 Stryker
npx stryker run # Run mutation testing
npx stryker run --incremental # Only changed files
npx stryker run --mutate "src/utils/**/*.ts" # Specific files
npx stryker run --reporters html,clear-text # HTML report
open reports/mutation/html/index.html # View report
理解结果
Mutation score: 82.5%
- Killed: 66 (tests caught the mutation)
- Survived: 14 (tests passed despite mutation - weak tests!)
- No Coverage: 0 (mutated code not executed)
- Timeout: 0 (tests took too long)
示例:薄弱测试与强测试
// Source code
function calculateDiscount(price: number, percentage: number): number {
return price - (price * percentage / 100)
}
// WEAK: Test passes even if we mutate the calculation
test('applies discount', () => {
const result = calculateDiscount(100, 10)
expect(result).toBeDefined() // Too weak!
})
// STRONG: Test catches mutation
test('applies discount correctly', () => {
expect(calculateDiscount(100, 10)).toBe(90)
expect(calculateDiscount(100, 20)).toBe(80)
expect(calculateDiscount(50, 10)).toBe(45)
})
在 Vitest 运行器下的静态存活变异体通常是误报
静态变异体会修改在模块加载时运行的代码(常量映射、Zod 模式)。在 Vitest 4.1 中使用 @stryker-mutator/vitest-runner 10.0.0 时观察到:
ignoreStatic: true | 效果 |
|---|---|
| 跳过 | 没有任何测试覆盖的静态变异体 |
| 不跳过 | 有测试覆盖的静态变异体——这些变异体仅针对 Stryker 归因其覆盖的 1–2 个测试运行,这些测试往往来自最先导入该模块的无关文件 |
因此,静态变异体可能报告为存活,即使真正断言该代码的测试本应将其杀死。某个 Next.js 应用中,766 个存活变异体里有 384 个标记为 static: true,且 testsCompleted <= 5;排除这些变异体后,分数从 68% 升至约 79%。针对许多测试运行过的静态存活变异体则是真实问题(从 Zod 模式中删除 .trim() 后,该变异体在 195 个测试中存活;手动应用后,在全部 96 个相关测试中也都通过)。统计值得阅读的存活变异体:
jq '[.files[].mutants[] | select(.status=="Survived" and ((.static|not) or .testsCompleted > 5))] | length' reports/mutation/mutation.json
阈值 5 是根据那一个代码库得出的经验值,并非 Stryker 定义的数值。判断可疑静态存活变异体的另一种方法:手动应用变异并运行相关测试——如果测试套件失败,说明该存活是误报。
Python(mutmut)
安装
uv add --dev mutmut # Using uv
pip install mutmut # Using pip
运行 mutmut
uv run mutmut run # Run mutation testing
uv run mutmut run --paths-to-mutate=src/calculator.py # Specific files
uv run mutmut results # Show results
uv run mutmut summary # Summary
uv run mutmut show 1 # Show specific mutant
uv run mutmut apply 1 # Apply mutant manually
uv run mutmut html # HTML report
理解结果
Status: 45/50 mutants killed (90%)
- Killed: 45 (tests caught the mutation)
- Survived: 5 (tests passed despite mutation)
手写测试框架报告的结果比 Stryker 和 mutmut 少
以上内容都假设使用了框架。很多实际的变异测试采用手写循环——应用变异、运行一条断言、捕获失败——通常是因为被测对象是生成器或构建器中的构建时检查,而不是单元测试套件。
这样的循环值得编写。但它会漏掉框架免费提供的一项关键记录:**Stryker 和 mutmut 会告诉你每个变异体是被哪个测试杀死的。**手写测试框架通常只报告有某个东西失败了,而“有东西失败”与“我正在测试的检查失败”无法区分。以下四种情况会导致问题——前三种在一次会话中观察到,第四种在另一次会话中观察到:
在信任手写测试框架的表格前,请阅读 references/hand-rolled-harness-failures.md:§1 前一个检查掩盖了被测检查,§2 选择的变异只有目标检查能发现,§3 修改表格后导入时派生的状态仍然过期,§4 修改过的文件从未被导入(产生误报 MISSED,文中附有各运行时的搜索路径表)。
对绿色表格的影响
会对每个变异都打印 CAUGHT 的测试框架,常被当作测试套件可靠的证明。但它只能证明更有限的事情:
全部显示 CAUGHT 的表格只能证明每个变异都被某条断言捕获。 它无法证明捕获变异的是你想要的那条断言,也无法证明任何单条断言 确实有能力触发失败。
两个成本很低的补充措施可以弥补大部分缺口:
- 刻意加入一个本应漏掉的无操作变异。 如果表格里的每个变异都显示 CAUGHT,它和已损坏的测试框架无法区分;预期出现一次 MISS,就能区分二者。这个对称性同样成立,而这种控制并不能覆盖它:全部显示 MISSED 的表格同样无法和已损坏的测试框架区分;按预期报告 MISSED 的无操作变异,与因未加载真实变异而报告 MISSED 的情况并无二致。先检查 META/accept-all 行——如果一个硬编码的
raise或 accept-all 未能让测试套件变红,这并非断言较弱,而是证明测试框架没有运行它所编辑的文件(§4)。 - 断言错误消息,而不只是异常。 将变异与错误消息中的预期子字符串匹配,这样被掩盖的结果会被视为测试框架失败,而不是悄然通过。
变异分数目标
| 分数 | 质量 | 行动 |
|---|---|---|
| 90%+ | 优秀 | 保持质量 |
| 80-89% | 良好 | 小幅改进 |
| 70-79% | 可接受 | 聚焦薄弱环节 |
| 60-69% | 需要改进 | 补充缺失的测试 |
| < 60% | 较差 | 需要大幅改进测试 |
Agent 式优化
| 场景 | 命令 |
|---|---|
| 快速 TS 变异测试 | npx stryker run --incremental --reporters clear-text |
| 定向 TS 变异测试 | npx stryker run --mutate "src/core/**/*.ts" |
| 快速 Python 变异测试 | uv run mutmut run --paths-to-mutate=src/core/ |
| 查看存活变异 | uv run mutmut results | grep Survived |
| CI 模式 | npx stryker run --reporters json |
详细示例、高级模式和最佳实践,请参阅 REFERENCE.md。
另请参阅
- references/triaging-large-survivor-sets.md - 排查数千个存活变异:按未断言的行为聚类,以对抗性方式验证声称的源代码缺陷,并要求测试编写者重新运行 Stryker 作为门槛
vitest-testing- 单元测试框架python-testing- Python pytest 测试test-quality-analysis- 检测测试异味api-testing- HTTP API 测试agent-patterns-plugin:tool-result-traps- 对任何会影响操作的负面结果进行控制测试(§4 中的硬编码raise正是这种控制)