写前端的都知道,测试是最容易被砍掉的环节。需求赶、排期紧,测试永远是”下个版本再补”。但最近半年 AI 编程工具的测试生成能力突飞猛进,我花了一周时间,拿一个真实的 React 组件库项目,分别用 Claude Code、GitHub Copilot 和 Cursor 生成单元测试,看看 AI 写测试到底到了什么水平。
测试项目:一个表单组件库
用的是我自己维护的一套内部表单组件库,包含 Input、Select、DatePicker、Form 等十几个组件。技术栈:React 18 + TypeScript + Vitest + Testing Library。选这个项目是因为表单组件的边界情况特别多——校验、联动、异步提交、受控/非受控模式——很适合考验 AI 的测试覆盖能力。
测试之前,整个项目的测试覆盖率是 23%,大部分都是建项目时随手写的 smoke test。
Round 1:Claude Code 直接上
先用 Claude Code,因为它能直接读项目代码。命令很简单:
claude "给 src/components/Select 目录下的所有组件生成完整的单元测试,使用 vitest + @testing-library/react,覆盖正常使用、边界情况和错误处理"
Claude Code 的做法让我有点意外——它没有一上来就写测试,而是先花了几分钟读完了 Select 组件的所有源码、类型定义和已有的测试文件,然后才开始生成。
生成的测试文件长这样(节选):
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Select } from './Select'
import { vi } from 'vitest'
const defaultOptions = [
{ label: '北京', value: 'beijing' },
{ label: '上海', value: 'shanghai' },
{ label: '广州', value: 'guangzhou' },
]
describe('Select', () => {
// 基础渲染
it('应该正确渲染占位文本', () => {
render(<Select options={defaultOptions} placeholder="请选择城市" />)
expect(screen.getByText('请选择城市')).toBeInTheDocument()
})
// 展开/收起
it('点击后应该展开下拉列表', async () => {
const user = userEvent.setup()
render(<Select options={defaultOptions} />)
await user.click(screen.getByRole('combobox'))
expect(screen.getByRole('listbox')).toBeVisible()
})
// 选中回调
it('选择选项后应该触发 onChange 并关闭下拉', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Select options={defaultOptions} onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('上海'))
expect(onChange).toHaveBeenCalledWith('shanghai', { label: '上海', value: 'shanghai' })
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
// 键盘导航
it('支持键盘上下选择和回车确认', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Select options={defaultOptions} onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}')
expect(onChange).toHaveBeenCalledWith('shanghai', expect.any(Object))
})
// 禁用状态
it('禁用状态下点击不应该展开', async () => {
const user = userEvent.setup()
render(<Select options={defaultOptions} disabled />)
await user.click(screen.getByRole('combobox'))
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
// 搜索过滤
it('输入关键词应该过滤选项', async () => {
const user = userEvent.setup()
render(<Select options={defaultOptions} showSearch />)
await user.click(screen.getByRole('combobox'))
await user.type(screen.getByRole('searchbox'), '北')
expect(screen.getByText('北京')).toBeInTheDocument()
expect(screen.queryByText('上海')).not.toBeInTheDocument()
})
// 空选项
it('没有匹配选项时显示空状态', async () => {
const user = userEvent.setup()
render(<Select options={defaultOptions} showSearch />)
await user.click(screen.getByRole('combobox'))
await user.type(screen.getByRole('searchbox'), 'xyz')
expect(screen.getByText('暂无数据')).toBeInTheDocument()
})
// 受控模式
it('受控模式下 value 变化应该更新显示', () => {
const { rerender } = render(
<Select options={defaultOptions} value="beijing" />
)
expect(screen.getByText('北京')).toBeInTheDocument()
rerender(<Select options={defaultOptions} value="shanghai" />)
expect(screen.getByText('上海')).toBeInTheDocument()
})
// 多选模式
it('多选模式下可以选择多个值', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Select options={defaultOptions} mode="multiple" onChange={onChange} />)
await user.click(screen.getByRole('combobox'))
await user.click(screen.getByText('北京'))
await user.click(screen.getByText('上海'))
expect(onChange).toHaveBeenLastCalledWith(
['beijing', 'shanghai'],
expect.any(Array)
)
})
})
一个 Select 组件,Claude Code 生成了 34 个测试用例,覆盖了基础渲染、交互、键盘导航、搜索、多选、受控/非受控、异步加载、无障碍等场景。跑了一下:28 个通过,6 个失败。
失败的原因主要有两类:
- 角色查询不对——组件用的不是标准的 ARIA role,Claude 猜的 role 名和实际不一致
- 异步时序问题——有两个测试的 waitFor 超时了,因为组件内部的防抖逻辑
手动修了这 6 个之后全部通过。修的时间大概 15 分钟。
Round 2:Copilot 逐文件生成
GitHub Copilot 没有 Claude Code 那种全局上下文能力,所以得一个文件一个文件来。打开 Select.tsx,然后新建 Select.test.tsx,Copilot 开始补全。
Copilot 的风格明显不同——它更倾向于生成”看起来对”的测试,但细节经常出错。比如:
// Copilot 生成的
it('should render correctly', () => {
const { container } = render(<Select options={options} />)
expect(container).toMatchSnapshot()
})
it('should call onChange when option is selected', () => {
const onChange = jest.fn() // ← 用了 jest 而不是 vitest
render(<Select options={options} onChange={onChange} />)
fireEvent.click(screen.getByText('Option 1')) // ← 硬编码了英文
expect(onChange).toHaveBeenCalledWith('1')
})
几个明显问题:
- 项目用的 vitest,它给我
jest.fn() - 选项数据是它自己编的,跟组件的 props 类型不匹配
- snapshot 测试——这种测试基本没什么价值,改一下 className 就挂
- 没有覆盖键盘导航、搜索、多选等高级场景
一个 Select 组件,Copilot 生成了 12 个测试,跑通 5 个。剩下 7 个要么是 API 用错了,要么是断言写错了。修复时间超过 40 分钟,因为很多测试的思路本身就不对,不是改个名字的事。
Round 3:Cursor 的 AI 测试生成
Cursor 的体验介于两者之间。它能读到项目上下文(通过 .cursorrules 和索引),但生成策略偏保守。
我在 Cursor 里用 Cmd+K 选中 Select 组件,输入”为这个组件生成完整的单元测试”。结果:
- 生成了 20 个测试
- 正确使用了 vitest 而不是 jest(读到了 vite.config)
- 测试数据比 Copilot 合理,但不如 Claude Code 贴近实际组件的用法
- 跑通 14 个,6 个需要修复
- 修复时间约 25 分钟
三轮对比数据
| 指标 | Claude Code | Copilot | Cursor |
|---|---|---|---|
| 生成测试数 | 34 | 12 | 20 |
| 直接通过 | 28 (82%) | 5 (42%) | 14 (70%) |
| 修复时间 | ~15min | ~40min | ~25min |
| 边界覆盖 | 键盘/搜索/多选/异步/无障碍 | 基础点击和渲染 | 键盘/搜索/部分边界 |
| 测试风格 | 用户行为驱动 | 实现细节驱动 | 混合 |
| 框架识别 | 准确 (vitest) | 经常搞混 (jest) | 准确 (vitest) |
全项目跑一遍的结果
只用 Claude Code,把整个组件库的测试都过了一遍。最终数据:
- 覆盖率从 23% → 81%
- 新增测试用例 247 个
- 直接通过率 76%
- 总修复时间约 3 小时(主要是调整 ARIA 查询和异步等待)
- 发现了 4 个真实 bug——AI 写的测试触发了我们自己没覆盖到的边界条件
4 个 bug 里最有意思的一个:DatePicker 在选择跨年日期时,月份计算会溢出。这个 bug 存在了至少半年,手动测试从来没触发过,AI 直接写了一个 new Date(2025, 11, 31) 到 new Date(2026, 0, 1) 的跨年测试把它揪出来了。
AI 生成测试的几个坑
1. ARIA Role 乱猜
这是最常见的失败原因。AI 倾向于用标准的 ARIA role(combobox、listbox、option),但很多组件库的实现并不符合 WAI-ARIA 规范。解决方案:要么修组件(推荐),要么在 prompt 里说清楚用 getByTestId。
2. 异步时序踩雷
AI 不知道你的组件内部有没有 debounce、throttle 或 transition 动画。生成的 waitFor 经常超时或者不够。我的做法是统一在测试配置里把 waitFor 超时设为 3000ms,然后 CI 环境给够时间。
3. Mock 不一致
Copilot 尤其明显——它会在不同的测试里用不同的 mock 方式,一会儿 jest.mock,一会儿 vi.mock,一会儿又手写 stub。Claude Code 好很多,基本能保持一致。
4. Snapshot 测试泛滥
Copilot 特别爱生成 snapshot 测试。这种测试看着覆盖率高,实际上毫无意义——任何 UI 改动都会导致 snapshot 失效,最后团队的习惯就是无脑 --update。建议在 prompt 里明确说”不要用 snapshot 测试”。
我的工作流建议
经过这一周的测试,我现在的做法是:
- 用 Claude Code 批量生成初始测试——它的上下文理解能力最强,生成的测试质量最高
- 跑一遍,分类处理失败用例——ARIA 查询错误统一修、时序问题统一调、真正的 bug 提 issue
- 后续新组件用 Cursor 边写边测——Cursor 在你写代码的过程中实时补全测试,体验最流畅
- Copilot 用来补单个函数的测试——纯工具函数的测试 Copilot 够用,不需要大炮打蚊子
AI 写测试不是银弹,但它确实把”补测试”这个最无聊的工作从几天压缩到了几小时。覆盖率从 23% 到 81%,过去可能需要一个人干一周,现在一个下午搞定。
关键不是 AI 写的测试有多完美,而是它提供了一个 80% 正确的起点。在这个起点上修比从零开始写,效率完全不是一个量级。