测试策略:Vitest + React Testing Library 完全指南
第20章:测试策略:Vitest + React Testing Library 完全指南
测试验证的是实现细节还是用户行为?答案决定了测试是资产还是负债。
本章核心问题:测试哲学'测行为不测实现'在实践中意味着什么? 读完本章你将理解:
- 测试应验证用户行为而非内部实现,重构时测试应继续通过
- render + screen + userEvent 三件套是基本使用模式
- MSW 在 Service Worker 层面拦截请求,让测试与网络请求代码完全解耦
Level 1 · 你需要知道的(1-3年经验)
环境搭建
安装依赖
npm install -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom
vitest.config.ts 配置
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom', // 模拟浏览器 DOM 环境
globals: true, // 不需要每次 import describe/it/expect
setupFiles: ['./src/test/setup.ts'],
},
});
测试全局设置文件
// src/test/setup.ts
import '@testing-library/jest-dom'; // 扩展 expect 断言:toBeInTheDocument 等
@testing-library/jest-dom 提供了一批语义化的 DOM 断言,让测试代码更可读:
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(element).toBeDisabled();
expect(element).toHaveTextContent('确认');
expect(input).toHaveValue('[email protected]');
render + screen + userEvent 三件套
这是 React Testing Library 的基本使用模式:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
test('点击按钮触发 onClick 回调', async () => {
// 1. setup:userEvent.setup() 返回一个模拟用户的实例
const user = userEvent.setup();
const handleClick = vi.fn();
// 2. render:渲染组件到虚拟 DOM
render(<Button onClick={handleClick}>提交</Button>);
// 3. screen:通过用户可感知的方式查找元素
const button = screen.getByRole('button', { name: '提交' });
// 4. 模拟用户操作
await user.click(button);
// 5. 断言结果
expect(handleClick).toHaveBeenCalledOnce();
});
screen 的查询方法层级
screen 的查询方法按优先级排序如下,应当优先使用排名靠前的方法:
| 方法 | 适用场景 |
|---|---|
getByRole |
最优先。按 ARIA 角色查询,最接近辅助技术的视角 |
getByLabelText |
表单元素,通过 label 关联查找 |
getByPlaceholderText |
无 label 的输入框(不推荐,但现实中常见) |
getByText |
按文本内容查找非交互元素 |
getByDisplayValue |
表单元素的当前值 |
getByAltText |
图片的 alt 属性 |
getByTestId |
最后手段,用 data-testid 属性,仅当其他方式无法定位时使用 |
getBy* 找不到时抛出错误(快速失败);queryBy* 找不到时返回 null(用于断言元素不存在);findBy* 返回 Promise,用于异步出现的元素。
异步测试与 waitFor
真实组件往往涉及异步操作——数据加载、debounce 搜索、动画。
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
test('加载用户信息后显示姓名', async () => {
const user = userEvent.setup();
render(<UserProfile userId="123" />);
// 初始状态:显示加载中
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待异步内容出现
// findBy* 内部使用 waitFor,自动轮询直到元素出现或超时
const nameElement = await screen.findByText('张三');
expect(nameElement).toBeInTheDocument();
// 不再显示加载状态
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
// waitFor 用于等待任意断言变为真
test('搜索结果延迟出现', async () => {
const user = userEvent.setup();
render(<SearchBox />);
await user.type(screen.getByRole('searchbox'), 'react');
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(5);
}, { timeout: 3000 }); // 默认超时 1000ms
});
重要:不要使用 act 手动包裹——userEvent 和 findBy*/waitFor 已经在内部正确处理了 React 的批量更新,手动使用 act 反而容易出错。
Mock 策略
vi.mock 模拟模块
// 模拟整个模块
vi.mock('../services/api', () => ({
fetchUser: vi.fn().mockResolvedValue({
id: '1',
name: '张三',
email: '[email protected]',
}),
}));
test('显示用户信息', async () => {
render(<UserProfile userId="1" />);
await screen.findByText('张三');
expect(screen.getByText('[email protected]')).toBeInTheDocument();
});
MSW(Mock Service Worker)模拟 API
对于 HTTP 请求的 mock,MSW 是更健壮的方案。它在 Service Worker 层面拦截请求,让测试与实际的网络请求代码完全解耦:
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: '张三',
email: '[email protected]',
});
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({ id: 'new-id', ...body }, { status: 201 });
}),
];
// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts(添加 MSW 的生命周期钩子)
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers()); // 每个测试后重置,避免污染
afterAll(() => server.close());
在单个测试中临时覆盖 handler:
test('显示错误状态', async () => {
server.use(
http.get('/api/users/:id', () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserProfile userId="1" />);
await screen.findByText('加载失败,请重试');
});
MSW 的优势在于:你的组件代码不需要知道自己在被测试,fetch/axios 等请求照常发出,只是在网络层被拦截了。
测试自定义 Hook
renderHook 专门用于测试不依附于具体 UI 的 hook 逻辑:
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('计数器正常工作', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('不能低于最小值', () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // 保持在 0,不变为 -1
});
如果 hook 依赖 Context,可以通过 renderHook 的 wrapper 选项提供:
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<AuthProvider initialUser={mockUser}>{children}</AuthProvider>
),
});
快照测试:何时有用,何时有害
快照测试(toMatchSnapshot)对于纯展示组件有一定价值,但很容易被滥用:
// 有用的场景:稳定的 UI 基准,如组件库
test('Button 渲染结果稳定', () => {
const { container } = render(<Button variant="primary">提交</Button>);
expect(container).toMatchSnapshot();
});
快照测试的问题在于:
- 过于容易通过。快照更新只需要
vitest --update-snapshots,开发者往往不假思索就更新,失去了保护作用。 - 不传达意图。快照失败时,你只知道"某些东西变了",但不知道变的是不是正确的。
- 维护成本高。每次修改 UI,即使是有意为之的,快照都要更新。
建议:对于行为测试(点击、输入、状态变化),用 getBy* + 具体断言;对于纯展示组件的视觉稳定性,考虑 Storybook 的 visual regression test,而非快照测试。
覆盖率:测什么,不测什么
Vitest 内置覆盖率报告支持:
// vitest.config.ts
test: {
coverage: {
provider: 'v8', // 或 'istanbul'
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.stories.tsx', 'src/test/**'],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
}
覆盖率数字是指标,不是目标。80% 覆盖率配合精心设计的测试,远好于 100% 覆盖率配合大量无意义的快照和实现测试。
真正需要测试的场景:
- 业务逻辑分支:if/else、条件渲染、边界情况
- 用户交互流程:表单提交、验证、成功/失败状态
- 错误处理:网络失败、输入非法、权限不足
- 可访问性关键路径:键盘导航、屏幕阅读器关键标签
可以跳过的场景:
- 纯展示组件(无状态、无交互、只渲染数据)
- 第三方库的行为(不要测试 axios 会发请求)
- 样式细节(颜色、间距,除非有严格 UI 规范)
CI 集成:GitHub Actions 示例
# .github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run type check
run: npm run type-check
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
fail_ci_if_error: true
package.json 中的对应脚本:
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"type-check": "tsc --noEmit"
}
}
Level 2 · 它是怎么运行的(3-5年经验)
测试哲学:测行为,不测实现
React Testing Library 的核心理念来自其作者 Kent C. Dodds 的一句话:
"The more your tests resemble the way your software is used, the more confidence they can give you." (你的测试越接近软件实际的使用方式,它们能给你带来的信心就越大。)
这句话的具体含义是:
- 不测内部状态。不应该断言
component.state.isLoading === true,而应该断言用户能看到 loading 指示器。 - 不测 props 透传。不应该断言子组件收到了某个 prop,而应该断言页面上出现了对应的内容。
- 不测实现细节。不应该断言某个私有函数被调用了几次,而应该断言操作后页面发生了什么变化。
这种哲学让测试真正反映业务价值,而不是代码结构。当你安全地重构组件内部实现时,测试应当继续通过——如果重构引发了大量测试失败,说明测试耦合了实现细节。
Level 3 · 规范怎么定义的(资深)
一个完整的集成测试案例
以登录表单为例,展示真实测试的全貌:
// LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
import { server } from '../test/server';
import { http, HttpResponse } from 'msw';
describe('LoginForm', () => {
test('提交有效凭据后跳转到首页', async () => {
const user = userEvent.setup();
const onSuccess = vi.fn();
render(<LoginForm onSuccess={onSuccess} />);
// 填写表单
await user.type(screen.getByLabelText('邮箱'), '[email protected]');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
// 等待异步操作完成
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith(
expect.objectContaining({ email: '[email protected]' })
);
});
});
test('密码错误时显示错误信息', async () => {
server.use(
http.post('/api/auth/login', () => {
return HttpResponse.json(
{ message: '邮箱或密码错误' },
{ status: 401 }
);
})
);
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
await user.type(screen.getByLabelText('邮箱'), '[email protected]');
await user.type(screen.getByLabelText('密码'), 'wrongpassword');
await user.click(screen.getByRole('button', { name: '登录' }));
await screen.findByText('邮箱或密码错误');
expect(screen.getByRole('button', { name: '登录' })).not.toBeDisabled();
});
test('提交中按钮禁用防止重复提交', async () => {
const user = userEvent.setup();
render(<LoginForm onSuccess={vi.fn()} />);
await user.type(screen.getByLabelText('邮箱'), '[email protected]');
await user.type(screen.getByLabelText('密码'), 'password123');
await user.click(screen.getByRole('button', { name: '登录' }));
// 提交中按钮应当禁用
expect(screen.getByRole('button', { name: /登录中/ })).toBeDisabled();
});
});
好的测试是代码质量文化的一部分,而不是流程的附加品。当团队真正理解"测行为不测实现"时,测试套件会变成一种让人有安全感的资产,而不是阻碍重构的包袱。
Level 4 · 边界与陷阱(所有人)
生产环境常见问题
在实际项目中,本章涵盖的概念最常见的问题包括:
-
忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。
-
过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。
-
文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。