CI 全绿,coverage 报 92%,你把 PR merge 进去,结果生产环境第一次真实调用就崩。打开 AI 写的测试一看:mock 了数据库、mock 了支付 API、断言只写了 expect(result).toBeDefined()。这是 Claude Code、Cursor、Codex 自动写测试时最常见的失败模式,它们倾向于”让测试通过”而不是”让真实场景通过”。
**最快的修复:**手写一条端到端测试,把生产里真实失败的 input 拿来重放,打到真实(或 staging)的数据库和 API。如果这一条挂了而整套测试还是绿的,就证明 unit 测试根本没跑过这条路径,而你现在有了一道回归门槛。然后审 mock 列表、强化弱断言。下面的 Step 1 + Step 2 通常能在一小时内暴露 50% 的”假绿”测试。
这不是凭感觉。截至 2026 年 6 月,同行评审的基准测试显示 LLM 生成的 unit 测试在真实函数上的 mutation score 平均只有约 40%,业界报告在复杂函数上甚至低到约 20%,也就是说大部分注入的 bug 都从测试里漏了过去。行覆盖率高但 mutation score 低,正是这个问题可量化的特征。
常见原因
按命中率从高到低排序。
1. 测试只覆盖 happy path
AI 写测试默认只测”正常输入返回正常输出”,不写空数组、超长字符串、网络超时、并发竞态、过期 token 这些真实分支。
// AI 生成的典型测试
it("returns user data", async () => {
const user = await getUser("123");
expect(user).toBeDefined();
});
// 缺:getUser("") / getUser(null) / 网络挂 / 404 / 5xx
**如何判断:**grep 测试文件里的 throw / reject / error 关键字数量。如果一个模块 10 个测试里 0 个错误分支,就是这种情况。
2. 关键依赖被 mock,真集成从没跑过
AI 倾向于把数据库、HTTP client、文件系统全 mock 掉来”让测试快”。结果是 schema 不匹配、API 字段改名、SQL 报错全测不出来。这是危害最大的一类,因为 mock 和断言是一起写的,所以这条测试其实只是在检查”mock 是不是返回了你让它返回的东西”。
jest.mock("./db", () => ({
getUser: jest.fn().mockResolvedValue({ id: "123", name: "Test" })
}));
// 真实 db.getUser 返回的是 { user_id, full_name },schema 早改了,测试照样绿
**如何判断:**把测试文件里的 jest.mock / vi.mock / unittest.mock.patch 数一遍,超过 3 个就要 review 是否核心依赖被屏蔽。
3. 断言太弱,只验证”有返回”
toBeDefined() / toBeTruthy() / toHaveBeenCalled() 单独使用几乎没有信息量。函数返回 {} 也能过。
// 弱
expect(result).toBeDefined();
expect(saveUser).toHaveBeenCalled();
// 强
expect(result).toEqual({ id: "123", email: "a@b.com", status: "active" });
expect(saveUser).toHaveBeenCalledWith({ id: "123", email: "a@b.com" });
expect(saveUser).toHaveBeenCalledTimes(1);
**如何判断:**搜测试文件里裸用 toBeDefined / toBeTruthy / toHaveBeenCalled() 不带参的次数。超过 5 处就该补结构断言。
4. coverage 高但走的是死分支
100% line coverage 可以靠”调一次函数把所有 if 都走一遍”达到,但每条分支只走一种输入。Mutation testing(JS/TS 用 Stryker,Python 用 mutmut)会直接揭示这点。
**如何判断:**跑 npx stryker run,mutation score 低于 60% 基本说明测试只测形状不测值。或人工:把被测函数的 return a + b 改成 return a - b,测试还过吗?
5. 测试用例的数据是 AI 编的,不来自真实样本
AI 编的测试数据往往规整漂亮("test@test.com"、"John Doe"、123),真实数据有 emoji、超长 unicode、null 字段、前导空格。生产挂的就是这些边角。
**如何判断:**导一份脱敏的生产样本,跑同一组测试,是不是大量失败?是 = AI 数据太理想。
我属于哪一类
按顺序跑下面的检查,命中第一条就停。
| 你看到的症状 | 最可能的原因 | 去哪一步 |
|---|---|---|
| 生产报错是字段缺失/改名,或 SQL/HTTP 报错 | 核心依赖被 mock(原因 2) | Step 1 + Step 2 |
测试文件里大量 toBeDefined / toHaveBeenCalled() | 断言太弱(原因 3) | Step 3 |
| coverage 高但完全没有错误分支测试 | 只测 happy path(原因 1) | Step 4 |
coverage 高但 stryker 分数低 | 走死分支(原因 4) | Step 5 |
| 只在真实客户数据上挂 | 测试数据是编的(原因 5) | Step 1 |
最短修复路径
按收益排序。Step 1 + Step 2 通常能在一小时内把 50% 的”假绿”暴露出来。
Step 1:手写一条端到端测试,复现生产挂的那条路径
不要再让 AI 写。从生产日志里抓出真实失败的 input,手动写一条用真实 db、真实 API(或 staging 镜像)的 e2e 测试。这一条挂的话,证明 unit 测试根本没覆盖。
// tests/e2e/checkout.e2e.test.ts
it("processes a real Stripe checkout end to end", async () => {
const order = await placeOrder({
userId: "real-staging-user-123",
items: [{ sku: "SKU-001", qty: 2 }],
paymentToken: process.env.STRIPE_TEST_TOKEN,
});
expect(order.status).toBe("paid");
expect(order.stripeChargeId).toMatch(/^ch_/);
});
跑 npm test -- checkout.e2e 单跑这条。挂了就保留,作为新的回归门槛。
Step 2:审 mock 列表,把核心依赖踢出去
列出仓库里所有 mock:
grep -rn "jest.mock\|vi.mock" tests/ src/ | grep -v node_modules
逐条对照”这是核心依赖吗”。原则:
- 数据库、ORM、HTTP client、消息队列、支付:不要 mock。数据库用 Testcontainers 起真实实例,HTTP 用
msw/nock。容器镜像要 pin 到和生产一致的大版本(比如postgres:16,不要用postgres:latest),并且一个测试套件复用一个容器,而不是每条测试都新起一个,否则启动时间会吃掉大半运行时间。 - 第三方 SaaS(OpenAI、Stripe、SendGrid):mock 可以,但要校验请求 payload 的结构,而不只是看返回值。
- 时间、随机数、文件系统:mock OK。
// 改用 msw 拦截真实 HTTP,schema 不匹配立刻挂。
// msw v2 写法(截至 2026 年 6 月的当前大版本):
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
const server = setupServer(
http.get("/api/users/:id", () => HttpResponse.json({ id: "123", email: "a@b.com" }))
);
Step 3:强化断言到”值等于具体期望”
把所有 toBeDefined 改成 toEqual / toMatchObject,所有 toHaveBeenCalled() 改成 toHaveBeenCalledWith(...) 带具体参数。
// before
expect(emailSpy).toHaveBeenCalled();
// after
expect(emailSpy).toHaveBeenCalledWith({
to: "user@example.com",
template: "welcome",
vars: { name: "Alice" }
});
expect(emailSpy).toHaveBeenCalledTimes(1);
Step 4:给 AI 一个新 prompt 模板生成测试
把这段贴进 Cursor / Claude Code 当系统指令:
为 <function> 写测试,必须包含:
1. 一个真实 happy path(用具体的真实数据,不要 "test"/"foo")
2. 至少 2 条错误分支:空输入、网络失败、上游 4xx/5xx
3. 至少 1 条边界:unicode、空字符串、超长、并发竞态
4. 所有断言都验证具体值,禁止裸用 toBeDefined / toBeTruthy
5. 不要 mock 数据库、HTTP client、支付 API;用 msw / Testcontainers
6. 生成后跑一遍 mutation test(npx stryker run),mutation score 必须 >= 70%
Step 5:用 mutation testing 把”假覆盖率”暴露出来
定期跑:
npx stryker run --mutate "src/**/*.ts"
Stryker 会把代码里的 + 换成 -、> 换成 >=、true 换成 false,看测试还过不过。过了 = 测试没意义。Stryker 的默认阈值(截至 2026 年 6 月)是 high: 80、low: 60、break: null。break 不设的话这次跑永远不会让 CI 失败,所以要在 stryker.conf.json 里显式设上来卡 merge:
{
"thresholds": { "high": 80, "low": 60, "break": 70 }
}
低于你设的 break 阈值的模块,全部回炉到 Step 4 的 prompt 模板。
如何确认修好了
- Step 1 手写的 e2e 测试现在打 staging 能过,并且留在 CI 里当回归门槛。
grep -rn "jest.mock\|vi.mock" src/ tests/的结果里没有数据库、ORM、支付 client。- 在改动的文件里搜裸断言(
toBeDefined、toBeTruthy、不带参的toHaveBeenCalled())几乎为零。 npx stryker run报出的 mutation score 达到或超过你设的break阈值(70% 是个合理的起步线),低于就让这次跑非零退出。- 人工抽查:把被测函数里的一个运算符翻一下(
+改成-),至少要有一条测试挂。一条都不挂,说明测试还是摆设。
预防建议
- Prompt 里硬性要求:“至少 2 条错误分支 + 不 mock 数据库/HTTP/支付 + 断言验证具体值”。
- 在
CLAUDE.md/.cursorrules写死禁止toBeDefined单独使用。 - CI 加 mutation testing(Stryker / mutmut),显式设
break阈值,低于就 block。 - 关键路径(checkout、auth、payments)必须有 1 条以上 e2e 测试,用真实 staging 数据。
- 每次生产事故后回写一条回归测试,PR 模板里勾”是否补了回归测试”。
- 月度 review mock 列表,看是否有核心依赖被 mock 掩盖。
常见问题
为什么 92% 覆盖率还是放了个挂的功能上线?
行覆盖率只记录”这行跑过了”,不记录”断言会不会抓到错的值”。一条把每行都跑了但只断言 toBeDefined() 的测试,覆盖率很高却几乎什么都抓不到。能反映测试是否会发现 bug 的指标是 mutation score,不是行覆盖率。
那能让 AI 写测试吗? 能,写 happy path 和样板代码没问题。修法不是”禁止 AI 写测试”,而是”约束它”:给它 Step 4 的 prompt 模板、禁止 mock 核心依赖、要求具体值断言、再用 mutation testing 卡结果。AI 擅长出量,弱在挑那些”会出事”的输入。
mutation score 该要求多少?
break 阈值从 70% 起步,套件成熟后再往上提。Stryker 默认把低于 60% 标为 warning、低于 80% 标为还没到绿。活跃开发的新代码可以定更高,遗留模块可能要临时放低底线。数字本身不是关键,关键是有一个能让 CI 真的失败的底线。
测试里真要起一个真实数据库吗? 集成层和 e2e 层,要。Testcontainers 在 Docker 里起一个真实数据库,SQL 怪癖、迁移、连接池、schema 漂移都会在测试里冒出来,而不是到生产才暴露。纯逻辑保留用 mock 的快速 unit 测试,但你要上线的那条路径上的 DB 绝不要 mock。
我的 AI 测试 mock 了函数、然后断言 mock 自己的返回值,这有用吗? 没用。这是最常见的假绿模式:测试只是在确认”mock 返回了你配置它返回的东西”,对真实行为一无所知。要么去掉 mock 打真实依赖,要么至少断言代码发出去的请求 payload,而不是那个写死的返回值。