代码智能体很会堆覆盖率、把测试跑成全绿,但断言经常没有杀伤力:边界不碰、错误路径不碰、期望值被写成「有值就行」。单看「参考实现是否通过」会系统性高估这批测试。下面用 Node.js 内置测试跑器和一套受控变异脚本,用杀死率而不是通过率给测试打分,并接到 CI 门禁与失败样本回流;流程不绑定某个模型。
全绿测试在骗谁
覆盖率回答的是「测试有没有执行到这行代码」,通过率回答的是「在当前这一份实现上,断言有没有炸」。两者都不回答更关键的问题:假如实现悄悄错了,测试会不会失败。
智能体生成测试时,常见捷径正好踩在这个盲区上。它会对着一份已经写好的参考实现编程,优先保证 node --test 全绿,于是断言被写成 typeof、notEqual(undefined)、ok(result)。参考实现一跑一个准,换一个差一号的实现,测试仍然全绿。这不是模型「不会写测试」,而是优化目标错了:它在最大化通过率,而不是最大化对故障的敏感度。
所以评测生成测试,不能停在「参考实现通过」。要把实现和断言都放到可复现的扰动下,看测试还能不能揭穿问题。扰动要受控、可枚举、可进 CI,而不是靠一次人工 code review 的手感。
评测口径:杀死率,不是通过率
变异测试的做法很旧,用在生成测试上仍然合适。先固定测试套件,再对生产代码做小而明确的改动(突变体)。测试失败,称这个突变体被杀死;测试仍通过,称它存活。杀死率是被杀死的突变体数量除以有效突变体数量。等价突变体(改了字面、行为不变)应单独列出,不要硬塞进分母里充分子。
针对智能体测试,只做生产代码变异还不够,建议固定三类扰动:
- 注入故障:改生产代码的比较符、返回值、异常路径,模拟真实回归。
- 翻转条件:把
min > max改成>=或<,把<改成<=,专门打边界。 - 弱化断言:改测试代码,把严格相等改成「非空 / 类型正确」,看套件会不会变成空壳。
第一、二类衡量测试的检出力;第三类衡量断言本身有没有信息量。参考实现必须先全绿,否则后面的杀死率没有意义——那是测试写错了,不是突变体太强。
下面的例子刻意用 Node.js 内置模块:node:test、node:assert/strict、node:fs、node:child_process。不引入测试框架插件,是为了让流水线可复制,而不是绑死在某一版 Stryker 配置上。生产仓库若已有 Stryker,可以把同一套门槛换成 npx stryker run 的 mutation score,口径不变。
被测代码,以及一份「看起来很好」的测试
假设要测一个小函数:把数字限制在闭区间内,区间非法时抛错。把下面两个文件放在同一目录。
// clamp.js
export function clamp(n, min, max) {
if (min > max) {
throw new RangeError('min must be <= max');
}
if (n < min) return min;
if (n > max) return max;
return n;
}
// clamp.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clamp } from './clamp.js';
test('clamp returns a number', () => {
const result = clamp(5, 0, 10);
assert.equal(typeof result, 'number');
assert.ok(result !== undefined);
});
package.json 只需声明 ESM,测试命令走内置跑器:
{
"type": "module",
"scripts": {
"test": "node --test clamp.test.js",
"mutate": "node scripts/evaluate.mjs"
}
}
在参考实现上执行 npm test,结果会全绿,覆盖率也不难看:主路径执行到了。但它几乎没有预言能力——返回 0、10 甚至 NaN 以外的数字,typeof 仍然是 number。智能体交付物里,这类测试非常常见:它验证了「函数能调用」,没有验证「函数做对了」。
对照着写一份有杀伤力的测试,不必一次堆几十条,先覆盖值、边界和非法区间:
// clamp.strong.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { clamp } from './clamp.js';
test('keeps interior value', () => {
assert.equal(clamp(5, 0, 10), 5);
});
test('clamps to min and max', () => {
assert.equal(clamp(-1, 0, 10), 0);
assert.equal(clamp(11, 0, 10), 10);
assert.equal(clamp(0, 0, 10), 0);
assert.equal(clamp(10, 0, 10), 10);
});
test('rejects inverted range', () => {
assert.throws(() => clamp(1, 10, 0), RangeError);
});
后面流水线会对同一组突变体分别跑这两份测试。你要看的不是谁覆盖率更高,而是谁能把故障打死。
受控变异流水线
把评测写成脚本,输入是「源文件 + 测试文件」,输出是结构化报告。突变体用显式替换表,不在生产代码上原地改写:先拷到临时目录,再跑 node --test。
// scripts/evaluate.mjs
import {
mkdtempSync, cpSync, readFileSync, writeFileSync, rmSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const THRESHOLD = 0.8;
const productionMutants = [
{ id: 'range-gt-to-ge', find: 'if (min > max)', replace: 'if (min >= max)' },
{ id: 'range-gt-to-lt', find: 'if (min > max)', replace: 'if (min < max)' },
{ id: 'drop-throw', find: "throw new RangeError('min must be <= max');", replace: '' },
{ id: 'lt-to-le', find: 'if (n < min)', replace: 'if (n <= min)' },
{ id: 'lt-to-gt', find: 'if (n < min)', replace: 'if (n > min)' },
{ id: 'gt-to-ge', find: 'if (n > max)', replace: 'if (n >= max)' },
{ id: 'gt-to-lt', find: 'if (n > max)', replace: 'if (n < max)' },
{ id: 'return-min-as-max', find: 'if (n < min) return min;', replace: 'if (n < min) return max;' },
{ id: 'return-n-as-min', find: 'return n;', replace: 'return min;' },
];
const assertionWeakeners = [
{ id: 'equal-to-typeof-number', find: /assert\.equal\(([^,]+),\s*([^)]+)\)/g, replace: 'assert.equal(typeof $1, \'number\')' },
{ id: 'drop-throws', find: /assert\.throws\(([^;]+);/g, replace: '' },
];
function runTests(cwd, testFile) {
const r = spawnSync(process.execPath, ['--test', testFile], {
cwd,
encoding: 'utf8',
});
return { ok: r.status === 0, stderr: r.stderr, stdout: r.stdout };
}
function withSandbox(fn) {
const dir = mkdtempSync(join(tmpdir(), 'mut-'));
try {
cpSync(join(root, 'clamp.js'), join(dir, 'clamp.js'));
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function evaluate(testFile) {
const baseline = withSandbox((dir) => {
cpSync(join(root, testFile), join(dir, testFile));
return runTests(dir, testFile);
});
if (!baseline.ok) {
throw new Error(`${testFile} 在参考实现上未通过,先修测试再评变异`);
}
const killed = [];
const survived = [];
const invalid = [];
for (const m of productionMutants) {
const result = withSandbox((dir) => {
const src = readFileSync(join(dir, 'clamp.js'), 'utf8');
if (!src.includes(m.find)) return { skip: true };
writeFileSync(join(dir, 'clamp.js'), src.replace(m.find, m.replace));
cpSync(join(root, testFile), join(dir, testFile));
return runTests(dir, testFile);
});
if (result.skip) invalid.push(m.id);
else if (result.ok) survived.push(m.id);
else killed.push(m.id);
}
const weakStillGreen = [];
for (const w of assertionWeakeners) {
const result = withSandbox((dir) => {
cpSync(join(root, 'clamp.js'), join(dir, 'clamp.js'));
let testSrc = readFileSync(join(root, testFile), 'utf8');
const next = testSrc.replace(w.find, w.replace);
if (next === testSrc) return { skip: true };
writeFileSync(join(dir, testFile), next);
return runTests(dir, testFile);
});
if (!result.skip && result.ok) weakStillGreen.push(w.id);
}
const valid = killed.length + survived.length;
const score = valid === 0 ? 0 : killed.length / valid;
return { testFile, killed, survived, invalid, weakStillGreen, score, valid };
}
const reports = ['clamp.test.js', 'clamp.strong.test.js'].map(evaluate);
writeFileSync(join(root, 'mutation-report.json'), JSON.stringify(reports, null, 2));
for (const r of reports) {
console.log(
`${r.testFile} 杀死率 ${(r.score * 100).toFixed(1)}% ` +
`(${r.killed.length}/${r.valid}) 存活: ${r.survived.join(', ') || '无'} ` +
`弱断言仍绿: ${r.weakStillGreen.join(', ') || '无'}`,
);
}
if (reports.some((r) => r.testFile.includes('strong') && r.score < THRESHOLD)) {
process.exit(1);
}
对弱测试,上面这组故障里大部分会存活:唯一的调用是 clamp(5, 0, 10),区间合法、值在内部,改比较符、丢掉 throw、甚至把 return n 改成 return min(得到 0)都过不了 typeof === 'number' 这一关。这不是突变体「太弱」,是断言没有锁定行为。强测试会打死翻转比较、改返回值、丢掉非法区间异常这类故障;lt-to-le / gt-to-ge 在闭区间语义下可能是等价突变,脚本把它们留在 survived 里,报告里应人工 squint 一眼,不要为了刷分去加「n === min 时必须走另一条分支」这种和规格无关的断言。
断言弱化是另一面镜子。如果把 assert.equal(clamp(5, 0, 10), 5) 换成「结果是 number」,参考实现仍然全绿,说明这条断言的信息量可以被抽空。生成测试评测时,应同时看:生产突变体杀死率低,或断言被弱化后套件毫无反应——两种都算质量不合格。
替换表要写死、要短。不要在评测脚本里做随机 AST 变形,否则同一天两次 CI 不可比。真要上规模,再用 Stryker 的确定性 mutator 集合,并把 mutate 范围限制在被测模块,避免把测试文件自己变异进去。
接到 CI,把存活样本回流
门禁只认报告,不认「模型说测试已经很好」。把 mutation-report.json 当制品;弱测试允许低分(它是对照基线),强测试或「本次智能体提交的测试文件」必须过门槛。门槛按模块定,不要从本例的玩具分母直接抄到业务仓库。
# .github/workflows/mutation.yml
name: mutation
on: [push, pull_request]
jobs:
score:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: baseline
run: node --test clamp.strong.test.js
- name: mutants
run: node scripts/evaluate.mjs
- uses: actions/upload-artifact@v4
if: always()
with:
name: mutation-report
path: mutation-report.json
回流比门禁更有用。把 survived 写成给下一轮生成用的材料,而不是再喂一遍「请提高覆盖率」。例如:return-n-as-min 存活,就明确要求补「区间内部的值必须原样返回」;drop-throw 存活,就要求非法区间必须抛 RangeError。提示里附上突变后的源码片段和「测试仍通过」这一事实,比空泛的「加强边界用例」有效。
有两点克制着写进流程。第一,评测用的突变体集合要版本化,和测试生成器解耦;换模型、换提示,只要杀死率上升,才说明测试变好,而不是突变体变少了。第二,不要用「再生成直到全绿」去拟合这一组突变体,否则测试会过拟合替换表,杀死率失去外推意义。突变体是抽检,不是新的规格说明书。
若仓库已经用 Jest 或 Vitest,不要为了评测再维护第二套用例。保持测试文件不动,只换执行器:评测脚本的 runTests 改成 npx vitest run --dir <sandbox> 即可。Stryker 适合回归阶段做全量 mutator,本文这种显式替换表适合给生成测试打分——范围小、失败可读、回流时能把突变 ID 对上提示词。
总结
全绿和覆盖率检查的是测试有没有跑通参考实现,变异杀死率检查的是测试能不能揭穿偏离。智能体生成的断言经常在前一项上表现很好,在后一项上接近于零。用 Node.js 内置测试跑器加一份显式突变表,就能得到可复现的分数:生产代码注入故障和翻转条件,衡量检出力;弱化断言,衡量预言强度。分数进 CI,存活突变体进下一轮生成材料,整条流水线不依赖某次生成结果。先让参考实现通过,再看突变体是否被杀死;两步缺一,都会把测试质量测偏。
评论