单元测试是保障组件质量的第一道防线。在 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,它更真实地模拟用户交互(例如输入时会触发 focus、keydown、input、keyup 等完整事件序列),而非 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),可以使用waitFor或findBy*来等待状态更新。
// 示例:测试一个模拟数据请求的 Hook
test('异步返回数据', async () => {
const { result } = renderHook(() => useFetch('/api/user'));
await waitFor(() => {
expect(result.current.data).not.toBeNull();
});
});
测试文件放置与命名约定
通常将测试文件放在组件同一目录下,命名为 ComponentName.test.js 或 ComponentName.spec.js,这样便于定位和维护。部分团队会将测试统一放入 tests 目录,但优先推荐就近放置。
编写可测试的组件
最后,单元测试的有效性高度依赖组件的设计。遵循“高内聚、低耦合”的组件设计,配合 Props 和清晰的接口,能让测试更容易编写。如果一个组件难以测试,往往也是其设计需要改进的信号。