React 组件测试:用 Vitest 替代 Jest
React 组件测试的工具链在 2025 年基本从 Jest + React Testing Library 迁移到了 Vitest + React Testing Library。这篇文章聊聊 Vitest 的配置和实际的测试写法。
为什么从 Jest 迁移到 Vitest
几个实际的原因:
- Vitest 基于 Vite,配置更简单(复用 vite.config.js)
- 启动速度更快(不需要 Jest 的 AST 转换)
- 原生支持 TypeScript 和 ESM
- 兼容 Jest API,迁移成本低
配置
# 安装
pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
// package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
基本测试写法
渲染测试
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import PostList from './PostList';
describe('PostList', () => {
const mockPosts = [
{ id: '1', title: '第一篇文章', excerpt: '摘要...' },
{ id: '2', title: '第二篇文章', excerpt: '摘要...' },
];
it('渲染文章列表', () => {
render(<PostList posts={mockPosts} />);
expect(screen.getByText('第一篇文章')).toBeInTheDocument();
expect(screen.getByText('第二篇文章')).toBeInTheDocument();
});
it('空列表显示提示', () => {
render(<PostList posts={[]} />);
expect(screen.getByText('暂无文章')).toBeInTheDocument();
});
});
交互测试
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SearchBar from './SearchBar';
describe('SearchBar', () => {
it('输入关键词后触发搜索', async () => {
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} />);
const input = screen.getByPlaceholderText('搜索文章...');
fireEvent.change(input, { target: { value: 'React' } });
fireEvent.click(screen.getByRole('button', { name: '搜索' }));
await waitFor(() => {
expect(onSearch).toHaveBeenCalledWith('React');
});
});
});
异步组件测试
import { render, screen, waitFor } from '@testing-library/react';
import PostDetail from './PostDetail';
// 模拟 API 请求
vi.mock('../api/posts', () => ({
getPostById: vi.fn().mockResolvedValue({
id: '1',
title: '测试文章',
content: '文章内容...',
}),
}));
describe('PostDetail', () => {
it('加载并显示文章', async () => {
render(<PostDetail postId="1" />);
// 初始显示加载状态
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待数据加载完成
await waitFor(() => {
expect(screen.getByText('测试文章')).toBeInTheDocument();
});
});
});
Mock 的使用
Mock 模块
// Mock 整个模块
vi.mock('next-auth', () => ({
useSession: () => ({ data: { user: { name: 'test' } }, status: 'authenticated' }),
}));
// Mock 部分导出
vi.mock('../utils/api', async () => {
const actual = await vi.importActual('../utils/api');
return {
...actual,
fetchPosts: vi.fn().mockResolvedValue([]),
};
});
Mock 定时器
it('防抖搜索', async () => {
vi.useFakeTimers();
const onSearch = vi.fn();
render(<SearchBar onSearch={onSearch} debounceMs={300} />);
fireEvent.change(screen.getByPlaceholderText('搜索...'), {
target: { value: 'React' },
});
// 300ms 内不应该触发
expect(onSearch).not.toHaveBeenCalled();
// 快进 300ms
vi.advanceTimersByTime(300);
await waitFor(() => {
expect(onSearch).toHaveBeenCalledWith('React');
});
vi.useRealTimers();
});
测试的边界
不需要测试什么
- 框架的功能:React 的 useState、useEffect 不需要你测试
- 第三方库的行为:dayjs 格式化、lodash 工具函数不需要你测
- 样式:CSS 的具体效果不需要测试
应该测试什么
- 用户的交互行为
- 组件的渲染输出
- 边界条件(空数据、加载状态、错误状态)
- 业务逻辑函数
不要测试实现细节
// 差:测试内部实现(组件用了什么 state、调用了什么方法)
it('calls setCount with 1', () => {
render(<Counter />);
fireEvent.click(screen.getByText('+'));
// 这不是用户关心的事
});
// 好:测试用户能看到的结果
it('点击 + 按钮后数字增加', () => {
render(<Counter />);
const button = screen.getByText('+');
const display = screen.getByRole('status');
const before = display.textContent;
fireEvent.click(button);
expect(parseInt(display.textContent!)).toBe(parseInt(before!) + 1);
});
覆盖率
pnpm test:coverage
Vitest 会生成覆盖率报告。但不要盲目追求 100% 覆盖率。核心业务逻辑的覆盖率高一些,UI 组件的覆盖率 70-80% 就够了。
测试金字塔:
/\
/ \ E2E 测试(少量)
/ \ 集成测试(适量)
/______\ 单元测试(大量)
单元测试最快最稳,应该最多。E2E 测试最慢最脆弱,保持关键流程的覆盖就够了。
E2E 测试工具
如果需要 E2E 测试,2025 年的选择:
- Playwright:微软出品,跨浏览器,API 设计好
- Cypress:老牌工具,生态成熟
- Vitest Browser Mode:Vitest 的浏览器模式,适合组件级 E2E
// Playwright 示例
import { test, expect } from '@playwright/test';
test('用户可以搜索文章', async ({ page }) => {
await page.goto('/');
await page.fill('[placeholder="搜索"]', 'React');
await page.press('[placeholder="搜索"]', 'Enter');
await expect(page.locator('.post-item').first()).toContainText('React');
});
测试不是目的,是手段。写测试的目的是让你有信心重构代码,而不是为了达到某个覆盖率指标。从核心逻辑开始写,逐步覆盖,比一开始就追求全覆盖实际得多。
ReactTypeScript
返回首页