name: react-testing description: 针对React的Jest和React Testing Library测试模式。在编写测试、模拟模块、测试Zustand存储或调试React Web应用程序中的测试失败时使用。
React 测试(Web)
问题陈述
React测试需要理解组件渲染、用户交互和异步状态管理。这项技能涵盖了Jest与React Testing Library模式,用于Web应用程序。
模式:Zustand存储测试
**问题:**存储状态在测试之间持久存在,导致测试不稳定。
import { useAppStore } from '@/stores/appStore';
const initialState = {
items: [],
loading: false,
error: null,
};
describe('App Store', () => {
// 每个测试前重置存储
beforeEach(() => {
useAppStore.setState(initialState, true); // true = 替换整个状态
});
it('向存储中添加项目', async () => {
const store = useAppStore.getState();
await store.addItem({ id: '1', name: 'Test' });
expect(useAppStore.getState().items).toHaveLength(1);
});
it('处理加载状态', async () => {
const store = useAppStore.getState();
const loadPromise = store.fetchItems();
expect(useAppStore.getState().loading).toBe(true);
await loadPromise;
expect(useAppStore.getState().loading).toBe(false);
});
});
关键点:
- 使用
setState(initialState, true)替换(不合并)状态 - 在异步操作后使用
getState()获取新状态 - 在存储测试中不要依赖组件重新渲染
模式:异步存储操作
**问题:**测试异步Zustand操作并正确等待。
import { act, waitFor } from '@testing-library/react';
it('正确加载数据', async () => {
const store = useAppStore.getState();
// 将异步存储操作包装在act中
await act(async () => {
await store.loadData('123');
});
// 异步完成后验证状态
await waitFor(() => {
const state = useAppStore.getState();
expect(Object.keys(state.data).length).toBeGreaterThan(0);
});
});
// 对于复杂流程,验证每个步骤
it('完成多步骤流程', async () => {
const store = useAppStore.getState();
// 第1步
await act(async () => {
await store.loadItems();
});
expect(useAppStore.getState().items).toBeDefined();
// 第2步
await act(async () => {
await store.processItems();
});
expect(useAppStore.getState().processed).toBe(true);
});
模式:组件测试
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('ItemCard', () => {
const mockItem = {
id: '1',
title: 'Test Item',
price: 99.99,
};
it('显示项目数据', () => {
render(<ItemCard item={mockItem} />);
expect(screen.getByText('Test Item')).toBeInTheDocument();
expect(screen.getByText('$99.99')).toBeInTheDocument();
});
it('点击时调用onClick', async () => {
const user = userEvent.setup();
const onClick = jest.fn();
render(<ItemCard item={mockItem} onClick={onClick} />);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledWith(mockItem.id);
});
it('显示加载状态', () => {
render(<ItemCard item={mockItem} loading />);
expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
});
});
模式:React Query测试
**问题:**使用React Query的组件需要QueryClientProvider。
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
},
},
});
}
function renderWithQuery(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
// 测试中的使用
it('获取并显示数据', async () => {
renderWithQuery(<UserProfile userId="123" />);
// 最初显示加载
expect(screen.getByText('Loading...')).toBeInTheDocument();
// 等待数据
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
模式:自定义Hook测试
import { renderHook, act, waitFor } from '@testing-library/react';
describe('useAuth', () => {
it('用户登录', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider, // 如果钩子需要上下文
});
await act(async () => {
await result.current.signIn('test@example.com', 'password');
});
expect(result.current.user).toBeDefined();
expect(result.current.isAuthenticated).toBe(true);
});
it('处理登录错误', async () => {
const { result } = renderHook(() => useAuth(), {
wrapper: AuthProvider,
});
await act(async () => {
try {
await result.current.signIn('invalid@example.com', 'wrong');
} catch (e) {
// 预期
}
});
expect(result.current.error).toBe('Invalid credentials');
});
});
// 带Zustand的Hook
describe('useUserData', () => {
beforeEach(() => {
useUserStore.setState(initialState, true);
});
it('返回当前用户数据', () => {
// 预填充存储
useUserStore.setState({ user: { id: '1', name: 'Test' } });
const { result } = renderHook(() => useUserData());
expect(result.current.user.name).toBe('Test');
});
});
模式:模拟API调用
// 全局模拟fetch
global.fetch = jest.fn();
beforeEach(() => {
(fetch as jest.Mock).mockClear();
});
it('获取用户数据', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '1', name: 'John' }),
});
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('John')).toBeInTheDocument();
});
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
// 模拟特定模块
jest.mock('@/api/users', () => ({
getUser: jest.fn(),
updateUser: jest.fn(),
}));
import { getUser, updateUser } from '@/api/users';
it('加载并更新用户', async () => {
(getUser as jest.Mock).mockResolvedValue({ id: '1', name: 'John' });
(updateUser as jest.Mock).mockResolvedValue({ id: '1', name: 'Jane' });
// 测试使用这些的组件
});
模式:路由器测试
import { MemoryRouter, Routes, Route } from 'react-router-dom';
function renderWithRouter(ui: React.ReactElement, { route = '/' } = {}) {
return render(
<MemoryRouter initialEntries={[route]}>
{ui}
</MemoryRouter>
);
}
// 测试导航
it('点击按钮后导航到个人资料', async () => {
const user = userEvent.setup();
renderWithRouter(
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/profile" element={<ProfilePage />} />
</Routes>
);
await user.click(screen.getByText('Go to Profile'));
expect(screen.getByText('Profile Page')).toBeInTheDocument();
});
// 测试带路由参数
it('显示来自路由参数的用户', async () => {
renderWithRouter(
<Routes>
<Route path="/users/:id" element={<UserPage />} />
</Routes>,
{ route: '/users/123' }
);
await waitFor(() => {
expect(screen.getByText('User 123')).toBeInTheDocument();
});
});
模式:表单测试
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('提交表单与输入的数据', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText('Email'), 'test@example.com');
await user.type(screen.getByLabelText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(onSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('显示验证错误', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={jest.fn()} />);
// 未填写表单即提交
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(screen.getByText('Email is required')).toBeInTheDocument();
expect(screen.getByText('Password is required')).toBeInTheDocument();
});
it('加载时禁用提交', async () => {
render(<LoginForm onSubmit={jest.fn()} loading />);
expect(screen.getByRole('button', { name: 'Sign In' })).toBeDisabled();
});
});
模式:避免act()警告
问题: “警告:测试中的更新没有被包装在act(…)”
// 错误 - 状态更新发生在测试之后
it('加载数据', () => {
render(<DataComponent />);
// 组件异步获取数据,测试结束后更新状态
});
// 正确 - 等待异步完成
it('加载数据', async () => {
render(<DataComponent />);
// 等待加载完成
await waitFor(() => {
expect(screen.getByText('Data loaded')).toBeInTheDocument();
});
});
// 正确 - 使用findBy*(内置waitFor)
it('加载数据', async () => {
render(<DataComponent />);
const element = await screen.findByText('Data loaded');
expect(element).toBeInTheDocument();
});
模式:快照测试
何时使用:
- 具有稳定结构的UI组件
- 设计系统组件
- 视觉回归重要的组件
何时避免:
- 具有动态内容的组件
- 频繁变化的组件
- 大型组件树(脆弱)
// 好的快照候选 - 稳定的UI组件
it('正确渲染', () => {
const { container } = render(<Button variant="primary">Submit</Button>);
expect(container).toMatchSnapshot();
});
// 不好的快照候选 - 动态内容
it('渲染用户列表', () => {
// 不要快照 - 列表内容变化
// 相反,测试特定行为
});
模式:测试上下文提供者
// 创建一个包含所有提供者的包装器
function AllProviders({ children }: { children: React.ReactNode }) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
function renderWithProviders(ui: React.ReactElement) {
return render(ui, { wrapper: AllProviders });
}
// 测试中的使用
it('用所有上下文渲染', () => {
renderWithProviders(<Dashboard />);
// 组件可以访问所有提供者
});
测试命令
npm test # 运行所有测试
npm test -- --watch # 监视模式
npm test -- --coverage # 覆盖率报告
npm test -- Button # 运行特定测试文件
npm test -- --updateSnapshot # 更新快照
npm test -- --runInBand # 串行运行测试(调试)
常见问题
| 问题 | 解决方案 |
|---|---|
| “无法找到模块” | 检查jest moduleNameMapper配置 |
| act()警告 | 用act()包装状态更新,使用waitFor/findBy |
| 存储状态渗透 | 添加beforeEach重置setState |
| 异步测试超时 | 增加超时或检查悬挂的承诺 |
| 模拟不工作 | 验证模拟路径与导入路径完全匹配 |
| 查询未找到 | 使用findBy*用于异步内容,检查可访问性 |
推荐文件结构
__tests__/
utils/
test-utils.tsx # 自定义渲染与提供者
query-test-utils.tsx # QueryClient包装器
jest.setup.js # 全局模拟和设置
jest.config.js # Jest配置