Sub-agents 多 Agent 协作:定义 / 内置类型 / Agent Teams 并发编排
第四十四章:Claude Code 与测试驱动开发:让 AI 写测试、看红灯、修代码
44.1 为什么 TDD 与 AI 编程是天作之合
测试驱动开发(Test-Driven Development,TDD)是软件工程领域被反复验证的高效开发方法论,但在实践中阻力巨大——写测试比写实现慢,很多人会跳过。
Claude Code 改变了这个方程式。
当 AI 负责编写测试时,"写测试慢"的理由消失了。当 AI 可以立即看到测试失败的输出并修复代码时,TDD 的反馈循环从"需要开发者手动执行多步"变成了"Claude 自动迭代直到绿灯"。
TDD 与 AI 编程的结合,解决了两个长期痛点:
- TDD 的执行成本问题:AI 消除了手写测试的时间成本
- AI 代码的质量验证问题:测试提供了 AI 生成代码的客观质量衡量标准
一句话概括:让 AI 写测试,AI 就无法欺骗你。测试是行为的精确规范,只有真正工作的代码才能让测试通过。
44.2 TDD 的三个阶段:红灯、绿灯、重构
经典 TDD 遵循"红-绿-重构"循环:
红灯(Red):写一个失败的测试
↓
绿灯(Green):写最少量的代码让测试通过
↓
重构(Refactor):在保持测试通过的前提下改善代码
↓
(循环)
在 Claude Code 辅助下,这个循环可以大幅加速:
人类:描述期望的行为
↓
Claude:将行为规范转化为测试代码(红灯)
↓
Claude:运行测试,确认失败
↓
Claude:写实现代码,追求最小化实现
↓
Claude:运行测试,如果失败则继续修改
↓
Claude:测试通过,展示结果(绿灯)
↓
人类/Claude:评估代码质量,必要时重构
44.3 实践:从零开始的 TDD 示例
以一个真实的场景为例:为一个工具函数库添加"货币格式化"功能。
第一步:明确需求规范
向 Claude 描述功能,但不要急着让它写实现代码,先让它写测试:
我需要一个 formatCurrency(amount, currency, locale) 函数,行为规范:
- 将数字格式化为对应货币格式的字符串
- 支持 USD、EUR、CNY 货币代码
- 支持 en-US、zh-CN、de-DE 区域设置
- amount 为负数时显示负号
- amount 为 0 时返回 "0.00" 格式(带货币符号)
- amount 不是数字时抛出 TypeError
请先写测试,不要写实现。
第二步:Claude 写测试(红灯)
Claude 会生成如下测试:
// src/utils/formatCurrency.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency } from './formatCurrency';
describe('formatCurrency', () => {
describe('基础货币格式化', () => {
it('应该格式化 USD 为英文格式', () => {
expect(formatCurrency(1234.56, 'USD', 'en-US')).toBe('$1,234.56');
});
it('应该格式化 EUR 为德语格式', () => {
expect(formatCurrency(1234.56, 'EUR', 'de-DE')).toBe('1.234,56 €');
});
it('应该格式化 CNY 为中文格式', () => {
expect(formatCurrency(1234.56, 'CNY', 'zh-CN')).toBe('¥1,234.56');
});
});
describe('边界情况', () => {
it('负数应该显示负号', () => {
expect(formatCurrency(-99.99, 'USD', 'en-US')).toBe('-$99.99');
});
it('零值应该正确格式化', () => {
expect(formatCurrency(0, 'USD', 'en-US')).toBe('$0.00');
});
it('非数字输入应该抛出 TypeError', () => {
expect(() => formatCurrency('abc' as any, 'USD', 'en-US')).toThrow(TypeError);
expect(() => formatCurrency(null as any, 'USD', 'en-US')).toThrow(TypeError);
});
});
describe('大数字处理', () => {
it('应该正确处理超过百万的数字', () => {
expect(formatCurrency(1_000_000, 'USD', 'en-US')).toBe('$1,000,000.00');
});
});
});
然后运行测试:
pnpm test src/utils/formatCurrency.test.ts
输出会显示所有测试失败(红灯),因为 formatCurrency 函数还不存在。Claude 会展示并确认这个失败输出。
第三步:Claude 写实现(绿灯)
确认红灯后,Claude 开始写最小化实现:
// src/utils/formatCurrency.ts
export function formatCurrency(
amount: number,
currency: 'USD' | 'EUR' | 'CNY',
locale: 'en-US' | 'zh-CN' | 'de-DE'
): string {
if (typeof amount !== 'number' || isNaN(amount)) {
throw new TypeError(
`Invalid amount: expected a number, got ${typeof amount}`
);
}
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
}
Claude 再次运行测试:
pnpm test src/utils/formatCurrency.test.ts
大多数测试应该通过,但某些区域格式可能有细微差异(例如 EUR 的格式在不同操作系统上可能有空格差异)。Claude 会分析失败原因并调整测试断言或实现,直到全部通过。
第四步:重构阶段
测试全部通过后,可以进入重构:
测试都通过了。现在请重构这个函数:
1. 用类型而不是字符串联合类型来表示 currency 和 locale
2. 提取一个内部的 validateAmount 函数
3. 添加 JSDoc 注释
保持所有测试通过。
重构完成后,Claude 再次运行测试确认没有引入回归。
44.4 测试文件的组织策略
在 Claude Code 辅助下进行 TDD,测试文件的组织策略非常重要:
策略一:测试与实现同目录
src/
├── utils/
│ ├── formatCurrency.ts # 实现
│ ├── formatCurrency.test.ts # 单元测试
│ ├── parseDate.ts
│ └── parseDate.test.ts
优点:实现与测试紧密关联,便于 Claude 定位和操作。
策略二:独立测试目录
src/
├── utils/
│ ├── formatCurrency.ts
│ └── parseDate.ts
tests/
├── unit/
│ ├── utils/
│ │ ├── formatCurrency.test.ts
│ │ └── parseDate.test.ts
└── integration/
└── api/
└── users.test.ts
优点:测试分类清晰,便于分别运行单元测试和集成测试。
在 CLAUDE.md 中说明你选择的策略,让 Claude 知道测试文件应该放在哪里:
## 测试文件约定
- 单元测试与实现文件同目录,命名为 `*.test.ts`
- 集成测试在 `tests/integration/` 目录下
- E2E 测试在 `tests/e2e/` 目录下
- 运行所有测试:`pnpm test`
- 只运行单元测试:`pnpm test:unit`
44.5 让 Claude 处理棘手的测试场景
测试异步代码
请为以下异步函数写测试:
async function fetchUserData(userId: string): Promise<User>
- 成功时返回用户对象
- userId 不存在时抛出 404 错误
- 网络错误时抛出网络错误
需要 mock fetch 调用。
Claude 会生成使用 vi.mock 或 jest.mock 的测试,并处理异步边界:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUserData } from './fetchUserData';
// Mock 全局 fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('fetchUserData', () => {
beforeEach(() => {
mockFetch.mockClear();
});
it('成功时应该返回用户数据', async () => {
const mockUser = { id: '123', name: 'Alice', email: '[email protected]' };
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
});
const result = await fetchUserData('123');
expect(result).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith('/api/users/123');
});
it('用户不存在时应该抛出 NotFoundError', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
json: () => Promise.resolve({ message: 'User not found' }),
});
await expect(fetchUserData('nonexistent')).rejects.toThrow('User not found');
});
it('网络错误时应该抛出 NetworkError', async () => {
mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
await expect(fetchUserData('123')).rejects.toThrow(TypeError);
});
});
测试 React 组件
请为以下 React 组件写测试,使用 React Testing Library:
- UserCard 组件,props: { user: User, onEdit: () => void }
- 测试:渲染用户名、邮箱;点击"编辑"按钮触发 onEdit
Claude 会生成:
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { UserCard } from './UserCard';
const mockUser = {
id: '1',
name: 'Alice Johnson',
email: '[email protected]',
};
describe('UserCard', () => {
it('应该渲染用户名和邮箱', () => {
render(<UserCard user={mockUser} onEdit={() => {}} />);
expect(screen.getByText('Alice Johnson')).toBeInTheDocument();
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
it('点击编辑按钮应该调用 onEdit', () => {
const onEdit = vi.fn();
render(<UserCard user={mockUser} onEdit={onEdit} />);
fireEvent.click(screen.getByRole('button', { name: /编辑/i }));
expect(onEdit).toHaveBeenCalledTimes(1);
});
});
测试数据库操作
为 UserRepository 的 create 方法写集成测试:
- 成功创建用户并返回带 id 的用户对象
- 邮箱重复时抛出 UniqueConstraintError
使用测试数据库,每次测试后清理数据。
Claude 会使用 beforeEach/afterEach 处理数据库清理:
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { UserRepository } from './UserRepository';
import { db } from '@/lib/test-db'; // 专用测试数据库连接
describe('UserRepository.create', () => {
const repo = new UserRepository(db);
afterEach(async () => {
// 清理测试数据
await db.user.deleteMany({ where: { email: { contains: '@test.example' } } });
});
it('应该成功创建用户并返回 id', async () => {
const created = await repo.create({
name: 'Test User',
email: '[email protected]',
});
expect(created.id).toBeDefined();
expect(created.name).toBe('Test User');
expect(created.email).toBe('[email protected]');
});
it('邮箱重复时应该抛出 UniqueConstraintError', async () => {
await repo.create({ name: 'User 1', email: '[email protected]' });
await expect(
repo.create({ name: 'User 2', email: '[email protected]' })
).rejects.toThrow('UniqueConstraintError');
});
});
44.6 测试覆盖率分析与补充
当现有代码库缺少测试时,可以让 Claude 分析覆盖率并补充测试:
运行测试覆盖率报告:pnpm test --coverage
然后找出覆盖率低于 70% 的文件,优先为以下类型的代码补充测试:
1. 包含业务逻辑的函数
2. 边界条件处理代码
3. 错误处理分支
Claude 会:
- 运行覆盖率命令并分析输出
- 识别未覆盖的代码路径
- 为每个未覆盖路径生成针对性测试
- 再次运行覆盖率确认提升
44.7 TDD 工作流的 CLAUDE.md 配置
在 CLAUDE.md 中配置 TDD 工作流,让 Claude 默认遵循:
## 开发工作流:测试驱动开发
**默认原则:先测试,后实现**
在为新功能或新函数写代码时,Claude 必须:
1. 先写测试文件(即使函数还不存在)
2. 运行测试,确认测试失败(red)
3. 写最小化实现让测试通过(green)
4. 报告测试通过结果
**禁止行为:**
- 不得直接写实现代码而不先写测试
- 不得跳过运行测试的步骤
- 不得为了让测试通过而修改测试(除非需求变了)
**测试框架:Vitest**
运行命令:
- `pnpm test` — 运行所有测试
- `pnpm test <文件路径>` — 运行指定文件的测试
- `pnpm test --coverage` — 运行并生成覆盖率报告
44.8 处理 TDD 中的常见挑战
挑战一:测试规范与实现的分歧
有时 Claude 写的测试规范和你的预期有微妙差异。解决方案:在写测试后,让 Claude 以人类可读的语言解释每个测试用例的意图,确认后再运行。
挑战二:Mock 设计过于复杂
过度 Mock 会导致测试脆弱。在 CLAUDE.md 中明确 Mock 原则:
## 测试 Mock 原则
- 只 Mock 外部依赖(HTTP 调用、数据库、文件系统)
- 不 Mock 被测模块内部的函数
- 优先使用真实实现(In-memory DB > Mock DB)
- 测试应该测试行为,而不是实现细节
挑战三:测试运行速度慢
数据库集成测试可能很慢。让 Claude 分离快速测试和慢速测试:
# 快速单元测试(CI 每次 commit 运行)
pnpm test:unit
# 慢速集成测试(CI 每次 PR 运行)
pnpm test:integration
小结
TDD 与 Claude Code 的结合是一个相互强化的组合:TDD 给了 Claude 生成代码的精确规范,Claude 消除了 TDD 的执行成本。
关键要点:
- 让 Claude 先写失败的测试,再写实现,这是确保代码正确性的最可靠方式
- 红灯-绿灯-重构循环可以完全由 Claude 自动执行,人类只需在节点上确认
- 覆盖异步代码、React 组件、数据库操作等复杂场景时,Claude 能生成正确的 Mock 和测试结构
- 在 CLAUDE.md 中配置 TDD 为默认工作流,防止 Claude 跳过测试步骤
- 通过覆盖率分析,Claude 可以系统性地为现有代码库补充测试