深入理解 React 测试体系
Vitest + Testing Library + MSW 基础使用、组件测试、Hook 测试、集成测试与最佳实践
什么是 React 测试?
定义:React 测试是通过自动化工具验证组件行为是否符合预期的过程。2026 年主流的测试栈是 Vitest(测试运行器)+ Testing Library(组件测试)+ MSW(网络请求 Mock)。核心理念是按用户行为测试,而非测试实现细节。
涉及场景:
- 组件测试:验证渲染输出、用户交互、条件渲染
- Hook 测试:验证自定义 Hook 的状态变化和副作用
- 集成测试:验证多个组件协作、数据流、路由跳转
- API Mock:拦截网络请求返回固定数据,避免依赖真实后端
- 快照测试:检测 UI 意外变更
- 可访问性测试:验证组件满足 ARIA 标准
作用:
- 防止回归:修改代码后自动验证原有功能是否正常
- 提升信心:重构时有测试保障,敢于大胆改动
- 文档效果:测试用例描述了组件的预期行为
- 面试考点:测试策略、Testing Library 用法、MSW Mock 方式是常见考题
一、测试栈搭建
安装
bash
npm install -D vitest @testing-library/react @testing-library/jest-dom \
@testing-library/user-event jsdom mswVitest 配置
typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test/setup.ts',
css: true,
},
});typescript
// src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// 每个测试后自动清理 DOM
afterEach(() => {
cleanup();
});二、组件测试基础
渲染与查询
tsx
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
function Greeting({ name }: { name: string }) {
return <h1>你好,{name}!</h1>;
}
describe('Greeting', () => {
it('显示问候语', () => {
render(<Greeting name="张三" />);
expect(screen.getByText('你好,张三!')).toBeInTheDocument();
});
it('渲染 h1 标签', () => {
render(<Greeting name="李四" />);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('李四');
});
});查询优先级
tsx
// Testing Library 推荐的查询优先级(从高到低):
// 1. getByRole — 最推荐,模拟辅助技术的访问方式
screen.getByRole('button', { name: '提交' });
screen.getByRole('textbox', { name: '用户名' });
screen.getByRole('heading', { level: 2 });
// 2. getByLabelText — 表单元素
screen.getByLabelText('邮箱');
// 3. getByPlaceholderText
screen.getByPlaceholderText('请输入搜索关键词');
// 4. getByText — 非交互元素
screen.getByText('欢迎使用');
screen.getByText(/欢迎/i); // 支持正则
// 5. getByDisplayValue — 表单当前值
screen.getByDisplayValue('当前输入内容');
// 6. getByAltText — 图片
screen.getByAltText('用户头像');
// 7. getByTitle
screen.getByTitle('关闭');
// 8. getByTestId — 最后手段
screen.getByTestId('custom-element');查询变体
tsx
// getBy — 同步查询,找不到抛错
screen.getByText('存在的文本');
// queryBy — 同步查询,找不到返回 null(用于断言不存在)
expect(screen.queryByText('不存在的文本')).not.toBeInTheDocument();
// findBy — 异步查询,等待元素出现(返回 Promise)
const element = await screen.findByText('异步加载的内容');
// getAllBy / queryAllBy / findAllBy — 返回数组
const items = screen.getAllByRole('listitem');
expect(items).toHaveLength(3);三、用户交互测试
tsx
import userEvent from '@testing-library/user-event';
describe('LoginForm', () => {
it('填写表单并提交', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup(); // 每个测试创建新实例
render(<LoginForm onSubmit={handleSubmit} />);
// 输入
await user.type(screen.getByLabelText('邮箱'), 'test@example.com');
await user.type(screen.getByLabelText('密码'), 'password123');
// 点击
await user.click(screen.getByRole('button', { name: '登录' }));
// 断言
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('空表单提交显示验证错误', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: '登录' }));
expect(screen.getByText('请输入邮箱')).toBeInTheDocument();
expect(screen.getByText('请输入密码')).toBeInTheDocument();
});
it('密码可见性切换', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
const passwordInput = screen.getByLabelText('密码');
expect(passwordInput).toHaveAttribute('type', 'password');
await user.click(screen.getByRole('button', { name: '显示密码' }));
expect(passwordInput).toHaveAttribute('type', 'text');
});
});常用交互方法
tsx
const user = userEvent.setup();
// 点击
await user.click(element);
await user.dblClick(element);
// 输入
await user.type(input, 'hello');
await user.clear(input);
// 键盘
await user.keyboard('{Enter}');
await user.keyboard('{Control>}a{/Control}'); // Ctrl+A
// 选择
await user.selectOptions(select, 'option-value');
// Tab 导航
await user.tab();
// Hover
await user.hover(element);
await user.unhover(element);
// 上传文件
const file = new File(['content'], 'test.png', { type: 'image/png' });
await user.upload(fileInput, file);四、异步测试
tsx
import { render, screen, waitFor } from '@testing-library/react';
describe('UserProfile', () => {
it('加载并显示用户信息', async () => {
render(<UserProfile userId="1" />);
// 等待加载完成
expect(screen.getByText('加载中...')).toBeInTheDocument();
// findBy 自动等待元素出现(默认超时 1000ms)
const userName = await screen.findByText('张三');
expect(userName).toBeInTheDocument();
// 加载指示器消失
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
it('显示错误信息', async () => {
// Mock 失败请求(配合 MSW)
server.use(
http.get('/api/users/1', () => {
return HttpResponse.json({ message: '用户不存在' }, { status: 404 });
})
);
render(<UserProfile userId="1" />);
await waitFor(() => {
expect(screen.getByText('用户不存在')).toBeInTheDocument();
});
});
});五、自定义 Hook 测试
tsx
import { renderHook, act } from '@testing-library/react';
// 被测 Hook
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initial);
return { count, increment, decrement, reset };
}
describe('useCounter', () => {
it('初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it('递增', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('重置', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});
it('参数变化时重新初始化', () => {
const { result, rerender } = renderHook(
({ initial }) => useCounter(initial),
{ initialProps: { initial: 0 } }
);
expect(result.current.count).toBe(0);
rerender({ initial: 10 }); // 重新渲染,传入新参数
// 注意:useCounter 不会自动 reset,这取决于 Hook 实现
});
});
// 测试带有异步操作的 Hook
describe('useFetch', () => {
it('获取数据', async () => {
const { result } = renderHook(() => useFetch('/api/users'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual([{ id: 1, name: '张三' }]);
expect(result.current.error).toBeNull();
});
});六、MSW(Mock Service Worker)
配置
typescript
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
// GET 请求
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: '张三', email: 'zhang@example.com' },
{ id: 2, name: '李四', email: 'li@example.com' },
]);
}),
// 带参数的 GET
http.get('/api/users/:id', ({ params }) => {
const { id } = params;
if (id === '999') {
return HttpResponse.json({ message: '用户不存在' }, { status: 404 });
}
return HttpResponse.json({ id, name: '张三' });
}),
// POST 请求
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json(
{ id: 3, ...body },
{ status: 201 }
);
}),
// DELETE
http.delete('/api/users/:id', () => {
return new HttpResponse(null, { status: 204 });
}),
];typescript
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);typescript
// src/test/setup.ts(追加)
import { server } from './mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());在测试中覆盖 Handler
tsx
import { server } from '@/test/mocks/server';
import { http, HttpResponse } from 'msw';
describe('UserList', () => {
it('正常显示用户列表', async () => {
// 使用默认 handler
render(<UserList />);
expect(await screen.findByText('张三')).toBeInTheDocument();
expect(screen.getByText('李四')).toBeInTheDocument();
});
it('服务器错误时显示错误提示', async () => {
// 覆盖 handler(只对这个测试生效)
server.use(
http.get('/api/users', () => {
return HttpResponse.json(
{ message: '服务器内部错误' },
{ status: 500 }
);
})
);
render(<UserList />);
expect(await screen.findByText('加载失败')).toBeInTheDocument();
});
it('空列表', async () => {
server.use(
http.get('/api/users', () => {
return HttpResponse.json([]);
})
);
render(<UserList />);
expect(await screen.findByText('暂无数据')).toBeInTheDocument();
});
});七、集成测试
测试带路由的组件
tsx
import { createMemoryRouter, RouterProvider } from 'react-router';
function renderWithRouter(routes, initialPath = '/') {
const router = createMemoryRouter(routes, {
initialEntries: [initialPath],
});
return render(<RouterProvider router={router} />);
}
describe('App 路由', () => {
it('首页', () => {
renderWithRouter(appRoutes, '/');
expect(screen.getByText('欢迎')).toBeInTheDocument();
});
it('导航到文章页', async () => {
const user = userEvent.setup();
renderWithRouter(appRoutes, '/');
await user.click(screen.getByRole('link', { name: '文章' }));
expect(await screen.findByText('文章列表')).toBeInTheDocument();
});
it('404 页面', () => {
renderWithRouter(appRoutes, '/nonexistent');
expect(screen.getByText('页面不存在')).toBeInTheDocument();
});
});测试带 Provider 的组件
tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // 测试中不重试
gcTime: Infinity,
},
},
});
}
function renderWithProviders(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
);
}
describe('Dashboard', () => {
it('加载数据并展示', async () => {
renderWithProviders(<Dashboard />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
expect(await screen.findByText('总用户数: 100')).toBeInTheDocument();
});
});八、测试最佳实践
测什么、不测什么
✅ 应该测:
- 用户可见的行为(渲染内容、交互响应)
- 条件渲染(不同 props/state 下的输出)
- 错误处理(错误边界、API 错误)
- 表单验证和提交
- 可访问性(正确的 ARIA 角色和标签)
❌ 不应该测:
- 实现细节(组件内部状态值、私有方法)
- 第三方库本身(React Router、TanStack Query)
- CSS 样式(除非是关键的条件样式)
- 常量和配置AAA 模式
tsx
it('添加待办事项', async () => {
// Arrange(准备)
const user = userEvent.setup();
render(<TodoApp />);
// Act(操作)
await user.type(screen.getByRole('textbox'), '学习 React');
await user.click(screen.getByRole('button', { name: '添加' }));
// Assert(断言)
expect(screen.getByText('学习 React')).toBeInTheDocument();
expect(screen.getByRole('textbox')).toHaveValue(''); // 输入框已清空
});测试覆盖率
bash
# vitest 内置覆盖率支持
npx vitest run --coverage
# vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html'],
exclude: ['node_modules/', 'src/test/'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});面试高频题
Q: 为什么推荐 getByRole 而不是 getByTestId?
getByRole 模拟了辅助技术(屏幕阅读器)的访问方式,按用户体验来查询元素。如果 getByRole 找不到元素,说明你的组件可能存在可访问性问题。getByTestId 是与实现耦合的方案,只在其他查询都不适用时才使用。
Q: userEvent 和 fireEvent 的区别?
fireEvent:直接触发 DOM 事件,跳过浏览器的事件流程userEvent:模拟真实用户行为,包括焦点变化、键盘事件序列、输入法处理等。例如user.type(input, 'abc')会触发 focus → keydown → keypress → input → keyup(对每个字符)- 推荐始终使用
userEvent
Q: 如何测试带有 API 请求的组件?
使用 MSW(Mock Service Worker)拦截网络请求。MSW 在 Service Worker 层面拦截,不需要修改组件代码。在 setupFiles 中启动 Mock 服务器,在 handlers 中定义默认响应,在单个测试中可以用 server.use() 覆盖。
Q: act() 是什么?什么时候需要手动使用?
act() 确保 React 的状态更新和副作用在断言前完成。render、userEvent、waitFor 已经内部使用了 act。手动使用的场景:直接调用 renderHook 返回的方法更新状态时,需要用 act 包裹。