React 测试深入实践
大约 7 分钟约 1958 字
React 测试深入实践
React 测试方法论
React 测试的核心原则是:测试用户行为,而非实现细节。这意味着我们应该关注用户看到什么、能做什么,而不是组件内部用了什么 state、调用了什么私有方法。这种测试方式被称为「行为驱动测试」,它的优势在于组件重构后测试依然有效,因为用户行为不变。
为什么不测试实现细节?
// Bad — 测试实现细节,重构后测试会失败
it('内部状态正确', () => {
render(<LoginForm />);
// 依赖内部 state 名称,如果重命名 count 为 clickCount 测试就失败了
expect(component.state('count')).toBe(0);
});
// Good — 测试用户行为,重构后测试仍然有效
it('点击增加按钮后计数显示为 1', () => {
render(<Counter />);
fireEvent.click(screen.getByRole('button', { name: /增加/i }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
});Testing Library 核心概念
查询优先级
Testing Library 提供多种查询方式,按优先级排列:
| 优先级 | 查询方式 | 说明 |
|---|---|---|
| 1(首选) | getByRole | 按 ARIA 角色查询 |
| 2 | getByLabelText | 按表单标签查询 |
| 3 | getByPlaceholderText | 按占位符查询 |
| 4 | getByText | 按文本内容查询 |
| 5 | getByDisplayValue | 按表单当前值查询 |
| 6 | getByTestId | 按 data-testid 查询(最后手段) |
// 优先级从高到低
const button = screen.getByRole('button', { name: '提交' }); // 最佳
const input = screen.getByLabelText('用户名'); // 很好
const placeholder = screen.getByPlaceholderText('请输入密码'); // 可以
const text = screen.getByText('欢迎回来'); // 可以
const testId = screen.getByTestId('submit-btn'); // 最后手段getBy vs queryBy vs findBy
// getBy* — 找不到立即报错(同步)
screen.getByText('加载中'); // 元素存在 → 返回元素
// screen.getByText('不存在'); // 元素不存在 → 抛出异常
// queryBy* — 找不到返回 null(同步)
const error = screen.queryByText('错误信息');
expect(error).not.toBeInTheDocument(); // 验证元素不存在
// findBy* — 等待元素出现(异步)
const data = await screen.findByText('张三'); // 等待直到出现或超时组件测试实战
表单组件测试
// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
// 公共的渲染函数,避免重复代码
const renderLoginForm = (props = {}) => {
const onSubmit = vi.fn();
const result = render(<LoginForm onSubmit={onSubmit} {...props} />);
return { ...result, onSubmit };
};
it('正常提交表单', async () => {
const user = userEvent.setup();
const { onSubmit } = renderLoginForm();
// 填写表单
await user.type(screen.getByLabelText('用户名'), 'admin');
await user.type(screen.getByLabelText('密码'), '123456');
// 提交
await user.click(screen.getByRole('button', { name: '登录' }));
// 验证调用
expect(onSubmit).toHaveBeenCalledWith({
username: 'admin',
password: '123456',
});
expect(onSubmit).toHaveBeenCalledTimes(1);
});
it('空用户名显示验证错误', async () => {
const user = userEvent.setup();
renderLoginForm();
// 直接点击提交
await user.click(screen.getByRole('button', { name: '登录' }));
// 验证错误信息
expect(await screen.findByText('请输入用户名')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('密码太短显示验证错误', async () => {
const user = userEvent.setup();
renderLoginForm();
await user.type(screen.getByLabelText('用户名'), 'admin');
await user.type(screen.getByLabelText('密码'), '12'); // 太短
await user.click(screen.getByRole('button', { name: '登录' }));
expect(await screen.findByText('密码至少6个字符')).toBeInTheDocument();
});
it('登录中禁用提交按钮', async () => {
const user = userEvent.setup();
const loadingOnSubmit = vi.fn(() => new Promise(() => {})); // 永远不 resolve
renderLoginForm({ onSubmit: loadingOnSubmit });
await user.type(screen.getByLabelText('用户名'), 'admin');
await user.type(screen.getByLabelText('密码'), '123456');
await user.click(screen.getByRole('button', { name: '登录' }));
// 按钮应禁用
expect(screen.getByRole('button', { name: /加载中/i })).toBeDisabled();
});
});异步数据组件测试
// UserList.test.tsx
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserList } from './UserList';
describe('UserList', () => {
beforeEach(() => {
// 默认 Mock
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{ id: 1, name: '张三', email: 'zhangsan@example.com', role: 'admin' },
{ id: 2, name: '李四', email: 'lisi@example.com', role: 'user' },
]),
} as Response);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('加载并显示用户列表', async () => {
render(<UserList />);
// 加载状态
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
// 验证列表内容
expect(screen.getByText('李四')).toBeInTheDocument();
expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
});
it('加载失败显示错误信息', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '服务器错误' }),
} as Response);
render(<UserList />);
expect(await screen.findByText('加载失败')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument();
});
it('点击重试重新加载数据', async () => {
const user = userEvent.setup();
vi.spyOn(global, 'fetch').mockResolvedValueOnce({
ok: false,
status: 500,
json: () => Promise.resolve({ message: '服务器错误' }),
} as Response);
render(<UserList />);
await screen.findByText('加载失败');
await user.click(screen.getByRole('button', { name: '重试' }));
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
});
it('搜索过滤用户', async () => {
const user = userEvent.setup();
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
await user.type(screen.getByPlaceholderText('搜索用户'), '张');
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.queryByText('李四')).not.toBeInTheDocument();
});
});
});Hook 测试
基础 Hook 测试
// useCounter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('初始化计数', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});
it('增加计数', () => {
const { result } = renderHook(() => useCounter(0));
act(() => { result.current.increment(); });
expect(result.current.count).toBe(1);
});
it('减少计数', () => {
const { result } = renderHook(() => useCounter(10));
act(() => { result.current.decrement(); });
expect(result.current.count).toBe(9);
});
it('重置计数', () => {
const { result } = renderHook(() => useCounter(5));
act(() => { result.current.increment(); });
act(() => { result.current.reset(); });
expect(result.current.count).toBe(5);
});
});异步 Hook 测试
// useFetch.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { useFetch } from './useFetch';
describe('useFetch', () => {
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: '张三', id: 1 }),
} as Response);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('加载数据', async () => {
const { result } = renderHook(() => useFetch('/api/user/1'));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: '张三', id: 1 });
expect(result.current.error).toBeNull();
});
it('重新加载数据', async () => {
const { result } = renderHook(() => useFetch('/api/user/1'));
await waitFor(() => {
expect(result.current.data).not.toBeNull();
});
act(() => { result.current.refetch(); });
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
});带 Context 的 Hook 测试
// useAuth.test.tsx
import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from './AuthContext';
// 自定义 wrapper
function createWrapper() {
return function Wrapper({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
};
}
describe('useAuth', () => {
it('初始状态未认证', () => {
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.user).toBeNull();
});
it('登录后状态更新', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve({ user: { id: 1, name: 'Admin' }, token: 'abc' }),
} as Response);
const { result } = renderHook(() => useAuth(), {
wrapper: createWrapper(),
});
await act(async () => {
await result.current.login('admin', '123456');
});
expect(result.current.isAuthenticated).toBe(true);
expect(result.current.user?.name).toBe('Admin');
});
});Mock 技巧
Mock 模块
// Mock 整个模块
vi.mock('./api', () => ({
fetchUsers: vi.fn().mockResolvedValue([{ id: 1, name: 'Test' }]),
createUser: vi.fn().mockResolvedValue({ id: 2, name: 'New' }),
}));
// 部分覆盖 Mock
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => vi.fn(), // 只 Mock useNavigate
};
});
// Mock React Context
vi.mock('./ThemeContext', () => ({
useTheme: () => ({
mode: 'dark',
colors: { bg: '#1f1f1f', text: '#fff' },
toggle: vi.fn(),
}),
}));Mock 定时器
describe('定时器相关', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('倒计时', () => {
const { result } = renderHook(() => useCountdown(60));
expect(result.current.seconds).toBe(60);
act(() => { vi.advanceTimersByTime(1000); });
expect(result.current.seconds).toBe(59);
act(() => { vi.advanceTimersByTime(59000); });
expect(result.current.seconds).toBe(0);
expect(result.current.isFinished).toBe(true);
});
});辅助函数封装
// test/utils.tsx — 封装通用的渲染辅助函数
import { render, RenderOptions } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from './ThemeProvider';
import { AuthProvider } from './AuthContext';
// 组合所有 Provider
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<BrowserRouter>
<ThemeProvider>
<AuthProvider>
{children}
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
);
}
// 自定义 render 函数
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllProviders, ...options });
}
// 使用
import { renderWithProviders, screen } from './test/utils';
test('渲染组件', () => {
renderWithProviders(<MyComponent />);
expect(screen.getByText('Hello')).toBeInTheDocument();
});测试 React Query
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { UserList } from './UserList';
// 为每个测试创建独立的 QueryClient
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // 测试中不重试
gcTime: 0, // 禁用垃圾回收
},
},
});
}
function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = createQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
describe('UserList with React Query', () => {
it('加载用户数据', async () => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: 1, name: '张三' }]),
} as Response);
renderWithQueryClient(<UserList />);
await waitFor(() => {
expect(screen.getByText('张三')).toBeInTheDocument();
});
});
});常见误区
误区 1:测试内部 state
// Bad
expect(component.state('isOpen')).toBe(true);
expect(component.state('items')).toHaveLength(5);
// Good — 测试 DOM 输出
expect(screen.getByText('弹窗标题')).toBeVisible();
expect(screen.getAllByRole('listitem')).toHaveLength(5);误区 2:过度 Mock
// Bad — Mock 了组件内部的子组件
vi.mock('./UserCard', () => ({ default: () => <div>Mocked</div> }));
// Good — 让真实的子组件渲染
// 除非子组件有昂贵的副作用(如网络请求),否则不应该 Mock误区 3:忘记 cleanup
// Vitest/Jest 默认自动 cleanup(afterEach),但需要确认配置
// 如果使用自定义 render,确保 cleanup 被调用
// vitest.config.ts
test: {
setupFiles: ['./src/test/setup.ts'],
}
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
// cleanup 在 Testing Library v14+ 中自动处理最佳实践总结
- 测试用户行为而非实现 — 使用 getByRole、getByText 查询 DOM
- 使用 userEvent 而非 fireEvent — 更接近真实用户行为
- 异步操作用 waitFor — 等待 DOM 更新完成再断言
- 封装 renderWithProviders — 统一处理 Provider 包裹
- 每个测试独立 — 使用 beforeEach/afterEach 清理状态
- 覆盖关键路径 — 登录、提交、列表加载、错误处理
