测试React组件 #
一、测试概述 #
1.1 测试类型 #
| 类型 | 说明 |
|---|---|
| 单元测试 | 测试独立函数或组件 |
| 集成测试 | 测试组件间交互 |
| E2E测试 | 测试完整用户流程 |
1.2 测试工具 #
| 工具 | 用途 |
|---|---|
| Jest | 测试运行器 |
| React Testing Library | React组件测试 |
| Vitest | Vite生态测试工具 |
1.3 安装 #
bash
# Jest
npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
# Vitest
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
二、配置测试环境 #
2.1 Jest配置 #
javascript
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['@babel/preset-env', '@babel/preset-react'] }]
}
};
// jest.setup.js
import '@testing-library/jest-dom';
2.2 Vitest配置 #
javascript
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/setupTests.js',
globals: true
}
});
// setupTests.js
import '@testing-library/jest-dom';
三、基础测试 #
3.1 渲染测试 #
javascript
import { render, screen } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('renders with variant class', () => {
render(<Button variant="primary">Primary</Button>);
const button = screen.getByRole('button');
expect(button).toHaveClass('btn-primary');
});
test('renders disabled button', () => {
render(<Button disabled>Disabled</Button>);
const button = screen.getByRole('button');
expect(button).toBeDisabled();
});
});
3.2 交互测试 #
javascript
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter', () => {
test('increments count when button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole('button', { name: /increment/i });
await user.click(button);
await user.click(button);
expect(screen.getByText('2')).toBeInTheDocument();
});
test('calls onClick handler', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});
3.3 表单测试 #
javascript
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
describe('LoginForm', () => {
test('submits form with user credentials', async () => {
const handleSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123'
});
});
test('shows validation error for empty fields', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
});
四、查询方法 #
4.1 查询类型 #
| 类型 | 说明 | 失败时 |
|---|---|---|
| getBy | 获取元素 | 抛出错误 |
| queryBy | 获取元素 | 返回null |
| findBy | 异步获取 | 抛出错误 |
4.2 常用查询 #
javascript
// 按角色查询(推荐)
screen.getByRole('button');
screen.getByRole('textbox', { name: /email/i });
screen.getByRole('heading', { level: 1 });
// 按文本查询
screen.getByText('Submit');
screen.getByText(/submit/i);
// 按标签查询
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter email');
// 按测试ID查询
screen.getByTestId('submit-button');
// 按显示值查询
screen.getByDisplayValue('Hello');
4.3 查询优先级 #
text
1. getByRole - 最推荐,反映可访问性
2. getByLabelText - 表单元素
3. getByPlaceholderText
4. getByText - 非表单元素
5. getByDisplayValue
6. getByAltText - 图片
7. getByTitle
8. getByTestId - 最后选择
五、异步测试 #
5.1 等待元素 #
javascript
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
describe('UserList', () => {
test('displays users after loading', async () => {
render(<UserList />);
// 等待加载完成
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// 等待数据加载
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
// 或使用findBy
expect(await screen.findByText('Alice')).toBeInTheDocument();
});
});
5.2 模拟API #
javascript
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserList from './UserList';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' }
])
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList', () => {
test('fetches and displays users', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
test('handles API error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
六、Mock和Spy #
6.1 函数Mock #
javascript
test('calls callback with correct arguments', () => {
const mockCallback = jest.fn();
render(<Button onClick={mockCallback}>Click</Button>);
fireEvent.click(screen.getByRole('button'));
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledWith({ id: 1 });
});
test('mock return value', () => {
const mockFn = jest.fn();
mockFn.mockReturnValue('mocked value');
expect(mockFn()).toBe('mocked value');
});
test('mock implementation', () => {
const mockFn = jest.fn((x) => x * 2);
expect(mockFn(5)).toBe(10);
});
6.2 模块Mock #
javascript
// 模拟整个模块
jest.mock('../api/users', () => ({
fetchUsers: jest.fn(() => Promise.resolve([{ id: 1, name: 'Alice' }]))
}));
// 模拟部分模块
jest.mock('../api/users', () => ({
...jest.requireActual('../api/users'),
fetchUsers: jest.fn()
}));
6.3 Timer Mock #
javascript
describe('Timer', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('calls callback after delay', () => {
const callback = jest.fn();
render(<Timer onTimeout={callback} delay={1000} />);
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();
});
});
七、Context测试 #
7.1 测试Provider #
javascript
import { render, screen } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeContext';
function TestComponent() {
const { theme } = useTheme();
return <div data-testid="theme">{theme}</div>;
}
describe('ThemeProvider', () => {
test('provides theme value', () => {
render(
<ThemeProvider initialTheme="dark">
<TestComponent />
</ThemeProvider>
);
expect(screen.getByTestId('theme')).toHaveTextContent('dark');
});
});
7.2 测试Consumer #
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeContext } from './ThemeContext';
import ThemeToggle from './ThemeToggle';
describe('ThemeToggle', () => {
test('toggles theme', async () => {
const mockToggle = jest.fn();
const user = userEvent.setup();
render(
<ThemeContext.Provider value={{ theme: 'light', toggleTheme: mockToggle }}>
<ThemeToggle />
</ThemeContext.Provider>
);
await user.click(screen.getByRole('button'));
expect(mockToggle).toHaveBeenCalled();
});
});
八、快照测试 #
8.1 基本快照 #
javascript
import { render } from '@testing-library/react';
import Button from './Button';
describe('Button', () => {
test('matches snapshot', () => {
const { asFragment } = render(<Button>Click me</Button>);
expect(asFragment()).toMatchSnapshot();
});
test('matches inline snapshot', () => {
const { asFragment } = render(<Button>Click me</Button>);
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<button class="btn btn-primary">
Click me
</button>
</DocumentFragment>
`);
});
});
九、测试最佳实践 #
9.1 测试用户行为 #
javascript
// ❌ 测试实现细节
test('sets state on click', () => {
const { result } = renderHook(() => useState(false));
act(() => result.current[1](true));
expect(result.current[0]).toBe(true);
});
// ✅ 测试用户可见行为
test('toggles menu visibility', async () => {
const user = userEvent.setup();
render(<Menu />);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /menu/i }));
expect(screen.getByRole('menu')).toBeInTheDocument();
});
9.2 避免实现细节 #
javascript
// ❌ 测试组件状态
test('component state', () => {
const wrapper = mount(<Counter />);
expect(wrapper.state('count')).toBe(0);
});
// ✅ 测试渲染结果
test('displays initial count', () => {
render(<Counter />);
expect(screen.getByText('0')).toBeInTheDocument();
});
9.3 使用可访问性查询 #
javascript
// ❌ 使用测试ID
screen.getByTestId('submit-button');
// ✅ 使用角色查询
screen.getByRole('button', { name: /submit/i });
十、总结 #
| 要点 | 说明 |
|---|---|
| 查询优先级 | role > text > testId |
| 用户行为 | 测试用户可见的行为 |
| 异步测试 | 使用waitFor和findBy |
| Mock | 合理使用mock隔离依赖 |
核心原则:
- 测试用户行为,而非实现细节
- 使用可访问性查询
- 合理使用Mock
- 保持测试简单明了
最后更新:2026-03-26