如果一个测试的期望值是通过运行被测代码得出的,那它只能确认代码当前的行为;如果 bug 早已存在,这个测试反而会把 bug 固定下来。
如果你用助手生成过测试,这个场景你可能不陌生:PR 新增了四十个测试,覆盖率上升了两个百分点,CI 一片绿色,而同一个计费 bug 照旧在周四进入生产环境。这些测试从一开始就没有能力对代码提出异议。
本文讨论生成式测试在”通过却什么也没保护”时的三种典型形态,以及修正它们的三项检查:从需求而非实现出发编写提示词、故意破坏代码、以及先读 mock 再读断言。
关键要点
- 行覆盖率只记录哪些行被执行过,完全不记录当这些行产生错误结果时是否会有断言失败。
- 手工推算得出的期望值和从函数输出复制来的期望值,在 diff 中看起来一模一样;两者只有来源不同,这正是生成式测试能通过评审的原因。
- 让模型为一个函数名编写测试,它只能以实现作为依据;而提供业务规则,则给了它一个能够与代码相抗的事实来源。
- 检验生成测试套件最快的办法,是在源码中改一个常量或翻转一个比较运算符,然后重跑测试;如果全部依然通过,说明没有任何测试在保护那一行。
- 如果一个测试用 mock 替换了它声称要测试的函数,那么它的断言检查的只是 mock 配置的返回值,这种测试应当删除而非修补。
为什么覆盖率上升,bug 却照旧上线?
覆盖率衡量的是执行,而非验证。Jest 的覆盖率提供器(默认 Babel/Istanbul,可选 V8)和 Vitest 的覆盖率提供器(默认 V8,可选 Istanbul)报告的是同样四项指标:语句、分支、函数和行。两者都不会评估是否存在可能失败的断言。一个调用函数并对返回值原样断言的测试,覆盖了它触及的每一行——与一个断言正确的测试毫无区别。
这就是为什么数字不断攀升,bug 却持续上线。生成测试的成本很低,因此有更多代码在测试中被执行。但从实现推导出来的测试会继承实现的全部缺陷,而覆盖率报告里没有这样一栏。
失效形态一:测试断言的正是代码已经返回的值
模型如果只拿到源码,那它判断期望值的唯一依据就是源码。它会推理(或直接运行)函数,观察输出,然后把这个输出当作字面量写进断言。如果函数是错的,那字面量也会以同样的方式错。
来看一个带有复制粘贴 bug 的折扣函数:
// discount.ts
export function calculateDiscount(price: number, tier: "silver" | "gold"): number {
const rate = tier === "gold" ? 0.25 : 0.25; // bug: silver should be 0.15
return price * (1 - rate);
}
下面的 describe/it/expect API 在 Jest 和 Vitest 中都可用;Jest 将其暴露为全局变量,而 Vitest 需要显式导入或设置 globals: true。
import { calculateDiscount } from "./discount";
// Generated from the implementation. 75 is what the buggy function returned.
it("applies silver discount", () => {
expect(calculateDiscount(100, "silver")).toBe(75);
});
// Written from the rule: silver is 15% off, so 100 * 0.85.
it("charges 85 for a 100 silver order", () => {
expect(calculateDiscount(100, "silver")).toBe(85);
});
第一个测试在有 bug 的函数上会通过。第二个会失败,而这正是重点。在 diff 里,75 和 85 看起来同样合理。语法层面没有任何线索能告诉评审者:一个是根据定价规则算出来的,另一个是从函数输出复制来的。唯一的防线,就是追问这个数字从哪来。
失效形态二:生成的测试只覆盖 happy path
生成的测试套件只测试提示词描述过的内容,而一个仅点出函数名的提示词,描述的只有它的正常运行路径。生成套件最常遗漏的那些用例(格式错误的输入、不可达的依赖、超时),恰恰是提示词从未提及的,所以模型没有理由去写。
结果就是:五个几乎相同的测试,全都针对格式规范、层级有效的输入;而负价格、未知层级字符串、或永不响应的上游服务,一个都没有。这些正是未经测试就进入生产环境的路径——因为它们同样也是开发者手工验证最少的路径。
失效形态三:被测单元本身被 mock 掉了
如果一个测试用 mock 替换了它声称要测试的函数,那么它的断言验证的是 mock 配置的返回值,而不是函数的行为。模型倾向于激进地使用 mock,因为 mock 能让测试稳定通过;而一个把数据库、网络客户端和被测服务统统 mock 掉的测试,在任何实现下都会通过。
用依赖注入的方式更容易看清这个模式,这样示例也不必纠缠 jest.mock 与 vi.mock 的差异:
// cart.ts
import type { calculateDiscount } from "./discount";
export function cartTotal(price: number, tier: "silver" | "gold",
discount: typeof calculateDiscount): number {
return Math.round(discount(price, tier) * 100) / 100;
}
// cart.test.ts
import { cartTotal } from "./cart";
// Before: the fake is the thing being checked.
it("returns discounted total", () => {
const fake = () => 85;
expect(cartTotal(100, "silver", fake)).toBe(85);
});
// After: assert on what cartTotal did, using a fake that exposes it.
it("rounds the discounted price to cents", () => {
const fake = () => 85.004999;
expect(cartTotal(100, "silver", fake)).toBe(85);
});
“改造前”那个测试,即便 cartTotal 完全忽略参数直接返回 85,也照样通过。“改造后”的测试给 fake 传入了一个只有在舍入逻辑真正执行时才会算对的值,因此它观察的是函数本身,而不是 stub。
修正一:给 AI 单元测试生成提供需求,而非代码
让模型为一个函数名编写测试,它只能以实现作为依据。把业务规则以书面需求的形式提供给它,则给了它代码无法提供的东西:一份能够与代码相抗的规格说明。
弱提示词:
Write unit tests for calculateDiscount.
更强的提示词:
Write unit tests for calculateDiscount in discount.ts.
Rules:
- Silver tier is 15% off the price.
- Gold tier is 25% off the price.
- Price must be non-negative; a negative price throws.
- The result is rounded to two decimal places.
Compute every expected value from these rules, not from the
current implementation. Include at least one invalid-input case
per rule. Name each test as the rule it checks.
第二个提示词会产出 expect(calculateDiscount(100, "silver")).toBe(85),因为规则说的就是 85,而它在首次运行时就会让有 bug 的函数失败。像规则一样表述的测试名(“charges 85 for a 100 silver order”)还能让整个套件作为规格说明被评审。这个提示词并不能保证模型会忽略源码;它是给了模型一个优先级高于源码的事实来源。
修正二:破坏代码,看它是否变红
检验生成测试套件最快的办法,是在被测代码中改一个常量或翻转一个比较运算符,然后重跑测试。如果全部依然通过,说明这个套件没有在保护那段代码。
1. Fix the bug in discount.ts (silver rate to 0.15), then change 0.15 to 0.05.
2. Run your test command.
3. Expected: "charges 85" fails with received 95.
If nothing fails, no test asserts the silver rate.
变异测试(mutation testing)可以将这个过程自动化。StrykerJS(@stryker-mutator/core)会向源码注入微小改动,为每一个改动重跑整个套件,并报告变异分数(mutation score)——即测试捕获到的变异体(有测试失败,或运行超时)数量除以有效变异体总数。它支持的变异器会翻转比较运算符、取反布尔值、替换算术运算符、将字符串字面量置空、以及移除代码块体。Jest 和 Vitest 都有对应的 runner 插件;在采用之前,请先确认插件与你所安装版本的兼容性。
修正三:先读 mock,再读断言
按这个顺序评审生成的测试 diff:什么被伪造了、期望值从哪来、最后才是断言了什么。什么被伪造了,决定了这个测试能观察到什么。期望值从哪来,决定了断言是否有可能与代码相抗。而断言本身,反而是信息量最小的一行。
- mock 顶替了被测单元本身:删掉这个测试。
- 期望值是一次函数调用,或是一个背后没有任何规则支撑的字面量:根据需求重新推算。
- mock 只用于真正的边界(数据库、网络、时钟),断言针对被测单元自身的输出:保留。
判断力仍需由人保留
AI 在测试的机械性环节上是可靠的:fixture、setup 与 teardown、一批近乎相同用例的参数化表格、异步错误路径的样板代码。它不可靠的地方在于判断哪些行为值得断言,因为这个判断存在于需求之中,而不在代码里。让 AI 生成脚手架,然后在 diff 中挑出最关键的那一个测试,破坏它本应守护的那一行,确认它确实变红,再合并。
常见问题
变异测试能取代代码覆盖率吗?
不能。两者报告的是不同类型的缺口。覆盖率告诉你哪些行在所有测试中都未被执行;变异测试告诉你哪些已执行的行没有任何断言在保护。StrykerJS 会同时报告两者:未被执行的代码中的变异体会被标记为 No coverage 状态,而已被执行的代码中、所有测试仍然通过的变异体则被标记为 Survived。变异分数把这两类都算作未检出,因此低覆盖率会直接拉低分数。
StrykerJS 能配合 Vitest 和 Jest 使用吗?
可以,通过 runner 插件。对 Vitest,安装 @stryker-mutator/vitest-runner 并在 Stryker 配置中将 testRunner 设为 vitest;该插件本身不附带 Vitest,因此请查阅其 package.json 以确认它支持的最低 Vitest 版本。通过 Vitest 的 Browser Mode 运行的测试不在该 runner 的处理范围内。对 Jest,使用 @stryker-mutator/jest-runner。在 Vitest runner 下,Stryker 会关闭 Vitest 自身的覆盖率收集,并让每个变异体的运行在第一个失败的测试处停止,因为一次失败就足以杀死该变异体。
如何揪出完全不含断言的 AI 生成测试?
让测试框架对零断言的测试直接判失败。在 Vitest 中,在配置里设置 expect.requireAssertions,或在 CLI 上传入 --expect.requireAssertions;未调用过 expect 就结束的测试会失败。在 Jest 中,在测试体内调用 expect.hasAssertions(),或放在 beforeEach 钩子里以全局生效。这两种设置都不会检查断言是否有可能失败,因此一个断言 mock 自身返回值的测试仍然会通过。
AI 生成的快照测试可信吗?
未经评审则不可信。根据当前输出录制的快照,只能断言代码会持续产出录制当时产出的东西——bug 也一并包含在内,这与复制字面量属于同一种同义反复。而重新录制只差一个 flag:jest -u 和 vitest -u 会重写所有失败的快照。在 CI 中,Jest 的 --ci flag 会在遇到新快照时判失败,而不是静默写入。评审快照 diff 时,应对照需求,而非对照此前的输出。