React 组件测试:用 Vitest 替代 Jest

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
返回首页