CI 全绿,coverage 报 92%,你把 PR merge 进去,结果生产环境第一次真实调用就崩。打开 AI 写的测试一看:mock 了数据库、mock 了支付 API、断言只写了 expect(result).toBeDefined()。这是 Claude Code / Cursor / Codex / Aider 自动写测试时最常见的失败模式——它们倾向于”让测试通过”而不是”让真实场景通过”。这篇拆 5 个常见原因和把测试集从摆设变成真防线的修复路径。
常见原因
按命中率从高到低排序。
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 报错全测不出来。
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(Stryker / 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 数据太理想。
最短修复路径
按收益排序。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 / msw / nock 起真实模拟
- 第三方 SaaS(OpenAI、Stripe、SendGrid):mock 可以,但要校验请求 payload 结构
- 时间、随机数、文件系统:mock OK
// 改用 msw 拦截真实 HTTP,schema 不匹配立刻挂
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,看测试还过不过。过了 = 测试没意义。mutation score < 60% 的模块全部回炉。
预防建议
- Prompt 里硬性要求:“至少 2 条错误分支 + 不 mock 数据库/HTTP/支付 + 断言验证具体值”
- 在
CLAUDE.md/.cursorrules写死禁止toBeDefined单独使用 - CI 加 mutation testing(Stryker / mutmut),mutation score < 70% 直接 block
- 关键路径(checkout、auth、payments)必须有 1+ e2e 测试,用真实 staging 数据
- 每次生产事故后回写一条回归测试,PR 模板里勾”是否补了回归测试”
- 月度 review mock 列表,看是否有核心依赖被 mock 掩盖