人人都会AI编程

19.1 单元测试:Jest + React Testing Library

更新时间:2026-07-10

单元测试是保障组件质量的第一道防线。在 React 项目中,主流的测试组合是 Jest(测试运行器 + 断言库)配合 React Testing Library(组件测试工具库)。它们共同倡导一种测试哲学:测试行为而非实现细节,让测试更贴近用户实际使用方式。

为什么选择 Jest + React Testing Library

  • Jest:零配置开箱即用,内置断言、模拟(mock)、覆盖率报告,由 Facebook 维护,与 React 生态深度整合。
  • React Testing Library:基于 @testing-library/react,提供一套简单、贴近用户的 API,核心原则是“你越能模拟真实用户的行为进行测试,你的测试就越可靠”。

与其他方案(如 Enzyme)相比,React Testing Library 不鼓励测试内部 state 或直接调用组件实例方法,而是通过渲染输出和用户交互来验证组件行为。这使得测试代码在面对重构时更稳定,不会因为组件内部实现变化而大量失效。

环境搭建

使用 Vite 创建的项目可以快速集成:

npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom

并在 package.json 或者 jest.config.js 中配置测试环境为 jsdom,以模拟浏览器 DOM。

组件渲染测试

渲染测试验证组件在不同 Props 下是否输出预期的内容。

// Greeting.jsx
function Greeting({ name }) {
  return <h1>你好, {name}!</h1>;
}

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

test('渲染传入的名字', () => {
  render(<Greeting name="张三" />);
  expect(screen.getByText('你好, 张三!')).toBeInTheDocument();
});

常用查询方法:

  • getByText / getByRole:精确匹配,找不到元素会直接报错。
  • queryByText:用于断言元素不存在,返回 null 而不报错。
  • findByText:返回 Promise,用于等待异步渲染的元素。

@testing-library/jest-dom 扩展了 Jest 的断言,提供如 toBeInTheDocument()toHaveClass()toHaveAttribute() 等语义化匹配器。

交互测试

交互测试模拟用户操作(点击、输入、键盘事件等),验证回调函数是否被正确调用,或 UI 是否按预期改变。

推荐使用 @testing-library/user-event,它更真实地模拟用户交互(例如输入时会触发 focuskeydowninputkeyup 等完整事件序列),而非 fireEvent 的单一事件触发。

// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span>计数:{count}</span>
      <button onClick={() => setCount(c => c + 1)}>+1</button>
    </div>
  );
}

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

test('点击按钮计数增加', async () => {
  render(<Counter />);
  const button = screen.getByRole('button', { name: '+1' });
  await userEvent.click(button);
  expect(screen.getByText('计数:1')).toBeInTheDocument();
});

常见交互场景示例

// 表单输入测试
test('输入用户名并验证值', async () => {
  render(<LoginForm />);
  const input = screen.getByLabelText('用户名');
  await userEvent.type(input, 'admin');
  expect(input).toHaveValue('admin');
});

// 表单提交后调用回调
test('提交时调用 onSubmit 回调', async () => {
  const handleSubmit = jest.fn();
  render(<LoginForm onSubmit={handleSubmit} />);
  await userEvent.click(screen.getByRole('button', { name: '登录' }));
  expect(handleSubmit).toHaveBeenCalledTimes(1);
});

快照测试

快照测试是 Jest 的内置功能,用于捕获组件的渲染输出,并在后续测试中比较是否一致。它特别适合纯展示型组件,如没有交互逻辑的 UI 卡片。

// ProfileCard.test.js
import { render } from '@testing-library/react';
import ProfileCard from './ProfileCard';

test('渲染结果与快照匹配', () => {
  const { container } = render(<ProfileCard name="李四" role="工程师" />);
  expect(container).toMatchSnapshot();
});

首次运行会生成一个 snapshots 目录和对应的快照文件。后续运行会比较渲染输出与快照的差异,如果不一致,测试会失败。你可以审查差异,若变更是预期的,可通过 --updateSnapshot 更新快照。

快照测试的注意事项

  • 不要滥用快照,避免为复杂交互组件生成动辄数百行的快照,因为任何微小改动都会导致快照失败,增加维护负担。
  • 快照应该小而专注,只覆盖稳定的 DOM 结构。
  • 快照不能替代交互测试,它只能验证渲染输出的一致性,不能验证行为正确性。

Hooks 单元测试

自定义 Hooks 通常依赖于组件上下文(如状态、副作用),无法直接独立调用。React Testing Library 提供了 renderHook 方法来测试 Hook。

npm install --save-dev @testing-library/react-hooks

最新版本的 @testing-library/react 已内置 renderHook,可直接从 @testing-library/react 引入。

// useCounter.js
import { useState, useCallback } from 'react';

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

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

test('默认初始值为 0,可增加和减少', () => {
  const { result } = renderHook(() => useCounter());

  // 初始值
  expect(result.current.count).toBe(0);

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

  // 调用 decrement
  act(() => {
    result.current.decrement();
  });
  expect(result.current.count).toBe(0);
});

test('可设置初始值', () => {
  const { result } = renderHook(() => useCounter(10));
  expect(result.current.count).toBe(10);
});

关键点

  • 使用 renderHook 包装 Hook 调用,返回一个包含 result 的对象,result.current 存储 Hook 的最新返回值。
  • 任何改变状态的函数调用必须包裹在 act() 中,以确保状态更新被正确提交到 React 并反映到 result.current
  • 对于涉及异步操作的 Hook(如 useEffect 中的 fetch),可以使用 waitForfindBy* 来等待状态更新。
// 示例:测试一个模拟数据请求的 Hook
test('异步返回数据', async () => {
  const { result } = renderHook(() => useFetch('/api/user'));
  await waitFor(() => {
    expect(result.current.data).not.toBeNull();
  });
});

测试文件放置与命名约定

通常将测试文件放在组件同一目录下,命名为 ComponentName.test.jsComponentName.spec.js,这样便于定位和维护。部分团队会将测试统一放入 tests 目录,但优先推荐就近放置。

编写可测试的组件

最后,单元测试的有效性高度依赖组件的设计。遵循“高内聚、低耦合”的组件设计,配合 Props 和清晰的接口,能让测试更容易编写。如果一个组件难以测试,往往也是其设计需要改进的信号。