Skip to content
测试

测试

React 测试的核心栈:Vitest(测试框架)+ React Testing Library(组件测试)+ MSW(Mock Service Worker,API mock)。


测试金字塔

           ╱ E2E ╲          Playwright / Cypress(少量,测试核心流程)
         ╱  集成   ╲         RTL + MSW(中等量,测试用户交互流程)
       ╱   单元     ╲        Vitest(大量,测试工具函数、Hooks)

💡 最佳实践:重点在集成测试(RTL 模拟用户行为),而非去测实现细节(state 内部值)。


技术选型

工具 用途
Vitest 测试运行器 + 断言库(兼容 Jest API,但更快)
React Testing Library 从用户角度渲染和查询组件
@testing-library/user-event 模拟真实的用户交互(点击、输入、键盘导航)
MSW (Mock Service Worker) 在测试中 mock API 请求
jsdom Node 中模拟浏览器 DOM(Vitest 内建)
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw

快速开始

// Button.jsx
export default function Button({ onClick, children }) {
  return <button onClick={onClick}>{children}</button>;
}

// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

describe('Button', () => {
  it('renders children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', async () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click</Button>);
    await userEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });
});

查询优先级

RTL 提供多种查询方法,按推荐的优先顺序

// 1. getByRole — 最推荐(语义化 + 可访问性)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('heading', { name: '用户列表' });
screen.getByRole('textbox', { name: '邮箱' });  // 匹配 label 关联的 input

// 2. getByLabelText — 表单字段首选
screen.getByLabelText('邮箱地址');

// 3. getByPlaceholderText
screen.getByPlaceholderText('请输入...');

// 4. getByText — 非交互文本
screen.getByText(/共 \d+ 条记录/);

// 5. getByTestId — 最后的手段(添加 data-testid 属性)
screen.getByTestId('submit-button');
方法 返回 找不到时
getBy... 第一个匹配元素 抛出错误
queryBy... 第一个匹配元素 null(用于断言元素不存在
findBy... Promise → 匹配元素 reject(用于异步等待出现)
getAllBy... 所有匹配数组 抛出错误
queryAllBy... 所有匹配数组 []
findAllBy... Promise → 数组 reject

模拟用户交互

import userEvent from '@testing-library/user-event';

// 点击
await userEvent.click(screen.getByRole('button', { name: '保存' }));

// 输入
const input = screen.getByRole('textbox', { name: '姓名' });
await userEvent.type(input, '张三');  // 模拟逐键输入(比 fireEvent.change 更真实)

// 清空
await userEvent.clear(input);

// 键盘
await userEvent.keyboard('{Enter}');
await userEvent.keyboard('{Shift>}A{/Shift}');  // Shift + A → "A"

// 双击
await userEvent.dblClick(element);

// 下拉选择
await userEvent.selectOptions(screen.getByRole('combobox'), 'option-value');

💡 最佳实践:优先使用 userEvent@testing-library/user-event v14+),而非原始的 fireEventuserEvent 模拟的是完整用户行为序列(如点击 = 聚焦 + 按键 + 弹起 + 点击),更接近真实。


测试异步行为

// 等待元素出现
const message = await screen.findByText('加载成功');

// 等待元素消失
await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));

// 通用等待
import { waitFor } from '@testing-library/react';
await waitFor(() => {
  expect(screen.getByText('完成')).toBeInTheDocument();
});

测试自定义 Hook

import { renderHook, act } from '@testing-library/react';

function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  return { count, increment, decrement };
}

describe('useCounter', () => {
  it('increments counter', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});

🚨 陷阱:直接调用 result.current.increment()(不用 act)在大多数情况下可以,但如果状态更新触发了 effect,React 会报 warning。用 act 包裹所有会触发状态变更的操作。


Mock 服务端请求(MSW)

// mocks/handlers.js
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: 1, name: '张三' },
      { id: 2, name: '李四' },
    ]);
  }),
  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 3, ...body }, { status: 201 });
  }),
];

// setup.js (vitest setup file)
import { setupServer } from 'msw/node';
import { handlers } from './mocks/handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

在单测中覆盖 handler

import { http, HttpResponse } from 'msw';
import { server } from './setup';

it('handles error', () => {
  server.use(
    http.get('/api/users', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );
  // 接下来渲染组件,它会收到 500 错误
});

测试上下文提供者

import { render } from '@testing-library/react';

// 自定义 render,包裹 Provider
function renderWithProviders(ui, { preloadedState = {}, ...options } = {}) {
  function Wrapper({ children }) {
    return (
      <ThemeProvider value="light">
        <UserProvider value={preloadedState.user}>
          {children}
        </UserProvider>
      </ThemeProvider>
    );
  }
  return render(ui, { wrapper: Wrapper, ...options });
}

测试覆盖的"正面清单"

应该测试的:

  • 核心业务交互流程(用户注册、下单、支付)
  • 条件渲染(登录前/后显示不同的 UI)
  • 错误状态处理(API 失败时的错误提示)
  • 表单验证逻辑
  • 关键的工具函数和自定义 Hooks

不需要测试的:

  • 第三方库的内部行为(React Router、Zustand 由库作者保证)
  • 实现细节(组件内部 state 的精确值)
  • 纯样式(CSS 是否正确应用——这由视觉测试工具做)

Vitest 配置参考

// vite.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,                  // 无需 import describe/it/expect
    environment: 'jsdom',
    setupFiles: './src/test/setup.js',
    css: true,                      // 处理 CSS imports
  },
});

🚨 常见陷阱

  1. 测试实现细节:测 state.count 而不是测 “点击 + 后显示 1”
  2. 用 fireEvent 代替 userEventfireEvent.click() 不模拟 focus/blur,行为不真实
  3. 查询方法优先级错误:能用 getByRole 就别用 getByTestId
  4. 忘记等待异步行为:API 请求后需 await findByText / waitFor
  5. act 警告:异步 state 更新没包裹在 act 中
  6. 过度 mock:mock 太多东西让测试变成测试"mock"而非"真实行为"