变异测试
本页提供 Stryker 完整配置文件、变异类型对照表(含代码示例)、变异分数解读标准、如何识别无断言意义的测试,以及只对改动文件运行变异测试的命令配置。
变异测试回答的是「若实现悄悄变坏,测试会不会红」,与覆盖率互补:高覆盖仍大量存活时,往往说明断言弱、只测了 happy path,或变异落在等价语义上。
SKILL 应写明所用工具(如 JavaScript/TypeScript 的 Stryker、Java 的 PIT)、默认变异算子集、超时与并行度、报告路径,以及如何把「等价变异」记入忽略规则或注释,避免团队对噪音免疫。
目标与边界
变异测试不是替代单元测试,而是衡量测试套件对「小错误」的敏感度;不是要求 100% 杀死率(等价变异与过宽算子会抬升成本)。
- 适合:核心业务模块、近期高变更目录、回归频繁却缺乏行为断言的代码。
- 谨慎:生成代码、薄封装 DTO、纯数据类(若团队未配置针对性算子)。
- 与覆盖率:覆盖率高 + 存活多 → 优先补「可观察行为」断言,而非再堆无断言分支覆盖。
记住:指标应驱动改测试与设计,而不是为 dashboard 好看而收紧到不可维护的范围。
推荐流程
[ 选定范围 ]
包 / 目录 / diff(增量) + 排除生成物与第三方
│
▼
[ 配置工具 ]
算子子集 · 超时 · 并行 · 报告格式 · 等价忽略策略
│
▼
┌──────────────┐ PR:增量 / 抽样 nightly:全量或更广
│ 执行变异运行 │──── 失败=构建失败 或 仅上传报告(团队约定)
└──────────────┘
│
▼
┌──────────────┐ 分类:测试缺口 / 等价 / 过宽变异 / 真缺陷
│ 存活变异 triage │
└──────────────┘
│
▼
[ 产出 ] 新用例或断言 · issue 链到需求 · 更新忽略规则与说明
先让「范围与配置」稳定,再谈杀死率;否则每次报告不可比,团队会放弃看存活列表。
工具与命令要点
在 SKILL 正文中写出可复制的命令模板(含配置文件路径),并注明 Node/JVM 版本与 CI 镜像需与本地一致,避免「仅本地能跑」。
Stryker 完整配置文件(stryker.conf.json):
{
"$schema": "https://stryker-mutator.io/schemas/stryker-core.json",
"testRunner": "jest",
"jest": {
"projectType": "custom",
"configFile": "jest.config.ts"
},
"mutate": [
"src/**/*.ts",
"!src/**/*.test.ts",
"!src/**/*.spec.ts",
"!src/**/*.d.ts",
"!src/generated/**",
"!src/mocks/**"
],
"mutator": {
"plugins": ["@stryker-mutator/typescript-checker"],
"excludedMutations": [
"StringLiteral", // 字符串字面量变异(噪音多)
"ObjectLiteral" // 对象字面量变异(误报高)
]
},
"reporters": ["html", "json", "progress"],
"htmlReporter": {
"fileName": "reports/mutation/mutation.html"
},
"jsonReporter": {
"fileName": "reports/mutation/mutation.json"
},
"thresholds": {
"high": 80, // 变异分数 ≥ 80% → 绿色
"low": 60, // 变异分数 60-80% → 黄色警告
"break": 50 // 变异分数 < 50% → CI 失败
},
"timeoutMS": 10000, // 单个变异体超时 10 秒
"concurrency": 4, // 并行测试进程数
"coverageAnalysis": "perTest", // 只运行覆盖到变异点的测试(性能优化)
"tempDirName": ".stryker-tmp",
"cleanTempDir": true
}
变异类型对照表(带代码示例):
类型 1:边界条件变异(ConditionalExpression)
原始: if (amount >= 100) return discount;
变异: if (amount > 100) return discount; // ← >= 改为 >
意义: 测试必须断言 amount=100 时的行为
类型 2:逻辑运算符变异(LogicalOperator)
原始: if (isVip && !isBlocked) return premium;
变异: if (isVip || !isBlocked) return premium; // ← && 改为 ||
意义: 测试必须覆盖 isVip=true 但 isBlocked=true 的场景
类型 3:返回值变异(ReturnValue)
原始: function getDiscount() { return 0.2; }
变异: function getDiscount() { return 0; } // ← 返回值改为零值
意义: 测试必须断言返回值,不能只断言「被调用」
类型 4:语句删除变异(BlockStatement)
原始: metrics.increment('order.created'); return orderId;
变异: return orderId; // ← metrics.increment 被删除
意义: 测试必须验证 metrics 调用,否则监控代码可随意删除
变异分数解读标准:
变异分数 = 被杀死的变异体 / (总变异体 - 等价变异体) × 100% > 80% → 好:测试套件对代码改动高度敏感,适合核心业务模块 60-80% → 中:大多数错误会被捕获,还有改进空间 < 60% → 差:很多错误可能悄悄溜过测试 具体场景对应: 85%(好)= 支付模块:所有边界条件和错误路径都有断言 70%(中)= 用户模块:基本逻辑覆盖了,但错误分支和边界条件断言弱 45%(差)= 报表模块:测试只断言「函数被调用」,没有断言输出数据是否正确
如何识别「通过了但没有断言意义」的测试:
// 信号:变异体 survived(测试通过但变异存活)
// 说明什么:测试存在,但即使代码被改坏,测试也不会失败
// 示例:ReturnValue 变异存活
// 原始函数:
function calculateTax(amount: number) { return amount * 0.1; }
// 变异版本(Stryker 生成):
function calculateTax(amount: number) { return 0; } // ← 返回 0
// 测试代码(变异存活的原因):
it('should calculate tax', () => {
const spy = jest.spyOn(taxService, 'calculateTax');
checkout.process(100);
expect(spy).toHaveBeenCalledWith(100); // ← 只断言参数,不断言返回值!
// 变异版返回 0 时,此测试仍然通过 → 变异存活
});
// 修复:加上对返回值的断言
it('should calculate 10% tax', () => {
expect(calculateTax(100)).toBe(10); // ← 断言实际返回值
expect(calculateTax(0)).toBe(0); // ← 边界条件
});
// 只对改动文件运行变异测试(PR 场景):
// package.json:
"mutation:diff": "stryker run --mutate \"$(git diff --name-only origin/main...HEAD | grep -E '\\.(ts|js)$' | grep -v '\\.test\\.' | tr '\\n' ',' | sed 's/,$//').js\"",
// 或使用 Stryker 的 --since 选项(v6+):
// npx stryker run --since origin/main
| 生态 | 工具 | Agent 需记录 |
|---|---|---|
| JS / TS | Stryker | stryker.conf、测试运行器、mutate glob、阈值 |
| Java / Kotlin (JVM) | PIT 等 | 目标类、排除、增量、与构建插件集成方式 |
| 其他 | 语言专属 | 是否支持增量、HTML 报告路径、与 Bazel/Gradle 的交互 |
- 为长时间 job 设置明确超时与重试策略,避免占满 CI 队列。
- 报告保留策略:artifact 保留天数、是否仅主干存档。
范围、性能与 CI
默认对「变更相关」代码跑增量变异;全量放在 nightly 或发布前。对 dist、generated、锁文件与快照目录使用排除规则。
- PR 门禁:可只要求「无新增高危存活」或「分数不低于基线」,而非每次全量达标。
- 等价变异:在配置或源码旁注释理由,并定期审计忽略列表,防止永久屏蔽真实风险区。
- 并行度与分片:大仓库按模块拆 job,便于定位慢在测试还是变异引擎。
存活变异怎么读
每个存活项应能归入下面之一,并在评审中留下可追溯说明。
- 测试薄弱:可补断言、拆分用例、测错误分支与边界。
- 等价变异:语义不变 → 配置忽略 + 注释「为何等价」。
- 算子过宽:对当前代码风格无意义 → 缩小 mutate 范围或关闭子集。
- 产品/缺陷讨论:变异揭示的行为与需求不一致 → 提 issue,而不是硬改测试去「绿」。
禁止为了数字把断言改成「测实现细节」或过度 mock;优先对齐对外可观察行为与契约。
给 Agent 的输出规范
分析变异报告时,输出应便于人类合并决策,而不是 dump 整份 HTML。
- 摘要:运行范围、工具版本、总变异数、杀死数、变异分数(若报告提供)。
- Top 存活:按模块/风险排序,每条含文件、行、变异类型、建议动作(补测 / 等价 / 忽略 / 需求确认)。
- 与需求的链接:需求 ID 或用户故事,便于评审对照。
- 后续命令:本地复现的一条命令与配置文件路径。
算子自检与变异分数估算
下方勾选常见变异算子类别,确认团队是否在文档或配置中明确覆盖;右侧用「已杀死 / 总变异体(可扣除等价)」估算变异分数并观察条形示意。
算子覆盖自检
已勾选 0 / 6 类算子(团队文档或配置中已声明覆盖)。
变异分数估算
公式:杀死数 ÷ (总变异体 − 等价变异) × 100%。等价变异填 0 即退化为「杀死 / 总数」。
有效分母 55,变异分数约 76.4%(存活 13)
---
name: mutation-testing-cn
description: Stryker 配置、变异类型识别、分数解读与 CI 增量运行
---
# 步骤 1:安装并初始化 Stryker
npm install --save-dev @stryker-mutator/core @stryker-mutator/jest-runner
npx stryker init # 生成 stryker.conf.json
# 步骤 2:配置关键字段(stryker.conf.json)
mutate: ["src/**/*.ts", "!src/**/*.test.ts", "!src/generated/**"]
thresholds: { high: 80, low: 60, break: 50 }
coverageAnalysis: "perTest" # 只跑覆盖到变异点的测试(3-5x 加速)
concurrency: 4
timeoutMS: 10000
# 步骤 3:只对改动文件运行(PR 场景)
npx stryker run --since origin/main
# 步骤 4:识别无效断言(mutant survived 的常见原因)
信号: ReturnValue 变异存活 → 测试只断言「被调用」,没断言返回值
信号: ConditionalExpression 存活 → 缺少边界值测试(amount=100 边界)
修复: 把 expect(spy).toHaveBeenCalled() 改为 expect(result).toBe(10)
# 步骤 5:处理等价变异
// 在源码旁注释(Stryker 识别后跳过):
// Stryker disable next-line: ArrowFunction
const noop = () => {}; // 这个函数本身是 noop,变异无意义
# 步骤 6:分数解读标准
> 80% → 好(适合支付/权限等核心模块)
60-80% → 中(补充边界和错误路径测试)
< 60% → 差(测试只测了 happy path,断言弱)
# 步骤 7:CI 分层策略
PR: npx stryker run --since origin/main(只跑改动文件)
nightly: npx stryker run(全量,报告存档 30 天)
门禁: break: 50(低于 50% 则 CI 失败)