AI写的测试全绿,如何验证需求真被满足
文章给出两项CI检查:将稳定的验收条件编号关联到实际执行通过的测试,再用变异测试检查测试能否发现错误。示例基于Node、Vitest和Stryker的真实运行,并提供可复制代码。
AI 深度解读
Agent 写完功能,再给自己补一套测试,CI 全绿就能交差了吗?作者用一个 CSV 导出案例说明:代码和测试可能一起偏离需求。测试通过,只能证明它们彼此认可,不能证明功能符合规格。
作者给出的补救办法是两道 CI 检查:先确认每条验收标准都有实际运行且通过的测试,再用变异测试检查这些测试能不能识别错误。案例跑通了,但它仍然替代不了对需求和关键断言的人工审查。
为什么测试全绿,需求却没实现
作者的实验环境是 Node 24、Vitest 5.0.5 和 Stryker 10.1.1,功能只有一个:把交易记录导出为 CSV。规格里写了三条带稳定编号的验收标准:
- AC-1:不加筛选导出时,包含用户全部交易,按时间从新到旧排列。
- AC-2:描述包含逗号或双引号时,按照 RFC 4180 给字段加引号并转义。
- AC-3:筛选结果为空时,文件只包含表头。
作者故意写弱了实现和测试。实现只处理逗号,漏掉双引号;AC-1 的测试只数行数,不检查顺序;AC-3 则用了 it.skip。
下面这条测试最能说明问题:标题声称验证逗号和引号转义,实际只检查结果里有没有 Coffee。
it('AC-2: escapes commas and quotes', () => {
const csv = buildCsv([row({ description: 'Coffee, "large"' })])
expect(csv).toContain('Coffee')
})
要看的是最后一行:即使完全不做转义,toContain('Coffee') 仍然成立。
Vitest 给出的结果是 2 passed | 1 skipped (3)。如果再用 grep 搜索 AC 编号,三条标准也都能找到。测试标题写了什么、断言验证了什么、测试到底有没有执行,是三件不同的事。
测试名称可以声明覆盖需求,只有断言才能检验行为。
第一道检查:需求编号必须对应真实测试结果
作者没有继续扫描测试源码,而是读取测试运行器生成的 JUnit XML。Vitest 配置使用 reporters: ['default', 'junit'],并通过 outputFile: { junit: 'reports/junit.xml' } 保存报告。
检查脚本 scripts/check-spec.mjs 默认读取 SPEC.md 和 reports/junit.xml,也允许通过命令行参数替换路径。它用 /\bAC-\d+\b/g 提取需求编号,再从 <testcase> 的名称和结果中建立对应关系。
规则很具体:规格中的编号没有测试,报 no test;关联测试出现失败,报 failing;只有跳过的测试,报 only skipped tests。反过来,测试里有编号、规格里却没有,也会报错。
因此,执行 node scripts/check-spec.mjs 后,AC-3 被识别为只有跳过的测试。作者又把规格里的 AC-3 改成 AC-4,测试保持原样,脚本同时报告 AC-4 缺测试、AC-3 已不在规格中。这能发现需求改名后测试没有同步的问题。
如果使用 Spec Kit 的 FR-001 编号,只需要调整提取编号的正则。作者还实测过 Node 内置运行器的 node --test --test-reporter=junit,脚本无需修改。Jest、Playwright 虽然也能输出 JUnit,但作者没有验证,不能直接假定跳过状态的表达方式相同。
取消 .skip 后,结果变成 3 passed (3),脚本报告 3 criteria covered。可双引号依然没转义,排序也依然没人检查。
这道检查只回答「有没有对应且通过的测试」,不会理解断言是否充分。
第二道检查:故意改坏代码,测试能不能报警
变异测试(mutation testing)会对现有代码做小幅修改,比如翻转条件、删除方法调用或清空字符串,再运行测试。每个修改后的版本叫 mutant。如果测试仍然通过,这个 mutant 就存活了。
安装命令是 npm i -D @stryker-mutator/core @stryker-mutator/vitest-runner。作者配置 testRunner: "vitest"、mutate: ["src/csv.js"]、reporters: ["clear-text", "progress"] 和 coverageAnalysis: "perTest",然后执行 npx stryker run。
这个单文件案例运行了 3 秒,生成 21 个 mutants,其中 6 个存活,得分 71.43%。两个结果直接暴露了测试漏洞:
- 把
if (text.includes(','))改成if (false),彻底关闭转义,AC-2 仍然通过。 - 把
[...rows].sort((a, b) => b.date.localeCompare(a.date))改成[...rows],删除排序,AC-1 仍然通过。
测试标题里的编号此时很有用:报告可以把存活的变异直接关联到验收标准,方便审查者找到薄弱断言。
但 Stryker 没有直接指出双引号处理缺失。原因也很直接:它修改的是已经存在的代码,没有写出来的逻辑,未必能通过修改现有语句暴露。
作者随后让 AC-1 比较完整输出及日期顺序,把 AC-2 拆成逗号、双引号两条测试。后者要求输出包含 ,"The ""large"" one",,终于触发 1 failed | 3 passed (4),规格检查也报告 AC-2: failing。
修复字段转义的核心代码只有一行:
if (/[",]/.test(text)) return `"${text.replaceAll('"', '""')}"`
要看的是 replaceAll('"', '""'):除了给字段外层加引号,还要把内部双引号成对转义。修复后,两道检查通过,Stryker 杀死全部 23 个 mutants,得分 100%。
我的判断
我认可这套方法把「需求有测试」和「测试有约束力」分开检查。它比单纯要求 Agent 补测试数量、补覆盖率,更接近团队真正关心的问题:功能退化时,CI 能否拦住。
但原文开头的「证明」需要收窄理解。两道检查都通过,仍然不能证明实现完整符合规格。 一个断言很强、却标错 AC 编号的测试,可能通过全部检查。规格如果漏掉 100,000 行导出的要求,工具也不会自动补上。
变异分数同样不能直接充当质量结论。这个案例只有一个小函数;真实项目可能存在不改变行为的等价变异,无法靠合理测试杀死。作者没有提供大型代码库、长期运行成本或其他方案的对照数据,100% 是案例结果,80 的门槛则是作者为文章选择的起点。
实现上还有边界:检查脚本用正则提取 XML,作者验证了两种运行器的输出,原文没有证明它是通用 JUnit 解析器。迁移前应拿团队实际报告验证。
执行顺序也有讲究:先 npx vitest run 生成报告,再 node scripts/check-spec.mjs,最后运行较慢的 npx stryker run。要让变异分数影响构建结果,需要配置 "thresholds": { "break": 80 }。作者实测的是得分 95.65%、门槛 100 时退出码为 1,并没有验证 80 适合一般团队。
Claude Code 的 Stop hook 只是补充思路。作者给出 npx vitest run; node scripts/check-spec.mjs || exit 2,因为 hook 只在退出码 2 时阻塞,而检查脚本失败返回 1;但他没有在演示中运行这个 hook。这里的分号也不会自动把 Vitest 失败转换成阻塞,落地时需要单独验证失败路径。
能拿来做什么
- 给前端或 Node 功能的验收标准加稳定编号,再让对应测试带上编号。先从 CSV 导出这类输入、输出明确的功能接入 JUnit 检查,确保跳过测试和需求编号漂移能让 CI 失败。
- 对关键规则检查具体结果,例如排序后的完整顺序、转义后的完整字段。代码覆盖率可以帮助定位没执行的代码,变异测试则补充检查「执行了却没有有效断言」的部分。
- 大项目先把
mutate限定到规格中「Files and interfaces」涉及的文件,再使用npx stryker run --incremental保存并复用结果,根据实际成本和存活原因调整门槛。 - 把存活变异交给 Agent 时,按作者建议开启新会话,明确禁止修改
src/和SPEC.md,只允许加强测试;如果新测试暴露实现错误,就停下来报告违反了哪条验收标准,再由人确认修复方向。