让 AI 帮我写单元测试,三个月后覆盖率从 12% 涨到了 91%

写单元测试是程序员最不爱干的活之一。本文记录如何用 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 用了 mockResolvedValueOncemockRejectedValueOnce 来模拟乐观锁的两种结果,写法很标准,甚至比我手写的还好。

让 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 生成的测试后,手动检查一遍没有 awaitexpect,或者在 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 写过测试?欢迎评论区交流你的经验和踩坑。

相关推荐
co_der12 小时前
救救孩子吧:被 AI 蠢哭后,我手把手教它 DDD,最后逼它自己写了这篇总结
ai编程·领域驱动设计
人月神话-Lee12 小时前
【图像处理】框架设计——协议、值类型与工程化思维
图像处理·人工智能·ios·设计模式·架构·ai编程·swift
_未完待续13 小时前
从零打造 AI Agent (二)—— 让 AI 拥有记忆
agent·ai编程
零壹AI实验室13 小时前
2026年5月AI编程工具横评:GPT-5.5、Claude Opus 4.7、Qwen3.7-Max谁最强
gpt·ai编程
Tech-Wang13 小时前
零基础AI编程之鸿蒙app开发
ai编程
财经资讯数据_灵砚智能13 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(夜间-次晨)2026年5月27日
大数据·人工智能·python·信息可视化·自然语言处理·ai编程·灵砚智能
kkkliaoo14 小时前
2026年AI编程Token消耗优化:从月费500到月费5的成本控制实战
人工智能·ai编程
Bigger14 小时前
mini-cc 的记忆引擎:让 AI 别再当金鱼了
前端·ai编程·claude
JavaGuide14 小时前
终于有好用的 Claude Code 状态栏增强插件了!
前端·后端·ai编程