React 面试题详细答案 - 第 36 题
36. 单元测试组件?(Jest, React Testing Library)
单元测试概述
单元测试是软件开发中的重要实践,对于 React 组件测试,主要目标是:
- 验证组件行为是否符合预期
- 确保组件在不同 props 下的正确渲染
- 测试用户交互和事件处理
- 保证代码重构时的安全性
Jest 基础
1. Jest 配置
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
moduleNameMapping: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
},
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/reportWebVitals.js',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
}
2. 基础测试语法
// Button.test.js
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'
describe('Button Component', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
})
test('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
test('applies custom className', () => {
render(<Button className="custom-class">Click me</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
})
React Testing Library 详解
1. 查询方法
// 优先级:可访问性 > 语义化 > 测试 ID
import { render, screen } from '@testing-library/react'
// 1. 可访问性查询(推荐)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/username/i)
screen.getByText(/welcome/i)
screen.getByPlaceholderText(/enter email/i)
// 2. 语义化查询
screen.getByAltText(/profile picture/i)
screen.getByTitle(/close dialog/i)
screen.getByDisplayValue(/john@example.com/i)
// 3. 测试 ID(最后选择)
screen.getByTestId('submit-button')
// 查询多个元素
screen.getAllByRole('listitem')
screen.queryAllByText(/error/i) // 不抛出异常
2. 用户交互测试
import { render, screen, fireEvent, userEvent } from '@testing-library/react'
test('form submission', async () => {
const user = userEvent.setup()
const handleSubmit = jest.fn()
render(<LoginForm onSubmit={handleSubmit} />)
// 使用 userEvent 模拟真实用户行为
await user.type(screen.getByLabelText(/username/i), 'john')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /login/i }))
expect(handleSubmit).toHaveBeenCalledWith({
username: 'john',
password: 'password123',
})
})
test('keyboard navigation', async () => {
const user = userEvent.setup()
render(<TodoList />)
const input = screen.getByRole('textbox')
await user.type(input, 'New todo')
await user.keyboard('{Enter}')
expect(screen.getByText('New todo')).toBeInTheDocument()
})
3. 异步操作测试
import { render, screen, waitFor } from '@testing-library/react'
test('loads user data', async () => {
const mockFetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({ name: 'John', email: 'john@example.com' }),
})
global.fetch = mockFetch
render(<UserProfile userId="123" />)
// 等待异步操作完成
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument()
})
expect(mockFetch).toHaveBeenCalledWith('/api/users/123')
})
test('handles loading and error states', async () => {
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'))
global.fetch = mockFetch
render(<UserProfile userId="123" />)
// 测试加载状态
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// 测试错误状态
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument()
})
})
高级测试技巧
1. Mock 和 Stub
// Mock 外部依赖
jest.mock('../api/userService', () => ({
fetchUser: jest.fn(),
updateUser: jest.fn(),
}))
// Mock React Router
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => jest.fn(),
useParams: () => ({ id: '123' }),
}))
// Mock 自定义 Hook
jest.mock('../hooks/useAuth', () => ({
useAuth: () => ({
user: { id: '1', name: 'John' },
login: jest.fn(),
logout: jest.fn(),
}),
}))
test('component with mocked dependencies', () => {
render(<ProtectedComponent />)
expect(screen.getByText('Welcome, John')).toBeInTheDocument()
})
2. 自定义渲染函数
// test-utils.js
import { render } from '@testing-library/react'
import { ThemeProvider } from 'styled-components'
import { BrowserRouter } from 'react-router-dom'
const AllTheProviders = ({ children }) => {
return (
<BrowserRouter>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</BrowserRouter>
)
}
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options })
export * from '@testing-library/react'
export { customRender as render }
// 在测试中使用
import { render, screen } from '../test-utils'
test('renders with providers', () => {
render(<MyComponent />)
// 组件会自动包装在 providers 中
})
3. 快照测试
import { render } from '@testing-library/react'
import Button from './Button'
test('button snapshot', () => {
const { container } = render(<Button>Click me</Button>)
expect(container.firstChild).toMatchSnapshot()
})
// 更新快照:npm test -- --updateSnapshot
测试最佳实践
1. 测试结构
describe('UserProfile Component', () => {
// 每个测试前的设置
beforeEach(() => {
jest.clearAllMocks()
})
describe('when user is loading', () => {
test('shows loading spinner', () => {
render(<UserProfile loading={true} />)
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument()
})
})
describe('when user data is loaded', () => {
test('displays user information', () => {
const user = { name: 'John', email: 'john@example.com' }
render(<UserProfile user={user} />)
expect(screen.getByText('John')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
})
})
describe('when user clicks edit button', () => {
test('opens edit form', async () => {
const user = userEvent.setup()
render(<UserProfile user={{ name: 'John' }} />)
await user.click(screen.getByRole('button', { name: /edit/i }))
expect(screen.getByRole('form')).toBeInTheDocument()
})
})
})
2. 可访问性测试
import { render } from '@testing-library/react'
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
test('should not have accessibility violations', async () => {
const { container } = render(<MyComponent />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
// 测试键盘导航
test('supports keyboard navigation', async () => {
const user = userEvent.setup()
render(<Menu />)
const firstItem = screen.getByRole('menuitem', { name: /first item/i })
firstItem.focus()
await user.keyboard('{ArrowDown}')
expect(screen.getByRole('menuitem', { name: /second item/i })).toHaveFocus()
})
3. 性能测试
import { render } from '@testing-library/react'
import { act } from 'react-dom/test-utils'
test('renders large list efficiently', () => {
const items = Array.from({ length: 1000 }, (_, i) => ({
id: i,
name: `Item ${i}`,
}))
const startTime = performance.now()
render(<VirtualizedList items={items} />)
const endTime = performance.now()
// 确保渲染时间在合理范围内
expect(endTime - startTime).toBeLessThan(100)
})
测试覆盖率
1. 覆盖率配置
// jest.config.js
module.exports = {
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/index.js',
'!src/**/*.stories.js',
'!src/**/*.test.js',
],
coverageReporters: ['text', 'lcov', 'html'],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
'./src/components/': {
branches: 90,
functions: 90,
lines: 90,
statements: 90,
},
},
}
2. 覆盖率报告
# 生成覆盖率报告
npm test -- --coverage
# 只运行失败的测试
npm test -- --onlyFailures
# 监视模式
npm test -- --watch
总结
React 组件测试的核心原则:
- 测试用户行为:关注用户如何使用组件,而不是实现细节
- 可访问性优先:使用可访问性查询方法
- 真实用户交互:使用 userEvent 而不是 fireEvent
- 异步操作处理:正确使用 waitFor 和 async/await
- Mock 外部依赖:隔离组件逻辑
- 测试覆盖率:确保关键逻辑被测试覆盖
通过 Jest 和 React Testing Library 的组合使用,可以构建健壮、可维护的测试套件,确保 React 应用的质量和稳定性。