前端测试驱动开发(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不仅是一种测试方法,更是一种开发思维方式。通过先写测试、后写代码的方式,能够让我们:

  1. 思考设计:先思考API如何使用
  2. 保证质量:测试驱动,减少bug
  3. 重构信心:有测试保护,大胆重构
  4. 活文档:测试即文档,永不过时

记住:测试不是负担,而是开发的加速器。投入在测试上的时间,会以更少的bug和更快的迭代速度回报给你。