name: tdd-enforcer
description: 在实现新功能时使用。强制执行TDD工作流程 - 先写测试,再写实现。确保AAA模式、适当的覆盖率和高质量的测试设计。
allowed-tools: Read, Grep, Bash
TDD工作流程强制执行器
何时使用
TDD流程(强制)
1. 先写测试(RED阶段)
- 通过测试定义行为
- 使用AAA模式(Arrange, Act, Assert)
- 测试最初必须失败
- 清晰的测试名称描述预期行为
2. 验证测试失败(确认)
- 运行测试:
npm test
- 确认因正确原因失败
- 测试应因功能不存在而失败,而不是语法错误
3. 编写实现(GREEN阶段)
- 编写最少的代码使测试通过
- 不要过度设计或添加额外功能
- 专注于使测试通过
4. 验证测试通过(验证)
- 运行测试:
npm test
- 所有新测试必须通过
- 所有现有测试必须仍然通过
5. 重构(REFACTOR阶段)
- 提高代码质量
- 消除重复
- 增强可读性
- 重构过程中测试保持通过
覆盖率要求
- 总体:75%+
- 业务逻辑(src/services/):90%+
- 工具类(src/utils/):90%+
- UI组件:60%+
- 关键用户流程的E2E测试
AAA模式(Arrange, Act, Assert)
describe('AuthService', () => {
describe('register', () => {
it('应该使用哈希密码创建用户', async () => {
// ARRANGE: 设置测试数据
const userData = {
email: 'test@example.com',
password: 'Pass123!',
}
// ACT: 执行行为
const result = await authService.register(userData)
// ASSERT: 验证结果
expect(result.id).toBeDefined()
expect(result.email).toBe(userData.email)
expect(result).not.toHaveProperty('password') // 永不返回密码
})
it('应该拒绝弱密码', async () => {
// ARRANGE
const userData = {
email: 'test@example.com',
password: '123', // 太弱
}
// ACT & ASSERT
await expect(authService.register(userData)).rejects.toThrow(
'密码必须至少8个字符'
)
})
})
})
测试结构
Describe块
// ✅ 正确做法:按模块/类组织
describe('UserService', () => {
// ✅ 正确做法:按方法组织
describe('findById', () => {
it('找到时应返回用户', () => {})
it('未找到时应返回null', () => {})
it('无效ID时应抛出错误', () => {})
})
describe('create', () => {
it('应该用有效数据创建用户', () => {})
it('应该验证邮箱格式', () => {})
it('应该在保存前哈希密码', () => {})
})
})
测试名称
// ✅ 正确做法:描述性测试名称
it('邮箱无效时应返回400', () => {})
it('应该在保存前用bcrypt哈希密码', () => {})
it('注册后应发送欢迎邮件', () => {})
// ❌ 错误做法:模糊的测试名称
it('有效', () => {})
it('测试用户创建', () => {})
it('应该正确工作', () => {})
测试不同层级
单元测试(业务逻辑)
// src/services/auth.service.test.ts
import { AuthService } from './auth.service'
import { prismaMock } from '../test/prisma-mock'
import bcrypt from 'bcrypt'
describe('AuthService', () => {
describe('login', () => {
it('应该为有效凭证返回用户和令牌', async () => {
// ARRANGE
const hashedPassword = await bcrypt.hash('password123', 10)
const mockUser = {
id: '1',
email: 'user@test.com',
password: hashedPassword,
}
prismaMock.user.findUnique.mockResolvedValue(mockUser)
// ACT
const result = await authService.login({
email: 'user@test.com',
password: 'password123',
})
// ASSERT
expect(result.user.email).toBe('user@test.com')
expect(result.token).toBeDefined()
expect(result.user).not.toHaveProperty('password')
})
it('密码错误时应抛出错误', async () => {
// ARRANGE
const hashedPassword = await bcrypt.hash('password123', 10)
const mockUser = {
id: '1',
email: 'user@test.com',
password: hashedPassword,
}
prismaMock.user.findUnique.mockResolvedValue(mockUser)
// ACT & ASSERT
await expect(
authService.login({
email: 'user@test.com',
password: 'wrongpassword',
})
).rejects.toThrow('无效凭证')
})
})
})
集成测试(API路由)
// src/app/api/auth/register/route.test.ts
import { POST } from './route'
describe('POST /api/auth/register', () => {
it('应该创建用户并返回201', async () => {
// ARRANGE
const request = new Request('http://localhost/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: 'newuser@test.com',
password: 'SecurePass123!',
name: 'Test User',
}),
})
// ACT
const response = await POST(request)
const data = await response.json()
// ASSERT
expect(response.status).toBe(201)
expect(data.user.email).toBe('newuser@test.com')
expect(data.token).toBeDefined()
expect(data.user).not.toHaveProperty('password')
})
it('无效邮箱时应返回400', async () => {
// ARRANGE
const request = new Request('http://localhost/api/auth/register', {
method: 'POST',
body: JSON.stringify({
email: 'invalid-email',
password: 'SecurePass123!',
}),
})
// ACT
const response = await POST(request)
const data = await response.json()
// ASSERT
expect(response.status).toBe(400)
expect(data.error).toContain('email')
})
})
组件测试(UI)
// src/components/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginForm } from './LoginForm'
describe('LoginForm', () => {
it('应该用邮箱和密码调用onSubmit', async () => {
// ARRANGE
const mockOnSubmit = vi.fn().mockResolvedValue(undefined)
render(<LoginForm onSubmit={mockOnSubmit} />)
// ACT
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'user@test.com' },
})
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'password123' },
})
fireEvent.click(screen.getByRole('button', { name: /login/i }))
// ASSERT
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith({
email: 'user@test.com',
password: 'password123',
})
})
})
it('登录失败时应显示错误消息', async () => {
// ARRANGE
const mockOnSubmit = vi
.fn()
.mockRejectedValue(new Error('无效凭证'))
render(<LoginForm onSubmit={mockOnSubmit} />)
// ACT
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'user@test.com' },
})
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'wrongpassword' },
})
fireEvent.click(screen.getByRole('button', { name: /login/i }))
// ASSERT
await waitFor(() => {
expect(screen.getByText(/无效凭证/i)).toBeInTheDocument()
})
})
it('加载时应禁用提交按钮', async () => {
// ARRANGE
const mockOnSubmit = vi
.fn()
.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
render(<LoginForm onSubmit={mockOnSubmit} />)
// ACT
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: 'user@test.com' },
})
fireEvent.change(screen.getByLabelText(/password/i), {
target: { value: 'password123' },
})
const submitButton = screen.getByRole('button', { name: /login/i })
fireEvent.click(submitButton)
// ASSERT
expect(submitButton).toBeDisabled()
await waitFor(() => {
expect(submitButton).not.toBeDisabled()
})
})
})
E2E测试(关键流程)
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('认证流程', () => {
test('用户可以注册和登录', async ({ page }) => {
// ARRANGE
const email = `test-${Date.now()}@example.com`
const password = 'SecurePass123!'
// ACT: 注册
await page.goto('/register')
await page.fill('[name="email"]', email)
await page.fill('[name="password"]', password)
await page.fill('[name="confirmPassword"]', password)
await page.click('button[type="submit"]')
// ASSERT: 重定向到仪表板
await expect(page).toHaveURL('/dashboard')
await expect(page.locator('h1')).toContainText('Dashboard')
// ACT: 登出
await page.click('[data-testid="user-menu"]')
await page.click('text=Logout')
// ASSERT: 重定向到登录
await expect(page).toHaveURL('/login')
// ACT: 登录
await page.fill('[name="email"]', email)
await page.fill('[name="password"]', password)
await page.click('button[type="submit"]')
// ASSERT: 返回仪表板
await expect(page).toHaveURL('/dashboard')
})
})
测试质量要求
✅ 正确做法:测试行为,而不是实现
// ✅ 正确做法
it('登录失败时应显示错误消息', async () => {
// 测试用户看到的内容
await expect(screen.getByText(/无效凭证/i)).toBeInTheDocument()
})
// ❌ 错误做法
it('应该用"无效凭证"调用setError', async () => {
// 测试实现细节
expect(setError).toHaveBeenCalledWith('无效凭证')
})
✅ 正确做法:测试边界情况
it('应该处理空输入', () => {})
it('应该处理超长输入(> 1000字符)', () => {})
it('应该处理邮箱中的特殊字符', () => {})
it('应该处理并发请求', () => {})
✅ 正确做法:测试错误条件
it('应该处理数据库连接失败', () => {})
it('应该处理网络超时', () => {})
it('应该处理无效JSON响应', () => {})
✅ 正确做法:使用测试数据构建器
// 测试数据构建器用于更清晰的测试
const userBuilder = {
default: () => ({
email: 'test@example.com',
password: 'Pass123!',
name: 'Test User',
}),
withEmail: (email: string) => ({
...userBuilder.default(),
email,
}),
withoutName: () => ({
email: 'test@example.com',
password: 'Pass123!',
}),
}
it('应该用默认数据创建用户', () => {
const user = userBuilder.default()
// ...
})
it('应该创建没有名字的用户', () => {
const user = userBuilder.withoutName()
// ...
})
覆盖率验证
# 运行带覆盖率的测试
npm run test:coverage
# 检查覆盖率阈值
npm test -- --coverage --coverageThreshold='{"global":{"lines":75,"functions":75,"branches":75}}'
常见TDD错误
❌ 错误做法:先写实现
// 错误顺序
1. 写函数
2. 写测试
3. 测试通过(或修复测试使其通过)
✅ 正确做法:先写测试
// 正确顺序(TDD)
1. 写测试(RED)
2. 验证测试失败
3. 写最少的实现(GREEN)
4. 验证测试通过
5. 重构(REFACTOR)
❌ 错误做法:测试实现细节
// 错误:测试内部状态
expect(component.state.loading).toBe(true)
// 正确:测试可观察行为
expect(screen.getByTestId('spinner')).toBeInTheDocument()
❌ 错误做法:写一个巨大的测试
// 错误:一个测试做所有事情
it('应该处理整个用户流程', () => {
// 100行测试代码
})
// 正确:拆分为专注的测试
it('应该验证邮箱格式', () => {})
it('应该哈希密码', () => {})
it('应该在数据库中创建用户', () => {})
it('应该发送欢迎邮件', () => {})
提交前检查清单
- [ ] 所有新功能都先写了测试
- [ ] 测试最初失败(RED)
- [ ] 实现使测试通过(GREEN)
- [ ] 代码已重构以提高质量(REFACTOR)
- [ ] 达到覆盖率阈值(总体75%+,业务逻辑90%+)
- [ ] 所有测试使用AAA模式
- [ ] 测试名称具有描述性
- [ ] 测试了边界情况
- [ ] 测试了错误条件
- [ ] 关键用户流程有E2E测试