下面,我们来系统的梳理关于 React 单元测试:Jest + React Testing Library 的基本知识点:
一、单元测试概述
1.1 为什么需要单元测试?
- 保证代码质量:提前发现并修复缺陷
- 提高可维护性:测试即文档,帮助理解代码功能
- 支持重构:确保重构不破坏现有功能
- 减少回归错误:防止新代码破坏已有功能
1.2 React 测试金字塔
- 单元测试:测试独立组件或函数(本指南重点)
- 集成测试:测试多个组件的交互
- 端到端测试:模拟用户操作测试完整流程
1.3 测试原则 (FIRST)
- Fast(快速):测试应快速执行
- Isolated(独立):测试之间互不影响
- Repeatable(可重复):在任何环境结果一致
- Self-validating(自验证):测试结果明确(通过/失败)
- Timely(及时):测试与代码同步编写
二、测试环境搭建
2.1 安装依赖
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
2.2 配置文件
jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
},
};
jest.setup.js
import '@testing-library/jest-dom/extend-expect';
2.3 脚本配置
package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
三、Jest 基础
3.1 测试结构
describe('测试套件描述', () => {
test('测试用例描述', () => {
// 准备 (Arrange)
const a = 1;
const b = 2;
// 执行 (Act)
const result = a + b;
// 断言 (Assert)
expect(result).toBe(3);
});
});
3.2 常用匹配器
匹配器 | 用途 | 示例 |
---|---|---|
toBe() |
严格相等 | expect(1).toBe(1) |
toEqual() |
深度相等 | expect({a:1}).toEqual({a:1}) |
toBeTruthy() |
真值检查 | expect(true).toBeTruthy() |
toBeFalsy() |
假值检查 | expect(false).toBeFalsy() |
toContain() |
包含检查 | expect(['a','b']).toContain('a') |
toHaveLength() |
长度检查 | expect('abc').toHaveLength(3) |
toThrow() |
错误检查 | expect(() => {throw Error()}).toThrow() |
3.3 模拟函数
const mockFn = jest.fn();
// 基础用法
mockFn('arg1', 'arg2');
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
// 返回值模拟
mockFn.mockReturnValue(42);
expect(mockFn()).toBe(42);
// 实现模拟
mockFn.mockImplementation((a, b) => a + b);
expect(mockFn(1, 2)).toBe(3);
四、React Testing Library 核心
4.1 核心原则
- 测试用户行为:而不是实现细节
- 查询方式:按用户查找元素(文本、标签等)
- 无障碍优先:使用与用户相同的访问方式
4.2 渲染组件
import { render } from '@testing-library/react';
const { container, getByText } = render(<MyComponent />);
4.3 查询方法
查询类型 | 方法 | 说明 |
---|---|---|
getBy | getByText |
获取匹配元素(不存在时报错) |
getByRole |
通过ARIA角色查询 | |
getByLabelText |
通过标签文本查询 | |
queryBy | queryBy... |
同上,但元素不存在时返回null |
findBy | findBy... |
异步查询,返回Promise |
getAllBy | getAllBy... |
查询多个元素 |
queryAllBy | queryAllBy... |
查询多个元素,不存在时返回空数组 |
findAllBy | findAllBy... |
异步查询多个元素 |
4.4 用户交互
import userEvent from '@testing-library/user-event';
// 模拟点击
userEvent.click(buttonElement);
// 模拟输入
userEvent.type(inputElement, 'Hello World');
// 模拟键盘操作
userEvent.keyboard('{Enter}');
4.5 常用断言扩展
import '@testing-library/jest-dom';
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toHaveClass('active');
expect(input).toHaveValue('test');
expect(button).toBeDisabled();
五、组件测试实战
5.1 基础组件测试
// Button.jsx
export default function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
test('渲染按钮并响应点击', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click Me</Button>);
const button = screen.getByRole('button', { name: 'Click Me' });
expect(button).toBeInTheDocument();
userEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
5.2 表单组件测试
// LoginForm.jsx
export default function LoginForm({ onSubmit }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
onSubmit({ username, password });
};
return (
<form onSubmit={handleSubmit}>
<label>
Username:
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label>
Password:
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit">Login</button>
</form>
);
}
// LoginForm.test.jsx
test('提交表单数据', async () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
const usernameInput = screen.getByLabelText('Username:');
const passwordInput = screen.getByLabelText('Password:');
const submitButton = screen.getByRole('button', { name: 'Login' });
// 输入数据
userEvent.type(usernameInput, 'testuser');
userEvent.type(passwordInput, 'password123');
// 提交表单
userEvent.click(submitButton);
// 验证提交数据
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'password123'
});
});
5.3 路由组件测试
// 安装依赖:npm install react-router-dom
import { BrowserRouter as Router } from 'react-router-dom';
test('显示导航链接', () => {
render(
<Router>
<Navbar />
</Router>
);
const homeLink = screen.getByRole('link', { name: 'Home' });
expect(homeLink).toHaveAttribute('href', '/');
});
六、异步测试
6.1 数据获取组件
// UserList.jsx
export default function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchUsers = async () => {
setLoading(true);
const response = await fetch('/api/users');
const data = await response.json();
setUsers(data);
setLoading(false);
};
fetchUsers();
}, []);
if (loading) return <div>Loading...</div>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
// 模拟fetch API
global.fetch = jest.fn();
test('加载并显示用户列表', async () => {
const mockUsers = [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
];
fetch.mockResolvedValueOnce({
json: async () => mockUsers,
});
render(<UserList />);
// 验证加载状态
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 等待数据加载完成
const listItems = await screen.findAllByRole('listitem');
// 验证用户列表
expect(listItems).toHaveLength(2);
expect(screen.getByText('User 1')).toBeInTheDocument();
expect(screen.getByText('User 2')).toBeInTheDocument();
});
6.2 使用 Mock Service Worker (MSW)
npm install msw --save-dev
// src/mocks/server.js
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'Mock User 1' },
{ id: 2, name: 'Mock User 2' }
])
);
})
);
export default server;
// jest.setup.js
import server from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// UserList.test.jsx
test('使用MSW加载用户列表', async () => {
render(<UserList />);
const listItems = await screen.findAllByRole('listitem');
expect(listItems).toHaveLength(2);
});
七、高级测试技巧
7.1 Context 测试
// ThemeContext.jsx
import { createContext, useContext } from 'react';
const ThemeContext = createContext('light');
export function ThemeProvider({ children, theme }) {
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
export function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={`btn-${theme}`}>Themed Button</button>;
}
// ThemedButton.test.jsx
test('使用深色主题渲染按钮', () => {
render(
<ThemeProvider theme="dark">
<ThemedButton />
</ThemeProvider>
);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-dark');
});
7.2 Redux 测试
// 安装:npm install @reduxjs/toolkit react-redux
import { render } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import userReducer from './userSlice';
import UserProfile from './UserProfile';
test('显示用户信息', () => {
const store = configureStore({
reducer: {
user: userReducer,
},
preloadedState: {
user: { name: 'Test User', email: 'test@example.com' }
}
});
render(
<Provider store={store}>
<UserProfile />
</Provider>
);
expect(screen.getByText('Test User')).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
});
7.3 自定义 Hook 测试
// useCounter.js
import { useState, useCallback } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = useCallback(() => setCount(c => c + 1), []);
const decrement = useCallback(() => setCount(c => c - 1), []);
return { count, increment, decrement };
}
// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';
test('计数器hook正常工作', () => {
const { result } = renderHook(() => useCounter(5));
// 初始值
expect(result.current.count).toBe(5);
// 增加
act(() => result.current.increment());
expect(result.current.count).toBe(6);
// 减少
act(() => result.current.decrement());
expect(result.current.count).toBe(5);
});
八、最佳实践
8.1 测试编写原则
- 测试用户行为:而不是组件内部实现
- 避免测试实现细节:
- ❌ 不要测试组件内部状态
- ❌ 不要测试组件生命周期方法
- ✅ 测试用户可见的输出和行为
- 使用合适的查询方法:
- 优先使用
getByRole
- 其次使用
getByLabelText
、getByText
- 避免使用
container.querySelector
- 优先使用
- 保持测试简单:每个测试只验证一个行为
8.2 测试组织策略
src/
├── components/
│ ├── Button/
│ │ ├── Button.jsx
│ │ ├── Button.test.jsx
│ │ └── index.js
│ └── ...
└── hooks/
├── useCounter.js
└── useCounter.test.js
8.3 可访问性测试
import { axe } from 'jest-axe';
test('组件应满足无障碍要求', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
8.4 快照测试(谨慎使用)
test('组件渲染快照', () => {
const { asFragment } = render(<MyComponent />);
expect(asFragment()).toMatchSnapshot();
});
适用场景:
- 配置型组件
- 静态内容组件
- 不经常变更的UI
注意事项:
- 避免过度使用
- 不要替代功能测试
- 审查每次快照变更
九、测试覆盖率与持续集成
9.1 生成覆盖率报告
npm test -- --coverage
配置 jest.config.js:
module.exports = {
collectCoverage: true,
coverageReporters: ['html', 'text'],
collectCoverageFrom: [
'src/**/*.{js,jsx}',
'!src/**/*.test.{js,jsx}',
'!src/index.js',
'!src/serviceWorker.js',
],
};
9.2 持续集成(GitHub Actions)
.github/workflows/test.yml:
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: '16'
- run: npm ci
- run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v2
十、常见问题与解决方案
10.1 测试失败:"act" warning
问题:测试中看到 “An update to Component inside a test was not wrapped in act(…)” 警告
解决方案:
// 使用 waitFor 处理异步更新
await waitFor(() => {
expect(screen.getByText('Loaded data')).toBeInTheDocument();
});
// 或者使用 findBy 查询
const dataElement = await screen.findByText('Loaded data');
10.2 模拟模块依赖
// 模拟第三方模块
jest.mock('axios', () => ({
get: jest.fn().mockResolvedValue({ data: 'mocked data' }),
}));
// 模拟本地模块
jest.mock('../utils/api', () => ({
fetchData: jest.fn().mockResolvedValue('mock data'),
}));
10.3 测试使用 useEffect
的组件
test('正确使用 useEffect', async () => {
const { rerender } = render(<TimerComponent interval={1000} />);
// 初始状态
expect(screen.getByText('Count: 0')).toBeInTheDocument();
// 等待效果执行
await waitFor(() => {
expect(screen.getByText('Count: 1')).toBeInTheDocument();
}, { timeout: 1500 });
// 更新 props 重新渲染
rerender(<TimerComponent interval={500} />);
// 验证新间隔生效
await waitFor(() => {
expect(screen.getByText('Count: 2')).toBeInTheDocument();
}, { timeout: 1000 });
});
十一、总结
核心要点
- 测试用户行为:而不是实现细节
- 使用合适的查询方法:优先按角色和文本查询
- 模拟真实环境:使用MSW模拟API请求
- 保持测试简单:每个测试只验证一个功能
- 自动化测试流程:集成到CI/CD流程