写单元测试是程序员最不爱干的活之一。本文记录如何用 Cursor + Claude Code 自动生成测试骨架、边界用例和 Mock,再配合 AI 分析覆盖率缺口,将项目测试覆盖率从 12% 提升到 91%,编写时间减少 80%。

痛点:每次重构都像走钢丝
我的项目是一个运营后台,核心业务逻辑都集中在几个 Service 层,纯函数和带副作用的方法混在一起。单元测试覆盖率只有 12%,而且这 12% 还是之前为了应付检查写的简单断言,基本属于摆设。
每次有人改动了订单状态机、优惠券计算或权限校验,我都提心吊胆------不知道会不会把另一个模块的边界条件搞崩。手动补测试吧,对着几百个分支逻辑一个一个写,枯燥到想辞职。
2026 年,AI 辅助测试生成的能力已经成熟了不少。最近 Cursor 的 Agent 模式可以直接读取测试报告并自动补充缺失用例,Claude Code 也能一次性分析整个模块生成全面的测试计划。我花了三个周末,把测试覆盖率系统性地提了上来,而且没有想象中那么痛苦。
工具选型与分工
我用的组合:
| 工具 | 角色 |
|---|---|
| Cursor(Claude Sonnet 4 模型) | 在 IDE 里选中函数,直接生成测试文件骨架和基础用例 |
| Claude Code CLI | 读取整个 Service 文件,输出所有需要覆盖的测试场景(包括边界、异常、并发) |
| Vitest | 测试框架,跑得快,原生支持 ESM |
| Istanbul(覆盖率工具) | 生成覆盖率报告,配合 AI 找缺口 |
分工逻辑:Cursor 负责"写",Claude Code 负责"想",人负责"审"。
实战:从零补全一个优惠券计算模块的测试
优惠券模块 CouponService.ts 大概 300 行,包含满减、折扣、叠加互斥等逻辑,原来只有一个测试用例(验证满减券正常使用)。
第一步:让 Claude Code 生成测试计划
我在终端里执行:
bash
claude "分析 src/services/CouponService.ts,列出该模块所有需要测试的场景。
要求:
1. 分类:正常路径、边界条件、异常路径、状态互斥。
2. 每个场景描述输入和预期输出。
3. 标注哪些场景涉及异步操作(需要 await / act)。
4. 输出为 Markdown 列表,保存在 /docs/test-plan-coupon.md"
Claude Code 用了约 1 分钟分析完毕,输出如下(节选):
markdown
## CouponService 测试场景清单
### 正常路径
- 满减券:订单金额 100,满 80 减 20 → 实付 80
- 折扣券:订单金额 200,8 折券 → 实付 160
- 多张券叠加(无互斥):满减券 + 运费券 → 总优惠累加
- 券已使用后不能重复用
### 边界条件
- 订单金额恰好等于门槛(满 100 减 10,订单 100)
- 券面额大于订单金额(满 100 减 200)→ 订单金额为 0 还是剩余?
- 多张券叠加总优惠超过订单金额 → 订单最低 0.01
- 并发使用同一张券 → 乐观锁防重
### 异常路径
- 券已过期 → 抛出 CouponExpiredError
- 券未到使用时间 → 抛出 CouponNotYetValidError
- 用户无此券 → 抛出 CouponNotOwnedError
- 券适用范围不匹配(指定商品券)→ 抛出 CouponNotApplicableError
### 状态互斥
- 已使用的券再次请求 → 不可用
- 已过期的券 + 已使用状态 → 同时满足,优先级
我审核了一遍,补充了一个场景:券被后台手动作废后也不能使用。
第二步:用 Cursor 逐个生成测试函数
在 Cursor 中打开 CouponService.ts,选中第一个要测试的方法 applyCoupon,按 Cmd+K 输入:
text
根据 /docs/test-plan-coupon.md 中列出的场景,为 applyCoupon 生成 Vitest 测试用例。
要求:
- 使用 describe/it 结构
- Mock 外部依赖(数据库查询、其他 service 调用)
- 每个测试用例要有清晰的名称
- 对于需要验证异常的场景,使用 expect().rejects.toThrow()
Cursor 自动生成了大约 250 行的测试文件,包含了 17 个用例。下面是其中几个典型用例的生成效果:
typescript
// __tests__/CouponService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CouponService } from '../CouponService';
describe('CouponService.applyCoupon', () => {
let service: CouponService;
let mockCouponRepo: any;
let mockOrderRepo: any;
beforeEach(() => {
mockCouponRepo = {
findById: vi.fn(),
markAsUsed: vi.fn(),
};
mockOrderRepo = {
findById: vi.fn(),
};
service = new CouponService(mockCouponRepo, mockOrderRepo);
});
it('满减券:订单金额 100,满 80 减 20 → 实付 80', async () => {
mockCouponRepo.findById.mockResolvedValue({
id: 'c1',
type: 'full_reduction',
threshold: 80,
discount: 20,
status: 'available',
userId: 'u1',
validFrom: new Date('2020-01-01'),
validUntil: new Date('2030-01-01'),
});
mockOrderRepo.findById.mockResolvedValue({
id: 'o1',
totalAmount: 100,
userId: 'u1',
});
const result = await service.applyCoupon('o1', 'c1');
expect(result.finalAmount).toBe(80);
expect(mockCouponRepo.markAsUsed).toHaveBeenCalledWith('c1');
});
it('券面额大于订单金额 → 订单最低为 0.01', async () => {
mockCouponRepo.findById.mockResolvedValue({
id: 'c2',
type: 'full_reduction',
threshold: 10,
discount: 200,
status: 'available',
userId: 'u1',
validFrom: new Date('2020-01-01'),
validUntil: new Date('2030-01-01'),
});
mockOrderRepo.findById.mockResolvedValue({
id: 'o2',
totalAmount: 100,
userId: 'u1',
});
const result = await service.applyCoupon('o2', 'c2');
expect(result.finalAmount).toBe(0.01);
});
it('券已过期 → 抛出 CouponExpiredError', async () => {
mockCouponRepo.findById.mockResolvedValue({
id: 'c3',
type: 'full_reduction',
threshold: 80,
discount: 20,
status: 'available',
userId: 'u1',
validFrom: new Date('2020-01-01'),
validUntil: new Date('2020-12-31'), // 已过期
});
await expect(service.applyCoupon('o1', 'c3'))
.rejects.toThrow('CouponExpiredError');
});
});
AI 的细节处理让我意外:它自动处理了 Mock 的参数匹配,甚至给"最低 0.01"这种业务规则写了准确的断言。我只需要花几分钟审一遍,改了几个 Mock 返回值的字段,测试就能跑通。
第三步:处理异步并发场景
测试计划里有一个"并发使用同一张券"的场景,需要模拟两个请求同时操作。我让 Cursor 继续生成:
typescript
it('并发使用同一张券时,只有第一个请求成功,第二个抛出并发冲突错误', async () => {
mockCouponRepo.findById.mockResolvedValue({
id: 'c4',
type: 'full_reduction',
threshold: 10,
discount: 5,
status: 'available',
userId: 'u1',
version: 1, // 乐观锁版本号
validFrom: new Date('2020-01-01'),
validUntil: new Date('2030-01-01'),
});
// 第一次 markAsUsed 成功,第二次抛出版本冲突
mockCouponRepo.markAsUsed
.mockResolvedValueOnce({ success: true })
.mockRejectedValueOnce(new Error('OptimisticLockError'));
const [r1, r2] = await Promise.allSettled([
service.applyCoupon('o1', 'c4'),
service.applyCoupon('o1', 'c4'),
]);
expect(r1.status).toBe('fulfilled');
expect(r2.status).toBe('rejected');
});
这里 AI 用了 mockResolvedValueOnce 和 mockRejectedValueOnce 来模拟乐观锁的两种结果,写法很标准,甚至比我手写的还好。
让 AI 分析覆盖率缺口,自动补充遗漏用例
测试文件写完后,运行 Vitest 并生成覆盖率报告:
bash
npx vitest --coverage
Istanbul 生成的 coverage/lcov-report/index.html 会指出哪些行、哪些分支没被覆盖。我把这个报告里的"未覆盖行号"摘要贴给了 Cursor:
text
以下是我的 CouponService.ts 文件中未被测试覆盖的行号和分支:
- Line 45-48: 超过最大叠加数量时的逻辑
- Line 62: 券类型为 'cash' 时的特殊处理
- Line 78-82: 指定商品券的适用范围校验 else 分支
请分析这些未覆盖的代码,生成补充测试用例,确保覆盖这些分支。
Cursor 根据行号定位代码,然后生成了 4 个新测试用例,把分支覆盖率从 78% 拉到了 96%。以前人工做这件事,要对着覆盖率报告一行一行找缺口,AI 替我做了这份苦力。
踩过的坑
1. Mock 太完美,隐藏了真实 bug
AI 生成的 Mock 总是假设数据库返回的数据结构完全正确。有一次 mockCouponRepo.findById 返回的对象里缺少 userId 字段(代码里有个分支没判断空值),结果线上真的出现了 coupon 无主的情况,测试却全过了。
教训:在 AI 生成的标准用例之外,必须手动补充 Mock 返回异常数据(缺字段、类型错误、null)的场景。
2. 异步测试忘记 await
AI 生成的异步测试有时候会忘记 await,导致测试假通过。Vitest 会警告,但容易被忽略。
教训 :跑完 AI 生成的测试后,手动检查一遍没有 await 的 expect,或者在 ESLint 里启用 require-await 规则。
3. 测试用例命名不直观
AI 生成的测试名有时是"should work correctly"这种废话。我统一要求 AI 用"场景 + 预期结果"的命名格式,比如"满减券:订单 100 满 80 减 20 → 实付 80",每次生成前在提示词里强调。
整合进 CI,用 AI 自动维护测试
测试写完后,最怕的是过两个月又没人维护。我做了两件事:
1. CI 中检查覆盖率,不达标不让合并
yaml
# .github/workflows/test.yml
- name: Run tests with coverage
run: npx vitest --coverage
- name: Check coverage thresholds
run: |
npx istanbul check-coverage --lines 90 --functions 90 --branches 85
如果有人提交了代码导致覆盖率下降,CI 直接报红。
2. 用 Claude Code 定期扫描新代码,自动生成测试建议
每周跑一次脚本,扫描最近一周新增的函数,调用 Claude Code 生成"需要测试的场景列表",发到团队 Slack 里:
bash
# scripts/test-reminder.sh
NEW_FUNCTIONS=$(git diff --name-only HEAD~7 | grep '.ts$' | xargs grep -Pn 'export (function|const \w+ = \()' | head -20)
claude "以下是本周新增的函数,请为每个函数列出需要测试的关键场景,用 Markdown 列表输出到 /docs/weekly-test-suggestions.md。
$NEW_FUNCTIONS"
效果:测试不是"一次性项目",而是持续演进的习惯。
最终数据
| 指标 | 以前 | 现在 |
|---|---|---|
| 单元测试覆盖率 | 12% | 91% |
| 单模块测试编写时间 | 4 小时 | 1 小时(含审核) |
| 回归 bug 数(月度) | 8 个 | 2 个 |
| 重构信心指数(自评) | 2/10 | 8/10 |
最重要的变化不是数字,而是敢改代码了。以前改一个核心模块前要心理建设半天,现在改完跑一下测试,绿了就有底气上线。
你可以直接用的提示词模板
生成测试计划
text
分析 [文件路径],列出所有需要测试的场景:
1. 正常路径
2. 边界条件(空值、极大极小值、零值、负数)
3. 异常路径(网络错误、权限不足、数据不存在)
4. 状态组合和互斥
5. 并发场景(如果涉及共享状态)
输出为 Markdown 表格,每行一个场景,包含描述、输入、预期输出。
生成测试代码
text
基于以下测试计划,为 [函数名] 生成 [Vitest/Jest] 测试代码。
要求:
- 使用 describe/it 结构
- Mock 所有外部依赖(数据库、网络、文件系统)
- 每个用例命名格式:"场景描述 → 预期结果"
- 对异常用例使用 expect().rejects.toThrow()
- 如果涉及 React 组件,使用 @testing-library/react 和 render
补全遗漏用例
text
以下是我的 [文件名] 文件未被测试覆盖的行:
[粘贴覆盖率报告的行号列表]
请生成补充测试用例,确保覆盖这些行。描述每个用例覆盖了哪一行。
总结
AI 写单元测试,不是替代你思考,而是替代你做重复劳动。你负责"测试什么"(分析业务逻辑、找出边界),AI 负责"怎么测"(生成 Mock、拼接断言、处理异步)。当测试覆盖率从 12% 涨到 91% 时,带来的不仅是代码质量,更是一种"掌控感"------你知道每一行业务逻辑都有测试在守护。
你的项目测试覆盖率是多少?有没有用 AI 写过测试?欢迎评论区交流你的经验和踩坑。