React单元测试
下面,我们来系统的梳理关于 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: Teston: [push, pull_request]jobs:test:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v2- uses: actions/setup-node@v2with:node-version: '16'- run: npm ci- run: npm test -- --coverage- name: Upload coverageuses: 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流程