用 AI 生成测试:实战
测试欠债是每个团队的痛,AI 生成测试能大幅缓解——但也能制造一堆无意义测试和虚假覆盖率。本文讲清 AI 生成测试的正确姿势:哪些测试适合交给 AI、生成→验证→接受的工作流、避免覆盖率陷阱、工具选型(Claude Code/Codium/自建),以及 AI 测试替代不了的部分。
本文要点
- 主题聚焦:Vibe Coding、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
每个团队都有测试欠债。原因高度一致:写测试比写代码慢、deadline 在头顶、重构一来测试先挂、补测试永远是"下一件事"。结果就是核心逻辑裸奔,线上出事才想起来补一个回归。
AI 生成测试看起来是这个问题的银弹——给它一个文件,十秒钟吐出 80% 覆盖率。但真用起来,你会撞上两类坑:一是它生成的测试什么都没断言,跑是过了,等于没测;二是它给你刷出一堆虚假覆盖率,行覆盖 100%,但一个 bug 都拦不住,还让 CI 变慢、重构变难。
AI 生成测试不是"一键还债",而是"把补测试这件体力活自动化"。体力活能自动,设计活不能。这篇文章讲清怎么把能自动的那部分做扎实,把不能自动的那部分留给人。
一、先分清:哪些测试 AI 能写好,哪些它写不好
让 AI 写测试之前,先判断这段被测代码属于哪一类。
AI 写得好的,基本都是"输入输出确定、依赖干净"的:
- 纯函数:格式化器、解析器、校验器、金额计算、日期处理。签名清楚、无副作用、断言点明确,AI 几乎不会翻车。
- 边界条件:
null/undefined/ 空数组 / 负数 / 超长字符串 / Unicode / 时区边界。这些是 AI 的强项——人懒得写的角落,它会一个个铺。 - 参数化(表驱动)测试:给定一组
(input, expected),生成it.each的 case 列表。 - 回归测试:你给它一个 bug 报告和复现步骤,它能把失败 case 固化成测试。这是性价比最高的用法之一。
- 快照测试:序列化输出、组件渲染树、CLI 的 stdout。
AI 写不好的,基本都是"需要理解全局或业务意图"的:
- 架构测试:模块边界、依赖方向、分层约束。这要它先理解整个系统,而它通常只看得到你贴的那个文件。
- 业务语义测试:"这个优惠叠加规则对不对""这个结算流程和产品要的一致吗"——AI 不知道"对"长什么样,它只会按代码现状去描述代码现状,等于自我证明。
- 探索性 / 性能测试:哪里可能慢、哪里会并发冲突、哪里有 race condition,这些要人去想,AI 只能照着显式分支机械覆盖。
- 测试设计本身:该测什么、不该测什么、哪些路径风险高——这是测试最有价值的部分,也是最不能外包的部分。
一句话:AI 擅长补"广度",不擅长补"判断"。 把广度交给它,把判断留给自己。
二、工作流:不是一次生成,是"生成→跑通→修→接受"的循环
用 AI 写测试最常见的错误用法:贴一段代码,让它"写测试",然后把输出粘到文件里。这样得到的测试大概率跑不起来,或者跑起来了却在测错东西。
正确的姿势是一个闭环,核心原则只有一条:生成的测试必须能跑通,跑不通就让它自己修到通为止。
- 分析被测代码:先把签名、类型、依赖摸清楚。重点是什么是外部依赖(db、HTTP、文件系统、时间、随机数),这些必须被 mock;什么是内部协作对象,这些不该 mock。
- 生成:让 AI 基于签名和依赖列表给出测试用例,包括正常路径、边界、异常。
- 执行验证:立刻跑
vitest run/jest,把结果喂回 AI。 - 失败修复:测试挂了分两种——mock 配错了(改测试),或者暴露了真 bug(改实现,而且你赚到了)。AI 自己根据报错修,不要你转述。
- 人工接受:最后一关是你看一眼——这个测试到底在测什么行为,是不是换个错误实现它还能过?
Claude Code / Cursor 这类 agent 工具天然适合这个循环,因为它能自己跑命令、读报错、迭代。一个能直接用的 prompt 长这样:
为 src/pricing/calculate.ts 里的 calculateDiscount 生成 Vitest 单元测试。
要求:
1. 先读懂函数签名和返回类型,识别所有外部依赖并正确 mock。
2. 覆盖:正常折扣、满减边界、负数/0/空输入、叠加规则上限。
3. 每个断言必须验证具体业务期望,禁止 toBeDefined / toBeTruthy 这种空断言。
4. 生成后执行 `pnpm vitest run src/pricing/calculate.test.ts`,
有失败就自己修,直到全部通过。把最终的覆盖率贴给我。
关键在最后一条:让它自验证。Agent 能读错误自我纠错,比你来回粘贴快一个数量级。如果你用的工具不能跑命令(Copilot 式补全),那它生成的测试你必须手动跑一遍,别偷懒——跑不起来的测试比没有测试更糟,它会腐烂、给假信心、还挡 CI。
判断一个生成测试有没有价值,有个很狠的办法:变异测试(mutation testing)。Stryker 这类工具会故意把你的实现改错(> 改 >=、+ 改 -),然后看测试能不能挂。如果代码被改错了测试还过,说明这个测试形同虚设。AI 生成的测试尤其值得过一遍 mutation,它能瞬间暴露"为了覆盖率而写"的废测试。
三、质量把控:别让覆盖率变成陷阱
覆盖率是最容易被 AI 玩坏的指标。下面这个测试,覆盖率工具会给它打满分:
// 看起来测了,其实什么都没测
describe("formatPrice", () => {
it("works", () => {
const result = formatPrice(99);
expect(result).toBeDefined();
expect(typeof result).toBe("string");
});
});
行覆盖 100%,但 formatPrice 哪天把 ¥99 改成 ¥0,这个测试照样过。它没断言任何业务期望,只是证明了"函数返回了个东西"。
好测试的标准只有一个:它能不能拦住一个合理的错误实现。 据此有几条硬规矩:
- 断言要落在业务期望上,不是落在"它返回了"。
expect(formatPrice(99)).toBe("¥99.00")是好断言;expect(result).toBeTruthy()是废断言。AI 很爱生成后者,审稿时一律删。 - 测行为,不测实现。 不要为了覆盖私有函数的某个分支去测内部方法,也不要把内部调用 mock 死去凑分支覆盖。实现一重构测试就全挂,这种测试不是资产是负债。
- 拒绝重复测试。 同一个分支三个 case 都在测同一件事,只留一个最有代表性的,其余删掉。AI 会为了"看起来努力"生成大量同质 case。
- 覆盖率是副产品,不是目标。 好的测试自然会带来合理覆盖;为了覆盖率去写测试,得到的是一堆凑行数的废物。把覆盖率当门禁可以(防止明显漏测),当 KPI 必反噬。
一个实用原则:审 AI 生成的测试,比审它生成的实现还重要。 因为错的实现会被测试拦住(如果你测试写对了),错的测试没人拦,它会一路绿灯混进主干。
四、Mock 与依赖:AI 最容易翻车的地方
AI 生成测试翻车,十次有八次翻在 mock 上。典型症状:
- mock 太深:把内部模块、内部函数全 mock 掉,被测代码只剩一层 if-else,等于在测 mock 不是测代码。
- mock 太浅:该 mock 的数据库没 mock,测试真的去连了开发库,时快时慢还污染数据。
- 忘了 reset:上个测试的 mock 状态漏到下个测试,出现"单独跑过、一起跑挂"的玄学失败。
- mock 错了层:该在 repository 层 mock 的,跑到 fetch 层 mock,结果换了个 HTTP 库测试全废。
正确的 mock 原则:只在边界处 mock——数据库客户端、HTTP 客户端、外部 API、时间、随机数、文件系统。 内部协作对象用真实实现。这样测的是"真实代码 + 假外部世界",而不是"假代码 + 假世界"。
import { describe, it, expect, vi, beforeEach } from "vitest";
// 正确:在边界(发邮件)mock,内部逻辑用真实代码
vi.mock("../email/client", () => ({
sendEmail: vi.fn().mockResolvedValue({ ok: true }),
}));
import { sendEmail } from "../email/client";
import { checkout } from "./checkout";
beforeEach(() => vi.clearAllMocks()); // 关键:每个 case 重置
describe("checkout", () => {
it("下单成功后发送一封确认邮件", async () => {
const order = await checkout({ userId: "u1", items: ["sku1"] });
expect(order.status).toBe("confirmed");
expect(sendEmail).toHaveBeenCalledOnce();
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: expect.any(String), subject: "订单确认" }),
);
});
});
两个细节别漏:一是 beforeEach(() => vi.clearAllMocks()),不写就会出现跨用例污染;二是别 mock 你没拥有的东西——第三方库的内部方法别 mock,要在自己包一层适配器再 mock 那层适配器,否则库一升级测试全挂。
还有两个隐形依赖:时间和随机。Date.now()、new Date()、Math.random()、crypto.randomUUID(),只要被测代码碰了,测试就会"今天过明天挂"。Vitest 用 vi.useFakeTimers() 锁时间,ID 生成用注入或 vi.spyOn 固定。AI 经常漏掉这些,要 explicitly 提醒。
五、工具选型:Claude Code、Cursor、Codium、自建
不同工具适合不同场景,别指望一个打天下。
- Claude Code / Cursor(agent 模式):适合"生成 + 自验证"闭环。它们能自己跑测试、读报错、迭代到通过。首选场景:从零给一个模块补测试。 给它文件路径 + 测试要求 + 让它自己跑通,你能腾出手干别的。关键是给清楚约束(用什么框架、mock 哪一层、禁止哪种断言),否则它会按默认习惯写。
- CodiumAI / Qodo Cover:专门做测试生成的工具,强项是场景穷举——它会主动问你"这条异常分支测了吗""这个 null 输入考虑了吗",对"我漏了什么"特别有用。适合给老代码补行为测试,但生成的 mock 有时很啰嗦,需要精简。
- Diffblue(Coverage Advisor,Java 为主):字节码层面分析,适合企业级 Java 仓库批量补测,TS 项目用不上,但思路值得借鉴——它强调"基于真实执行路径生成"。
- 自建 agent:团队有统一测试约定(固定 mock 工厂、固定 fixture、固定目录结构)时最划算。用 Claude API + tool use 自己跑测试,把项目规范、mock 模板、禁止项塞进 system prompt,生成的测试直接符合团队规范,几乎不用人工调整。前期投入大,规模化后回报最高。
选型其实就一句话:要闭环用 agent,要场景穷举用 Codium,要规模化统一规范就自建。 大多数团队前两个就够,自建是规模化之后的事。
六、AI 替代不了的那部分:测试设计
说了这么多 AI 能做的,得把边界划清楚——AI 能补充覆盖,替代不了"测试设计"本身。
测试真正有价值的部分,从来不是那几百行 it(...)。是这些:
- 该测什么、不该测什么:资源有限,测哪些路径收益最高?AI 不知道哪些是核心资产、哪些是一次性脚本。
- 风险在哪里:这个函数错了会让公司赔钱还是会让 UI 错位?优先级是业务判断,AI 看不出来。
- "对"长什么样:业务规则的对错,在产品文档和用户脑子里,不在代码里。AI 只能复述代码现状。
- 设计反馈:写一个函数时发现"这玩意儿根本没法测",往往说明设计有问题——依赖太重、职责太杂、副作用太深。这种"测试驱动设计"的洞察,是人在写测试时最大的副产品,AI 替你写了,这个信号就丢了。
所以最健康的用法是:AI 负责把已经设计好的测试铺满,人负责设计测试。 你决定测哪些行为、哪些边界重要、哪些风险不可接受;AI 负责把这些决定快速落地成可执行的测试代码,并且帮你发现"哦这条分支你忘了测"。它是个不知疲倦的执行者,不是测试架构师。
小结
- 先分类再动手:纯函数/边界/参数化/回归适合交给 AI,架构/业务语义/探索性/测试设计不合适。把广度给 AI,把判断留给自己。
- 必须闭环:生成的测试要能跑通,让 agent 自己执行、读报错、修到通过。跑不起来的测试比没有测试更糟。
- 严控质量:拒绝空断言、拒绝重复、拒绝为覆盖率而测。审 AI 的测试要像审它的代码一样严,因为错的测试没人拦。
- mock 只在边界:数据库/HTTP/时间/随机这些外部依赖 mock 掉,内部协作对象用真实实现。别忘了 reset 和 fake timer。
- 覆盖率是结果不是目标:它好测试的自然副产品。需要门禁,但绝不能当 KPI。
AI 能让测试欠债从"永远还不上"变成"还得起",但它还的是体力债,不是脑力债。决定测什么的那个决定,永远得你来下。
Related