React Testing Library 完整指南
使用 React Testing Library 进行 React 组件测试的完整指南
目录
快速开始
安装
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event基础配置
// setupTests.js
import '@testing-library/jest-dom'核心原则
测试用户行为,而非实现细节
❌ 不好的测试:
// 测试实现细节
expect(component.state.count).toBe(1)✅ 好的测试:
// 测试用户看到的内容
expect(screen.getByText('Count: 1')).toBeInTheDocument()查询优先级
- getByRole - 最推荐,语义化查询
- getByLabelText - 表单元素
- getByPlaceholderText - 输入框
- getByText - 文本内容
- getByDisplayValue - 表单值
- getByAltText - 图片
- getByTitle - title 属性
- getByTestId - 最后选择
基础测试
组件渲染测试
import { render, screen } from '@testing-library/react'
import Button from './Button'
test('renders button with text', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
})条件渲染测试
function Greeting({ isLoggedIn }) {
return isLoggedIn ? <p>Welcome back!</p> : <p>Please log in</p>
}
test('shows welcome message when logged in', () => {
render(<Greeting isLoggedIn={true} />)
expect(screen.getByText('Welcome back!')).toBeInTheDocument()
expect(screen.queryByText('Please log in')).not.toBeInTheDocument()
})
test('shows login message when not logged in', () => {
render(<Greeting isLoggedIn={false} />)
expect(screen.getByText('Please log in')).toBeInTheDocument()
expect(screen.queryByText('Welcome back!')).not.toBeInTheDocument()
})用户交互测试
使用 user-event
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Counter from './Counter'
test('increments counter on button click', async () => {
const user = userEvent.setup()
render(<Counter />)
const button = screen.getByRole('button', { name: /increment/i })
await user.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})表单输入测试
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import LoginForm from './LoginForm'
test('submits form with user input', async () => {
const user = userEvent.setup()
const handleSubmit = jest.fn()
render(<LoginForm onSubmit={handleSubmit} />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /submit/i })
await user.type(emailInput, 'user@example.com')
await user.type(passwordInput, 'password123')
await user.click(submitButton)
expect(handleSubmit).toHaveBeenCalledWith({
email: 'user@example.com',
password: 'password123',
})
})异步操作测试
等待元素出现
import { render, screen, waitFor } from '@testing-library/react'
import { fetchUser } from './api'
import UserProfile from './UserProfile'
test('displays user data after loading', async () => {
render(<UserProfile userId="123" />)
// 等待加载完成
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})使用 findBy 查询
test('displays user data', async () => {
render(<UserProfile userId="123" />)
// findBy 自动等待
const userName = await screen.findByText('John Doe')
expect(userName).toBeInTheDocument()
})Mock API 调用
import { render, screen, waitFor } from '@testing-library/react'
import { fetchUser } from './api'
import UserProfile from './UserProfile'
jest.mock('./api')
test('displays user data', async () => {
fetchUser.mockResolvedValue({ name: 'John Doe', email: 'john@example.com' })
render(<UserProfile userId="123" />)
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument()
})
expect(fetchUser).toHaveBeenCalledWith('123')
})Hooks 测试
使用 renderHook
import { renderHook, act } from '@testing-library/react'
import { useState } from 'react'
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue)
const increment = () => setCount(c => c + 1)
const decrement = () => setCount(c => c - 1)
return { count, increment, decrement }
}
test('increments counter', () => {
const { result } = renderHook(() => useCounter(0))
expect(result.current.count).toBe(0)
act(() => {
result.current.increment()
})
expect(result.current.count).toBe(1)
})测试 useEffect
import { renderHook } from '@testing-library/react'
import { useEffect, useState } from 'react'
function useDocumentTitle(title) {
useEffect(() => {
document.title = title
}, [title])
}
test('updates document title', () => {
renderHook(() => useDocumentTitle('New Title'))
expect(document.title).toBe('New Title')
})Context 测试
测试 Context Provider
import { render, screen } from '@testing-library/react'
import { createContext, useContext } from 'react'
const ThemeContext = createContext()
function ThemeProvider({ children }) {
return (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
)
}
function ThemedButton() {
const theme = useContext(ThemeContext)
return <button>Theme: {theme}</button>
}
test('uses theme from context', () => {
render(
<ThemeProvider>
<ThemedButton />
</ThemeProvider>
)
expect(screen.getByText('Theme: dark')).toBeInTheDocument()
})路由测试
测试 React Router
import { render, screen } from '@testing-library/react'
import { BrowserRouter } from 'react-router-dom'
import { Routes, Route, Link } from 'react-router-dom'
function App() {
return (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
)
}
function Home() {
return (
<div>
<h1>Home</h1>
<Link to="/about">About</Link>
</div>
)
}
test('renders home page', () => {
render(
<BrowserRouter>
<App />
</BrowserRouter>
)
expect(screen.getByText('Home')).toBeInTheDocument()
})Mock 和 Stub
Mock 函数
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>
}
test('calls onClick when clicked', async () => {
const handleClick = jest.fn()
const user = userEvent.setup()
render(<Button onClick={handleClick}>Click me</Button>)
await user.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})Mock 模块
// api.js
export const fetchData = async () => {
const response = await fetch('/api/data')
return response.json()
}
// Component.test.js
import { fetchData } from './api'
jest.mock('./api')
test('displays data', async () => {
fetchData.mockResolvedValue({ name: 'Test' })
render(<Component />)
await waitFor(() => {
expect(screen.getByText('Test')).toBeInTheDocument()
})
})最佳实践
1. 使用语义化查询
// ✅ 好
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText('Email')
// ❌ 不好
screen.getByTestId('submit-button')
screen.getByClassName('email-input')2. 测试用户行为
// ✅ 测试用户看到和交互的内容
test('user can submit form', async () => {
// ...
})
// ❌ 不要测试实现细节
test('component state updates', () => {
// ...
})3. 使用 data-testid 作为最后选择
// 只有在没有其他查询方式时才使用
<div data-testid="complex-component">
{/* 复杂组件 */}
</div>
screen.getByTestId('complex-component')4. 清理测试
import { cleanup } from '@testing-library/react'
afterEach(() => {
cleanup()
})5. 测试可访问性
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('has no accessibility violations', async () => {
const { container } = render(<Component />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})总结
React Testing Library 的核心思想:
- ✅ 测试用户行为:关注用户看到和交互的内容
- ✅ 语义化查询:使用
getByRole、getByLabelText等 - ✅ 异步处理:使用
waitFor和findBy处理异步操作 - ✅ 用户事件:使用
user-event模拟真实用户交互 - ✅ 可访问性:测试可访问性,提升应用质量
最后更新:2025-12-12