AI 生成单元测试的质量治理:覆盖率虚高、断言失焦与变异测试验证
先说结论:LLM 生成的单元测试,覆盖率能轻松到 90%+,但大约 40%~60% 的断言是"废话断言"------它们能通过,却抓不住 bug。我们在一个 180+ 模块的 Vitest 项目中接入 LLM 自动生成测试用例,跑完变异测试后发现,变异杀死率只有 34%。你的测试看起来一片绿,代码随便改也还是一片绿。
覆盖率虚高:行覆盖率是最大的谎言
"被执行"不等于"被验证"
覆盖率工具(v8 或 istanbul)统计的是"这行代码有没有被执行到",而不是"这行代码的结果有没有被正确断言"。LLM 特别擅长生成那种"跑一遍函数但不检查关键输出"的测试。
举个典型例子。一个简单的订单折扣计算函数,三个分支对应三种会员等级。LLM 会怎么测?它会老老实实把三个分支都调用一遍,覆盖率拉满。但仔细看断言部分:
ts
// 被测函数:计算订单折扣
function calcDiscount(price: number, level: 'vip' | 'svip' | 'normal'): number {
if (level === 'svip') return price * 0.7
if (level === 'vip') return price * 0.85
return price
}
// LLM 生成的典型"虚胖"测试
test('calcDiscount should work', () => {
const result1 = calcDiscount(100, 'svip')
const result2 = calcDiscount(100, 'vip')
const result3 = calcDiscount(100, 'normal')
// 三个分支全走到了,覆盖率 100%
// 但断言全是废话------只要不是 undefined 就过
expect(result1).toBeDefined()
expect(result2).toBeDefined()
expect(result3).toBeTruthy()
})
行覆盖率 100%,分支覆盖率 100%,但你把 0.7 改成 0.3 测试照样绿。这种测试的价值约等于零。
量化虚高程度:引入"有效断言率"
为了把"废话断言"这个直觉量化成可度量的指标,我们搞了一个 Effective Assertion Ratio(EAR) 。计算方式很粗暴:扫描所有测试文件,把 expect 调用中的 matcher 分成强弱两类。
弱断言包括 toBeDefined、toBeTruthy、toBeFalsy、toBeNull、toBeInstanceOf、toHaveBeenCalled(只断言调没调用,不管参数)、toMatchSnapshot 这些。强断言则是 toBe、toEqual、toStrictEqual、toHaveBeenCalledWith、toThrow、toContain、toHaveProperty、toBeGreaterThan 等需要指定具体期望值的 matcher。
EAR 就是强断言数除以总断言数,阈值设在 0.6------低于这条线的测试文件不合格。第一次跑完,LLM 生成的测试 EAR 平均值是 0.31。接近三分之二的断言是废话。
Prompt 改造:给 LLM 加硬约束
问题出在 Prompt 太笼统。早期的 Prompt 就一句"为以下 TypeScript 函数生成 Vitest 单元测试,覆盖所有分支"。LLM 理解成"覆盖"就够了,没人告诉它要"验证"。
改造后的 Prompt 加了三条明确的禁令:每个 test case 必须包含至少一个 toBe / toEqual / toStrictEqual 断言;禁止用 toBeDefined / toBeTruthy / toBeFalsy 作为唯一断言;每个分支必须断言具体的返回值,不是类型。
这三条约束加上去之后,EAR 从 0.31 提到了 0.58。有改善但还不够------LLM 学会了"投机取巧",它会写一个 toBe 但值是硬编码猜的,跟实际逻辑对不上。Prompt 工程的天花板就在这里,光靠文字约束没法解决语义层面的偷懒。
变异测试:最后一道质量关卡
变异测试在干什么
思路很直接:如果你的测试质量够高,那源码被"故意改坏"之后,测试应该会挂。变异测试就是自动对源码做微小修改(叫变异体,比如把 > 改成 >=、把 + 改成 -),然后对每个变异体跑一遍测试。测试挂了,说明这个变异体被"杀死"了,测试有效;测试还过,说明变异体"存活"了,测试有漏洞。
第一次跑的结果
全量跑完花了 23 分钟,结果让人清醒:
yaml
Mutation score: 34.2%
Killed: 487 Survived: 937 Timeout: 43 No coverage: 89
Top surviving mutations:
ConditionalExpression 218/312 survived
ArithmeticOperator 156/198 survived
EqualityOperator 141/167 survived
StringLiteral 78/89 survived
条件表达式的存活率最高------312 个条件变异体里有 218 个活了下来。也就是说,源码里的 if 判断被改了,七成情况下测试毫无反应。覆盖率 92% 和变异杀死率 34% 之间的鸿沟,就是 LLM 测试质量的真实写照。
用变异结果反向修复测试
Stryker 的 HTML 报告里,每个存活的变异体都标注了具体的文件、行号、原始代码和变异后的代码。这些信息本身就是绝佳的 Prompt 素材。我们写了一个脚本,把存活变异体按文件分组提取出来,再构造针对性的 Prompt 丢给 LLM 做二次生成。Prompt 的关键不是"请生成高质量测试"这种抽象要求,而是非常具体的失败信号:
ts
function buildMutantFixPrompt(source: string, mutants: SurvivingMutant[]): string {
const mutantDescriptions = mutants.map(m =>
`第${m.line}行: "${m.original}" 被改成 "${m.mutated}" (${m.mutationType}) 后测试仍然通过`
).join('\n')
return `
以下函数的变异测试中,这些变异体存活了(测试没有检测到代码被篡改):
${mutantDescriptions}
源代码:
${source}
请为每个存活的变异体生成一个精确的测试用例,确保该测试能区分原始代码和变异后的代码。
要求:用 toBe 或 toEqual 断言具体值,选择能让原始代码和变异代码产生不同结果的输入。
`.trim()
}
比起"请覆盖所有分支","第 12 行的 > 被改成 >= 后你的测试没挂,请修复"这种指令,LLM 的输出质量完全不是一个量级。这一轮修复后,变异杀死率从 34% 提升到了 67%。
三层治理管线的设计
踩完这些坑,我们把整个流程重新设计成三层过滤,核心原则是快检在前、慢检在后,像漏斗一样逐级收紧。Layer 1:静态检查(毫秒级)------语法合法性、EAR 有效断言率不低于 0.6、禁止纯快照测试。这一层能过滤掉最明显的废话测试。
Layer 2:执行验证(秒级)------Vitest 执行通过、覆盖率达标、无 flaky test。重点是分支覆盖率而不是行覆盖率。
Layer 3:变异验证(分钟级)------Stryker 变异杀死率不低于 60%,关键模块(涉及金额计算、权限校验的)不低于 75%。
90% 的垃圾测试在前两层就被拦住了,昂贵的变异测试只需要处理剩下的 10%。
Layer 1 的实现:ESLint 自定义规则
我们写了一条 ESLint 规则来拦截"只有弱断言"的 test case。注意,这条规则不是禁止 toBeDefined------在一个 test case 里同时有 toBe 和 toBeDefined 完全没问题,它拦截的是一个 test case 里只有弱断言、没有任何强断言的情况。规则的逻辑是:进入 test() 或 it() 块时重置状态,遍历过程中标记是否出现了强/弱 matcher,离开块时检查------如果只有弱断言没有强断言,直接报错。
Layer 2 的配置要点
覆盖率阈值的设置有个反直觉的地方:我们故意把行覆盖率阈值(70%)设得比分支覆盖率(80%)低。因为行覆盖率太容易达标,给人虚假的安全感。一个函数只要被调用一次,里面大部分行就算"覆盖"了,但 if-else 的另一条路径可能从来没走过。分支覆盖率才是硬指标。
Layer 3 的工程化:增量变异测试
Stryker 跑全量变异测试太慢了------180+ 模块全跑一次要 40 分钟,CI 里不现实。我们的解决方案是只对 PR 中变更的源码文件做变异测试。脚本先通过 git diff 拿到变更文件列表,过滤出 src/ 下的非测试 .ts 文件,动态生成 Stryker 配置。如果变更文件超过 15 个,就抽样前 15 个跑,避免 CI 超时。
增量模式下,单次 PR 的变异测试一般在 3~8 分钟内完成,完全可以接受。
不适用的场景与工程边界
变异测试对 UI 组件效果很差
Stryker 能变异 <template> 里的表达式吗?能,但大部分变异体会因为"等价变异"而存活。比如把 class="active" 改成 class="",测试里如果没有断言 class 属性,变异体就活了。但这不代表测试质量差,只是测试的关注点不在样式上。
我们的策略是按目录区分:src/utils/、src/services/、src/composables/ 跑变异测试,src/components/ 只跑覆盖率。纯逻辑代码用变异测试守住质量,UI 组件靠快照 + 视觉回归测试来兜底。
LLM 生成测试的"不可重复性"
同样的 Prompt 同样的源码,LLM 每次生成的测试用例都不一样。
解决办法是加一个 --frozen 模式:一旦某个模块的测试通过了三层验证,就用源码文件的 MD5 哈希锁定它。只要源码没变,测试文件就不再重新生成。我们在测试文件旁边放一个 .spec.lock 文件记录对应源码的哈希值,每次管线启动时先比对哈希,相同就跳过。
成本考量
LLM API 按 token 计费。以 180 个模块为例,全量生成一次测试大约消耗 280 万输入 token 和 150 万输出 token,按 Claude 定价计算,单次成本在 30~50 美元之间。若叠加变异修复的二次生成,整体成本基本会翻倍。因此,全量生成只在项目初始化或季度测试质量审计时触发;日常 PR 场景则采用增量生成,将单次成本控制在 1~3 美元。