跳到主要内容

别只看全绿:检查 AI 生成的测试,看它能否真正发现代码错误

你刚运行完 AI 生成的测试套件,控制台一片翠绿:“All tests passed!”

兴奋了吗?还是隐隐觉得不踏实?

这恰恰是大多数开发者(尤其是使用 AI 辅助写测试的开发者)最常陷入的陷阱:把“测试通过”误认为“需求被保护”。

接下来我们将分三件事推进:第一,系统梳理现有对 AI 生成的测试用例,定位尚未覆盖的场景与边界;第二,用几个“有意义的错误”实际运行测试,以此校验检错能力是否到位;第三,给出明确、可执行的补充测试清单,让 AI 或后续开发者能够精准补测。


为什么“全绿”常常是骗人的?​

测试覆盖率告诉你代码执行过,没告诉你断言是否有效。

想象这段代码(Stryker 官方用的小例子):

function isUserOldEnough(user) {
return user.age >= 18;
}

需求很简单:年龄 ≥ 18 返回 true,否则 false。

现在,AI 帮你写了几条测试:

describe("isUserOldEnough", () => {
it("returns true for an adult (age 19)", () => {
expect(isUserOldEnough({ age: 19 })).toBe(true);
});

it("returns false for a minor (age 17)", () => {
expect(isUserOldEnough({ age: 17 })).toBe(false);
});
});

看起来不错:覆盖了两个典型值,断言也合理。但这就是全部吗?

假设你把它给 AI:“帮我补点测试,提高覆盖率。”AI 可能再给你一条:

it("returns true for an older adult (age 30)", () => {
expect(isUserOldEnough({ age: 30 })).toBe(true);
});

你突然意识到:我们从未验证过边界值 18。如果实现里有个 > 而不是 >=,那所有测试依然全绿——19、17、30 的行为都没变。

这就是“存活变异”:测试没抓到实现里的真正逻辑。

覆盖率是“代码动没动”,断言才是“结果对不对”。AI 写的测试容易在第一个维度上好看,第二个维度上偷懒。


变异测试:让测试自己“挑错”的思路​

这里不立刻教你配 Stryker,但理解它背后的思想很有用:

  1. 先确认正确实现的基线通过。
  2. 只对一个有意义的变化进行测试(比如把 >= 改成 >)。
  3. 运行测试:
    • 失败 → 测试抓到了这个错误(变异被检出)。
    • 全过 → 这个变异“存活”,说明你的断言没覆盖到区分它的关键输入。
  4. 结束后恢复正确实现,再核对整体。

核心是:测试失败的原因要具体看:

  • 目标断言失败 → 好,测试在抓行为变化。
  • 导入失败、语法错误、环境故障 → 先解决运行条件,别让噪音干扰判断。

用在你自己的项目里,其实不需要真正跑变异工具,只要带着同样的怀疑意识去审阅测试即可。


实战:审阅一组 AI 生成的测试,找出具体遗漏​

下面给你一个常见场景:一个订单校验函数 validateOrder。

需求(或接口约定)​

  • 必须有:userId、items(数组)、totalAmount(数字)。
  • items 必须非空,且每个 item 的 price > 0。
  • 每项的 quantity >= 0。
  • totalAmount 必须是所有 item 的 price * quantity 之和;金额精度按项目约定处理(如固定两位小数或统一舍入策略)。
  • 输入符合上述规则时返回 { valid: true };任一条件不满足则返回 Error 对象,而不是抛出异常,也不是返回错误字符串。

AI 给出的当前测试(示意)​

describe("validateOrder", () => {
it("returns success for a valid order", () => {
expect(validateOrder({
userId: "u1",
items: [{ id: 1, price: 10, quantity: 2 }],
totalAmount: 20
})).toEqual({ valid: true });
});

it("returns error if userId is missing", () => {
expect(validateOrder({
items: [{ id: 1, price: 10, quantity: 2 }],
totalAmount: 20
})).toBeInstanceOf(Error);
});

it("returns error if items array is empty", () => {
expect(validateOrder({
userId: "u1",
items: [],
totalAmount: 0
})).toBeInstanceOf(Error);
});

it("returns error if an item has a zero price", () => {
expect(validateOrder({
userId: "u1",
items: [{ id: 1, price: 0, quantity: 2 }],
totalAmount: 0
})).toBeInstanceOf(Error);
});

it("returns success for many items", () => {
expect(validateOrder({
userId: "u1",
items: [
{ id: 1, price: 5, quantity: 1 },
{ id: 2, price: 3, quantity: 2 }
],
totalAmount: 11
})).toEqual({ valid: true });
});
});

全绿了。现在用下面这套检查清单,逐条“挑刺”。

1. 断言是否真的调用目标函数?​

看上面:每条 expect(validateOrder(...)) 都调用了。✅

如果 AI 偷懒,可能写成:

it("validates orders", () => {
const result = validateOrder({ userId: "u1" });
expect(result).toBeDefined(); // 只检查存在性,没检查结构
});

这种测试很容易存活变异(比如把 validateOrder 改成只返回 { valid: true })。

2. 期望值是否独立于实现细节?​

测试的精髓在于:你正在验证“事物应该如何工作”,而不是“事物当前如何被构造”。期望值(Expectation)应当来源于需求或公开契约,而非直接复制被测代码的内部逻辑。如果两者完全一致,测试就失去了发现隐藏 bug 的能力——它变成了一场自我证明的仪式。

举个例子:假设我们的订单处理函数有一个公开契约:“有效订单返回 { valid: true }”。那么你的测试期望写 toEqual({ valid: true }) 是合理的。这个断言会递归比较对象的所有属性,只有当键和值完全吻合(在 toEqual 的规则下)时才算通过。

这里有两个关键点需要特别注意:

  1. 这不是“包含”关系:toEqual 不是简单的部分匹配。如果你写的是 toEqual({ valid: true, message: "ok" }),而实际返回结果只有 { valid: true },测试就会失败。反之,如果实现代码返回了一个多余字段(比如 { valid: true, debug: 99 }),只要该字段不为 undefined,断言同样会失败。
  2. undefined 字段的特殊处理:这是 Jest 最容易被误用的地方之一。如果对象包含值为 undefined 的额外键,toEqual 会自动忽略这些键,不会导致断言失败。这很好,避免了你因为无意的垃圾字段而频繁修测试。如果你确实需要严格区分“有无该键”或“值是否为显式 undefined”,可以查看 Jest 文档中的 toStrictEqual。

那么,期望值真的必须完全剥离实现细节吗? 答案是:取决于你的契约。如果你的 API 文档明确声明响应体中包含 message 和 userId 等字段,那这些就已经成为了“公开契约”的一部分,纳入测试期望是合理的。但如果这些字段只是内部调试用的、随时可能变动的实现细节(Implementation Detail),那么把它们硬编码进测试里就是危险的——一旦后端重构,测试就全挂了。

最后记住一个自检标准:如果你把被测函数本身的返回结果直接当作期望值,或者用同一个函数去计算期望,那测试就失去了独立检错的意义。 字段数量多本身不构成“同义反复”,关键在于这些字段是否真正源自业务需求,还是仅仅因为“代码里就有”就被带进了测试。

3. 边界条件是否遗漏?​

现有测试覆盖范围如下:

  • 已覆盖:userId 缺失、items 为空数组、price = 0、正确单项总价、正确多项总价。
  • 尚未覆盖:
    • price < 0(注意:已有 price = 0 的测试并不能覆盖“所有非正数”);
    • quantity 约定为 >= 0,但未测试 quantity = 0(应视为有效输入);
    • quantity < 0(应为无效输入);
    • 导致总价为负、零或异常值的错误总价场景;
    • 金额精度相关的舍入规则与比较方式(需在确定项目规则后再选择合适的断言)。

4. Mock 是否把待检查行为替换掉了?​

更一般地说:当系统存在外部服务时,用 mock 隔离环境是常见且必要的做法;但前提是 validateOrder 自身的校验逻辑仍在执行,断言依据的仍是输入与业务规则。

如果直接用 mock 替换整个 validateOrder 再检查其返回值,你就只能证明 mock 的行为,无法验证原函数的逻辑是否正确。另一方面,对 mock 调用本身加断言(如 toHaveBeenCalledWith)用于确认交互与参数;而对最终结果加断言用于确认校验结论——两者用途不同,不应混为一谈。

5. 输入是否覆盖有意义的组合?​

单看上面测试,大部分是“单个维度”:缺 userId、空 items、价格错误。

但真实系统往往有组合错误:

  • userId 存在,但 items 非空且总价正确,唯独某个 item 的 quantity 为负数。
  • totalAmount 给了一个与计算结果完全无关的值(比如随便填个 999)。

AI 写的测试很容易停留在“各打各的”,组合场景靠人补。


用一个小例子:核对 AI 是否真的能抓住错误实现​

我们回到 isUserOldEnough,构造一个“有意义的错误实现”:

function isUserOldEnoughWrong(user) {
// 错误实现:严格大于 18,而不是 >= 18
return user.age > 18;
}

如果测试只有:

expect(isUserOldEnoughWrong({ age: 19 })).toBe(true);
expect(isUserOldEnoughWrong({ age: 17 })).toBe(false);

全绿。错误实现混过来了。

现在加一条针对边界值 18 的测试:

it("returns true for exactly 18", () => {
expect(isUserOldEnoughWrong({ age: 18 })).toBe(true);
});

再次运行,这次就红了——这就是变异测试想要演示的效果:一条精心选择的断言,把“看起来差不多”的实现区别开。

把这个思路用在你项目的 AI 测试上,就是:

  • 先让 AI 生成测试,全绿。
  • 然后你手动构造一个“合理但不完全正确的实现”(比如把 >= 改成 >、把严格相等改成模糊匹配等)。
  • 观察哪些测试失败,哪些仍全绿。
  • 针对仍全绿的,补充一条目标明确的断言,再次验证。

如果最后你发现:要逼出变异失败,需要 AI 测试里已经有针对该错误的用例,那恭喜:AI 这次真给到位了。 如果发现:你得靠手动加一两条边界测试才能抓住错误,那说明补测需求非常清晰了——接下来就交给 AI 去批量生成类似场景。


给 AI 的明确补测要求(模板 + 示例)​

与其说“提高覆盖率”这种模糊指令,不如直接把以下结构丢给 AI:

通用模板​

请根据以下信息补充测试用例:

- 目标函数:{{functionName}}
- 需求/接口约定:
- {{rule1}}
- {{rule2}}
- ...
- 当前已有测试(如有):
- {{test snippet 1}}
- ...
- 需要重点覆盖的遗漏点(可多选或自填):
- [ ] 边界值(最小/最大、0、负数)
- [ ] 组合输入(多个字段同时异常)
- [ ] 数值精度 / 小数处理
- [ ] 空/null/undefined 处理
- [ ] 与实现逻辑相近但错误的变异场景(如 > vs >=,== vs ===)

请输出:
1) 每个新测试的输入数据
2) 对应的期望结果及原因(哪条规则触发了该结果)
3) 若适合,给出针对该测试的断言写法示例

实战示例(接 validateOrder)​

你直接把下面发给 AI:

请根据以下信息补充测试用例:

- 目标函数:validateOrder
- 需求/接口约定:
- 必填 userId;
- 非空 items 数组;
- 数字 totalAmount;
- price > 0 且 quantity >= 0;
- totalAmount 为各 price × quantity 之和,金额精度按项目约定;
- 有效订单返回 { valid: true };
- 违规订单返回 Error 对象。

- 当前已有测试:
- valid order: userId + single item + correct total
- missing userId
- empty items
- item with price = 0
- multiple items with correct totals

- 需要重点覆盖的遗漏点:
- [x] 数值边界(price、quantity 为 0/1/极大)
- [x] totalAmount 与实际计算不符
- [x] 组合异常(userId 存在但 items 中有 price<=0)
- [x] 小数精度敏感场景

请输出:
1) 每个新测试的输入数据
2) 对应的期望结果及原因(哪条规则触发了该结果)
3) 若适合,给出针对该测试的断言写法示例

AI 通常会回你几组像下面的数据:

用例 A:
- input: { userId: "u1", items: [{ id: 1, price: 0.01, quantity: 1 }], totalAmount: 0.01 }
- expect: valid (price > 0,总量正确)
- 原因:边界小数价格 + 正确总量

用例 B:
- input: { userId: "u1", items: [{ id: 1, price: 5, quantity: 0 }], totalAmount: 0 }
- expect: valid (quantity=0 是允许的)
- 原因:区分“数量为零”和“价格为零”的语义

用例 C:
- input: { userId: "u1", items: [{ id: 1, price: 5, quantity: 2 }, { id: 2, price: 3, quantity: 1 }], totalAmount: 17 } // 实际应为 13
- expect: Error
- 原因:总量与计算结果不一致

用例 D:
- input: { userId: "u1", items: [{ id: 1, price: -1, quantity: 2 }], totalAmount: -2 }
- expect: Error
- 原因:price <= 0 违反规则,即使总量“自洽”

然后你再把生成的测试加到项目里运行。如果仍全绿,就对着需求逐条过一遍:

“这条规则是 {price > 0},但测试里虽然有 price=0.01 和 price=-1 这类边界,仍需要核对是否覆盖到 tiny 正数(如 0.001)或接近金额上限的大数场景;请基于已有用例清单,指出具体缺失的项目金额或数值范围,让 AI 补测。”


几个经验判断(避免陷入形式主义的“全绿”)​

  • 等价变异不必恐慌:Stryker 官方也强调,有些变异在可观察行为上完全等价,存活不代表测试无效。你不需要把每个存活项都当成“缺陷”。
  • 红色比绿色更珍贵:Mark Seemann 提醒过,只追求全绿容易变成“仪式性测试”。偶尔看到一条“针对任务的测试失败”,再用实现让它通过(red-green TDD 的精神),比一百条同义反复的绿条更有价值。
  • AI 是高级“用例生成器”:把“需求 + 边界点 + 可疑场景”喂给它,比只说“写得更好”更能得到真正有用的测试集。

小结(方便快速查阅)​

  1. 测试通过 ≠ 需求被保护。覆盖率只看执行路径,不看断言质量。
  2. 审阅 AI 生成测试的实用清单:
    • 是否调用目标函数?
    • 期望值是否独立于实现细节?
    • 边界、组合、精度、空值是否覆盖?
    • Mock 是否替换了待测逻辑?
  3. 通过一个小例子(年龄判断)理解变异测试的核心:用精心选择的输入区分“看起来差不多”的实现。
  4. 给 AI 补测时使用结构化模板,明确指出需求、已有测试和遗漏点,而不是只说“提高覆盖率”。
  5. 把“红色测试失败”当成调试需求的好工具,而不是要消灭的污点。

现在去翻一次你项目里最近 AI 生成的测试集,用上面的清单过一遍,你会发现:真正的工作量不在于写多少条测试,而在于挑出哪几条真的能拦住错误的实现。

延伸阅读​