React 组件如何进行测试?

一、是什么

React 测试是指通过自动化的方式验证组件的行为是否符合预期。现代 React 测试的核心理念是 "测试用户行为,而非实现细节"——关注组件渲染了什么、用户交互后发生了什么,而不是内部 state 如何变化。

测试金字塔在 React 中的体现:

测试层级工具数量速度
单元测试Jest + RTL
集成测试Jest + RTL + MSW
端到端测试Playwright / Cypress

标准技术栈:Jest(测试运行器)+ React Testing Library(组件测试工具)+ user-event(用户交互模拟)。

二、React Testing Library 基础

渲染与查询

React Testing Library(RTL)的核心 API 是 renderscreen

import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';

test('渲染用户信息', () => {
  render(<UserProfile name="张三" email="zhang@example.com" />);

  expect(screen.getByText('张三')).toBeInTheDocument();
  expect(screen.getByText('zhang@example.com')).toBeInTheDocument();
});

查询方法优先级

RTL 提供三种查询变体,各有用途:

查询类型找不到时适用场景
getBy*抛出错误断言元素必须存在
queryBy*返回 null断言元素不存在
findBy*返回 Promise等待异步内容出现

按语义化优先级选择查询方法:

// 优先级从高到低
screen.getByRole('button', { name: '提交' });    // 最推荐:无障碍角色
screen.getByLabelText('用户名');                  // 表单元素
screen.getByPlaceholderText('请输入搜索关键词');    // placeholder
screen.getByText('欢迎回来');                     // 可见文本
screen.getByDisplayValue('当前值');                // 表单当前值
screen.getByAltText('用户头像');                   // img alt
screen.getByTestId('custom-element');             // 最后手段

getByRole 是首选方案,因为它与用户和辅助技术感知页面的方式一致。

三、用户交互测试

使用 @testing-library/user-event 模拟真实用户操作,比底层的 fireEvent 更贴近真实行为:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

test('点击按钮增加计数', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  const button = screen.getByRole('button', { name: '增加' });
  expect(screen.getByText('计数: 0')).toBeInTheDocument();

  await user.click(button);
  expect(screen.getByText('计数: 1')).toBeInTheDocument();

  await user.click(button);
  await user.click(button);
  expect(screen.getByText('计数: 3')).toBeInTheDocument();
});

test('表单输入与提交', async () => {
  const user = userEvent.setup();
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);

  await user.type(screen.getByLabelText('用户名'), 'zhangsan');
  await user.type(screen.getByLabelText('密码'), 'password123');
  await user.click(screen.getByRole('button', { name: '登录' }));

  expect(handleSubmit).toHaveBeenCalledWith({ username: 'zhangsan', password: 'password123' });
});

userEventfireEvent 的区别:userEvent 会模拟完整的事件序列(如 type 会触发 focus → keyDown → keyPress → input → keyUp),更接近真实用户操作。

四、异步测试

waitFor 等待状态更新

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

test('加载数据后显示列表', async () => {
  render(<UserList />);

  expect(screen.getByText('加载中...')).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('张三')).toBeInTheDocument();
  });

  expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});

findBy 异步查询

findBy 内部封装了 waitFor,是等待异步元素出现的简洁写法:

test('搜索结果异步加载', async () => {
  const user = userEvent.setup();
  render(<SearchPage />);

  await user.type(screen.getByRole('searchbox'), 'React');
  await user.click(screen.getByRole('button', { name: '搜索' }));

  const results = await screen.findByRole('list');
  expect(results).toBeInTheDocument();

  const items = await screen.findAllByRole('listitem');
  expect(items.length).toBeGreaterThan(0);
});

五、Mock 技术

Jest Mock

// mock 整个模块
jest.mock('./api', () => ({
  fetchUsers: jest.fn().mockResolvedValue([
    { id: '1', name: '张三' },
    { id: '2', name: '李四' },
  ]),
}));

test('渲染用户列表', async () => {
  render(<UserList />);

  expect(await screen.findByText('张三')).toBeInTheDocument();
  expect(screen.getByText('李四')).toBeInTheDocument();
});

MSW(Mock Service Worker)

MSW 在网络层拦截请求,比 mock 模块更接近真实场景:

import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/users', () => {
    return HttpResponse.json([
      { id: '1', name: '张三', email: 'zhang@example.com' },
      { id: '2', name: '李四', email: 'li@example.com' },
    ]);
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: '3', ...body }, { status: 201 });
  })
);

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

test('加载并显示用户列表', async () => {
  render(<UserList />);

  expect(await screen.findByText('张三')).toBeInTheDocument();
  expect(screen.getByText('李四')).toBeInTheDocument();
});

test('处理 API 错误', async () => {
  server.use(
    http.get('/api/users', () => {
      return new HttpResponse(null, { status: 500 });
    })
  );

  render(<UserList />);

  expect(await screen.findByText('加载失败')).toBeInTheDocument();
});

MSW 的优势:mock 对组件代码透明,不需要修改 import 或注入依赖,且同一套 handler 可在开发、测试、Storybook 中复用。

六、测试 Hooks

使用 renderHook 独立测试 Custom Hook:

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

test('useCounter 基本功能', () => {
  const { result } = renderHook(() => useCounter(0));
  expect(result.current.count).toBe(0);

  act(() => { result.current.increment(); });
  expect(result.current.count).toBe(1);
});

// 依赖 Context 的 hook 需要提供 wrapper
test('useAuth 在 Provider 中工作', () => {
  const wrapper = ({ children }) => <AuthProvider>{children}</AuthProvider>;
  const { result } = renderHook(() => useAuth(), { wrapper });
  expect(result.current.user).toBeNull();
});

七、快照测试

快照测试捕获组件的渲染输出,检测意外变化:

test('Button 组件快照', () => {
  const { container } = render(
    <Button variant="primary" size="md">确认</Button>
  );
  expect(container.firstChild).toMatchSnapshot();
});

// 内联快照:小组件更直观
test('Badge 渲染', () => {
  const { container } = render(<Badge count={5} />);
  expect(container.firstChild).toMatchInlineSnapshot(`
    <span class="badge">5</span>
  `);
});

快照测试的利弊:

优点缺点
快速覆盖渲染输出容易产生大而难以审查的快照
检测意外的 UI 变化频繁更新导致团队盲目接受
适合稳定的小组件不能替代行为测试

建议:仅对稳定的基础组件使用快照测试,业务组件优先写行为测试。

八、E2E 测试

端到端测试验证完整的用户流程,Playwright 是目前主流选择:

// tests/login.spec.ts — Playwright
import { test, expect } from '@playwright/test';

test('用户登录流程', async ({ page }) => {
  await page.goto('/login');

  await page.getByLabel('用户名').fill('testuser');
  await page.getByLabel('密码').fill('password123');
  await page.getByRole('button', { name: '登录' }).click();

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('欢迎回来, testuser')).toBeVisible();
});

test('表单验证错误提示', async ({ page }) => {
  await page.goto('/login');

  await page.getByRole('button', { name: '登录' }).click();

  await expect(page.getByText('用户名不能为空')).toBeVisible();
  await expect(page.getByText('密码不能为空')).toBeVisible();
});

E2E 测试选型对比:

特性PlaywrightCypress
浏览器支持Chromium/Firefox/WebKitChromium/Firefox/WebKit
多标签页支持不支持
并行执行原生支持需要付费
API 测试内置需要插件
运行速度

九、测试最佳实践

核心原则

  1. 测行为不测实现:不要断言 state 值,断言渲染结果和副作用
// 差:expect(component.state.isOpen).toBe(true);
// 好:
expect(screen.getByRole('dialog')).toBeVisible();
  1. 每个测试独立:不依赖其他测试的执行顺序
  2. 使用语义化查询:优先 getByRolegetByLabelText,避免依赖 CSS 类名
  3. 避免过度 mock:只 mock 外部依赖,组件内部逻辑真实执行
  4. 测试边界情况:空数据、加载状态、错误状态
test('空列表显示提示', () => {
  render(<TodoList items={[]} />);
  expect(screen.getByText('暂无待办事项')).toBeInTheDocument();
});

十、总结

测试类型工具测什么
组件渲染RTL render + screen正确渲染内容
用户交互userEvent点击、输入、键盘操作
异步行为waitFor / findBy数据加载、状态变化
API 请求MSW网络请求与响应
Custom HookrenderHook + actHook 状态与返回值
E2E 流程Playwright完整用户场景

核心理念:以用户视角编写测试,关注 "组件做了什么" 而非 "组件怎么做的",让测试成为重构的安全网而非束缚。