前端测试驱动开发(TDD)实践指南
通过实际案例学习如何在React项目中实践TDD,掌握单元测试、组件测试和E2E测试的编写技巧,提升代码质量
2024年3月8日
11 min read
测试TDDJestReact Testing Library
前言
测试驱动开发(TDD)是一种先写测试、后写代码的开发方式。虽然看起来会降低开发速度,但实际上能够提升代码质量,减少bug,让重构更有信心。本文将通过实际案例,展示如何在React项目中实践TDD。
TDD核心理念
TDD三部曲
// TDD开发流程(红-绿-重构)
// 1. 红灯阶段:编写失败的测试
describe('Calculator', () => {
it('should add two numbers', () => {
const calculator = new Calculator()
expect(calculator.add(2, 3)).toBe(5)
})
})
// 此时运行测试会失败(红灯),因为Calculator还不存在
// 2. 绿灯阶段:编写最简代码让测试通过
class Calculator {
add(a: number, b: number): number {
return a + b
}
}
// 现在测试通过(绿灯)
// 3. 重构阶段:优化代码,保持测试通过
class Calculator {
add(...numbers: number[]): number {
return numbers.reduce((sum, num) => sum + num, 0)
}
}TDD的好处
// ✅ TDD带来的好处
const benefits = {
'更少的bug': '通过测试驱动,避免大量边界情况的bug',
'更好的设计': '先写测试迫使你思考API设计',
'重构信心': '有测试保护,重构不怕出错',
'活文档': '测试本身就是最好的文档',
'快速反馈': '立即知道代码是否正确',
}单元测试实践
纯函数测试
// src/utils/formatter.ts
export function formatCurrency(amount: number, currency = 'CNY'): string {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
}).format(amount)
}
export function formatDate(date: Date, format = 'YYYY-MM-DD'): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return format
.replace('YYYY', String(year))
.replace('MM', month)
.replace('DD', day)
}
// src/utils/formatter.test.ts
import { formatCurrency, formatDate } from './formatter'
describe('formatCurrency', () => {
it('should format number as CNY currency', () => {
expect(formatCurrency(1234.56)).toBe('¥1,234.56')
})
it('should format number as USD currency', () => {
expect(formatCurrency(1234.56, 'USD')).toBe('US$1,234.56')
})
it('should handle zero', () => {
expect(formatCurrency(0)).toBe('¥0.00')
})
it('should handle negative numbers', () => {
expect(formatCurrency(-100)).toBe('-¥100.00')
})
})
describe('formatDate', () => {
const testDate = new Date('2024-03-08')
it('should format date with default format', () => {
expect(formatDate(testDate)).toBe('2024-03-08')
})
it('should format date with custom format', () => {
expect(formatDate(testDate, 'MM/DD/YYYY')).toBe('03/08/2024')
})
it('should pad single digit months and days', () => {
const date = new Date('2024-01-05')
expect(formatDate(date)).toBe('2024-01-05')
})
})带副作用的函数测试
// src/services/api.ts
export class ApiService {
async fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`)
if (!response.ok) {
throw new Error('Failed to fetch user')
}
return response.json()
}
}
// src/services/api.test.ts
import { ApiService } from './api'
describe('ApiService', () => {
let apiService: ApiService
beforeEach(() => {
apiService = new ApiService()
// 清理mock
global.fetch = jest.fn()
})
afterEach(() => {
jest.restoreAllMocks()
})
it('should fetch user successfully', async () => {
const mockUser = { id: '1', name: 'John' }
// Mock fetch响应
;(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => mockUser,
})
const user = await apiService.fetchUser('1')
expect(fetch).toHaveBeenCalledWith('/api/users/1')
expect(user).toEqual(mockUser)
})
it('should throw error when fetch fails', async () => {
// Mock失败的响应
;(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
})
await expect(apiService.fetchUser('1')).rejects.toThrow(
'Failed to fetch user'
)
})
})React组件测试
简单组件测试
// src/components/Button/Button.tsx
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
variant?: 'primary' | 'secondary'
}
export function Button({
children,
onClick,
disabled = false,
variant = 'primary',
}: ButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`btn btn-${variant}`}
>
{children}
</button>
)
}
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
it('should render button with text', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('should not call onClick when disabled', () => {
const handleClick = jest.fn()
render(
<Button onClick={handleClick} disabled>
Click me
</Button>
)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).not.toHaveBeenCalled()
})
it('should apply variant class', () => {
render(<Button variant="secondary">Click me</Button>)
const button = screen.getByText('Click me')
expect(button).toHaveClass('btn-secondary')
})
})复杂组件测试
// src/components/TodoList/TodoList.tsx
interface Todo {
id: string
title: string
completed: boolean
}
interface TodoListProps {
todos: Todo[]
onToggle: (id: string) => void
onDelete: (id: string) => void
}
export function TodoList({ todos, onToggle, onDelete }: TodoListProps) {
if (todos.length === 0) {
return <p>暂无待办事项</p>
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} data-testid={`todo-${todo.id}`}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Toggle ${todo.title}`}
/>
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.title}
</span>
<button onClick={() => onDelete(todo.id)}>删除</button>
</li>
))}
</ul>
)
}
// src/components/TodoList/TodoList.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { TodoList } from './TodoList'
describe('TodoList', () => {
const mockTodos = [
{ id: '1', title: '学习TDD', completed: false },
{ id: '2', title: '写测试', completed: true },
{ id: '3', title: '重构代码', completed: false },
]
it('should render empty message when no todos', () => {
render(<TodoList todos={[]} onToggle={jest.fn()} onDelete={jest.fn()} />)
expect(screen.getByText('暂无待办事项')).toBeInTheDocument()
})
it('should render all todos', () => {
render(
<TodoList todos={mockTodos} onToggle={jest.fn()} onDelete={jest.fn()} />
)
expect(screen.getByText('学习TDD')).toBeInTheDocument()
expect(screen.getByText('写测试')).toBeInTheDocument()
expect(screen.getByText('重构代码')).toBeInTheDocument()
})
it('should show completed todos with line-through', () => {
render(
<TodoList todos={mockTodos} onToggle={jest.fn()} onDelete={jest.fn()} />
)
const completedTodo = screen.getByText('写测试')
expect(completedTodo).toHaveStyle({ textDecoration: 'line-through' })
})
it('should call onToggle when checkbox is clicked', () => {
const handleToggle = jest.fn()
render(
<TodoList todos={mockTodos} onToggle={handleToggle} onDelete={jest.fn()} />
)
const checkbox = screen.getByLabelText('Toggle 学习TDD')
fireEvent.click(checkbox)
expect(handleToggle).toHaveBeenCalledWith('1')
})
it('should call onDelete when delete button is clicked', () => {
const handleDelete = jest.fn()
render(
<TodoList todos={mockTodos} onToggle={jest.fn()} onDelete={handleDelete} />
)
const deleteButtons = screen.getAllByText('删除')
fireEvent.click(deleteButtons[0])
expect(handleDelete).toHaveBeenCalledWith('1')
})
})异步组件测试
// src/components/UserProfile/UserProfile.tsx
export function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetchUser(userId)
.then(setUser)
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [userId])
if (loading) return <div>加载中...</div>
if (error) return <div>错误: {error}</div>
if (!user) return <div>用户不存在</div>
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}
// src/components/UserProfile/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { UserProfile } from './UserProfile'
import { fetchUser } from '@/services/api'
jest.mock('@/services/api')
describe('UserProfile', () => {
it('should show loading state initially', () => {
;(fetchUser as jest.Mock).mockReturnValue(new Promise(() => {}))
render(<UserProfile userId="1" />)
expect(screen.getByText('加载中...')).toBeInTheDocument()
})
it('should show user data when loaded', async () => {
const mockUser = { name: 'John', email: 'john@example.com' }
;(fetchUser as jest.Mock).mockResolvedValue(mockUser)
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
it('should show error when fetch fails', async () => {
;(fetchUser as jest.Mock).mockRejectedValue(new Error('Network error'))
render(<UserProfile userId="1" />)
await waitFor(() => {
expect(screen.getByText('错误: Network error')).toBeInTheDocument()
})
})
})集成测试
// src/pages/TodoApp.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoApp } from './TodoApp'
describe('TodoApp Integration', () => {
it('should add, toggle and delete todo', async () => {
const user = userEvent.setup()
render(<TodoApp />)
// 添加待办事项
const input = screen.getByPlaceholderText('输入待办事项')
await user.type(input, '学习测试')
await user.click(screen.getByText('添加'))
// 验证添加成功
expect(screen.getByText('学习测试')).toBeInTheDocument()
// 切换完成状态
const checkbox = screen.getByRole('checkbox')
await user.click(checkbox)
await waitFor(() => {
const todoText = screen.getByText('学习测试')
expect(todoText).toHaveStyle({ textDecoration: 'line-through' })
})
// 删除待办事项
await user.click(screen.getByText('删除'))
await waitFor(() => {
expect(screen.queryByText('学习测试')).not.toBeInTheDocument()
})
})
})E2E测试
// e2e/todo-app.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Todo App E2E', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:3000')
})
test('should complete full todo workflow', async ({ page }) => {
// 添加待办事项
await page.fill('[placeholder="输入待办事项"]', '学习E2E测试')
await page.click('text=添加')
// 验证添加成功
await expect(page.locator('text=学习E2E测试')).toBeVisible()
// 标记为完成
await page.click('[aria-label="Toggle 学习E2E测试"]')
// 验证样式变化
const todoText = page.locator('text=学习E2E测试')
await expect(todoText).toHaveCSS('text-decoration', 'line-through')
// 删除待办事项
await page.click('text=删除')
// 验证删除成功
await expect(page.locator('text=学习E2E测试')).not.toBeVisible()
})
test('should persist todos after page refresh', async ({ page }) => {
// 添加待办事项
await page.fill('[placeholder="输入待办事项"]', '持久化测试')
await page.click('text=添加')
// 刷新页面
await page.reload()
// 验证数据仍然存在
await expect(page.locator('text=持久化测试')).toBeVisible()
})
})测试覆盖率管理
// package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"collectCoverageFrom": [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/**/*.stories.tsx",
"!src/index.tsx"
],
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}CI/CD集成
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v2
with:
files: ./coverage/lcov.info
- name: Run E2E tests
run: |
npm run build
npm run start &
npx playwright test最佳实践
1. 测试金字塔
// ✅ 测试金字塔:70% 单元测试,20% 集成测试,10% E2E测试
const testPyramid = {
e2e: '10% - 关键用户流程',
integration: '20% - 组件协作',
unit: '70% - 纯函数、工具类',
}2. 测试命名规范
// ✅ 好的测试名称
it('should return true when user is authenticated', () => {})
it('should throw error when email is invalid', () => {})
it('should display error message when form submission fails', () => {})
// ❌ 差的测试名称
it('test 1', () => {})
it('works', () => {})
it('error', () => {})3. AAA模式
// Arrange-Act-Assert模式
it('should calculate total price', () => {
// Arrange - 准备
const cart = new Cart()
cart.addItem({ price: 10, quantity: 2 })
cart.addItem({ price: 5, quantity: 3 })
// Act - 执行
const total = cart.getTotal()
// Assert - 断言
expect(total).toBe(35)
})总结
TDD不仅是一种测试方法,更是一种开发思维方式。通过先写测试、后写代码的方式,能够让我们:
- 思考设计:先思考API如何使用
- 保证质量:测试驱动,减少bug
- 重构信心:有测试保护,大胆重构
- 活文档:测试即文档,永不过时
记住:测试不是负担,而是开发的加速器。投入在测试上的时间,会以更少的bug和更快的迭代速度回报给你。