第 44 章

Sub-agents 多 Agent 协作:定义 / 内置类型 / Agent Teams 并发编排

第四十四章:Claude Code 与测试驱动开发:让 AI 写测试、看红灯、修代码

44.1 为什么 TDD 与 AI 编程是天作之合

测试驱动开发(Test-Driven Development,TDD)是软件工程领域被反复验证的高效开发方法论,但在实践中阻力巨大——写测试比写实现慢,很多人会跳过。

Claude Code 改变了这个方程式。

当 AI 负责编写测试时,"写测试慢"的理由消失了。当 AI 可以立即看到测试失败的输出并修复代码时,TDD 的反馈循环从"需要开发者手动执行多步"变成了"Claude 自动迭代直到绿灯"。

TDD 与 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.mockjest.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 会:

  1. 运行覆盖率命令并分析输出
  2. 识别未覆盖的代码路径
  3. 为每个未覆盖路径生成针对性测试
  4. 再次运行覆盖率确认提升

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 的执行成本。

关键要点:

本章评分
4.8  / 5  (3 评分)

💬 留言讨论