React 测试:Jest 与 React Testing Library
在现代前端开发中,测试已经成为了不可或缺的一环。随着应用复杂度不断提升,手动测试不仅耗时,而且难以覆盖所有边界情况。自动化测试能够帮助你在代码重构、添加新功能时快速发现问题,增强对代码质量的信心。本文将介绍 React 项目中最常用的两种测试工具——Jest 和 React Testing Library,并带你从零开始掌握它们的核心用法。
1. 为什么需要测试驱动开发
在编写代码之前先写测试,这种开发方式被称为测试驱动开发(TDD)。TDD 的核心思想是「红-绿-重构」循环:首先编写一个失败的测试(红),然后编写最少量的代码让测试通过(绿),最后优化代码结构(重构)。这种循环能带来几个显著好处:提前发现 bug,避免问题在后期放大;明确接口设计,迫使你思考组件的使用方式;安全重构,确保修改代码不会破坏已有功能。
对于 React 应用而言,组件是构建界面的基本单元。每个组件都应该有一个清晰的职责边界,这使得为组件编写单元测试变得自然而然。接下来我们将搭建一个完整的测试环境,并学习如何测试 React 组件。
2. 环境搭建与依赖安装
2.1 使用 Create React App 或 Vite 创建项目
如果你是从零开始,推荐使用 Create React App 或 Vite 来创建项目,因为它们已经内置了对 Jest 的支持。
使用 Vite 创建项目:
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install
使用 Create React App 创建项目:
npx create-react-app my-react-app
cd my-react-app
npm start
2.2 手动安装测试依赖
如果你在已有项目中添加测试功能,需要手动安装以下依赖:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
安装完成后,需要在 package.json 中添加测试脚本:
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
为了支持 ES6 语法和 JSX,你还需要配置 Babel。安装 Babel 相关依赖:
npm install --save-dev @babel/preset-env @babel/preset-react
创建 babel.config.js 配置文件:
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }]
]
};
3. Jest 核心概念与语法
Jest 是 Facebook 开发的一个测试框架,它的设计理念是「零配置」——大多数情况下,你只需要写测试用例,Jest 会自动完成其余工作。
3.1 测试的基本结构
Jest 使用 test 或 it 函数来定义测试用例,两者的功能完全相同,只是语义略有区别:test 更强调「测试什么」,it 更强调「它应该做什么」。
// math.test.js
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
describe('数学函数测试', () => {
test('add 函数应该正确计算两个数的和', () => {
expect(add(2, 3)).toBe(5);
});
test('subtract 函数应该正确计算两个数的差', () => {
expect(subtract(5, 3)).toBe(2);
});
});
在这个例子中,describe 用来将相关的测试用例分组,expect 用来断言某个值的实际结果,toBe 是最常用的匹配器,用于判断两个值是否严格相等。
3.2 常用匹配器
Jest 提供了丰富的匹配器来满足不同的断言需求:
test('各种匹配器示例', () => {
const value = { name: 'React' };
// 相等性测试
expect(value).toEqual({ name: 'React' }); // 深度相等(推荐用于对象和数组)
expect(value).toBe(value); // 严格相等(引用相同)
// 布尔值测试
expect(null).toBeNull(); // 值是否为 null
expect(undefined).toBeUndefined(); // 值是否为 undefined
expect(true).toBeTruthy(); // 真值
expect(0).toBeFalsy(); // 假值
// 数值测试
expect(10).toBeGreaterThan(5); // 大于
expect(10).toBeLessThanOrEqual(10); // 小于等于
expect(0.1 + 0.2).toBeCloseTo(0.3); // 浮点数近似相等
// 字符串测试
expect('Hello React').toContain('React'); // 包含子串
expect('Hello').toMatch(/^Hel/); // 正则匹配
// 数组测试
expect([1, 2, 3]).toContain(2); // 包含元素
expect([1, 2, 3]).toHaveLength(3); // 长度断言
// 对象测试
expect({ a: 1 }).toHaveProperty('a'); // 属性存在
});
3.3 异步测试
处理异步代码时,Jest 支持多种测试方式:
// async.test.js
// 方式一:使用 Promise
test('异步函数测试(Promise)', () => {
return fetchData().then(data => {
expect(data).toEqual({ success: true });
});
});
// 方式二:使用 async/await
test('异步函数测试(async/await)', async () => {
const data = await fetchData();
expect(data).toEqual({ success: true });
});
// 方式三:使用 .resolves / .rejects
test('Promise 应该被解决', () => {
return expect(fetchData()).resolves.toEqual({ success: true });
});
test('Promise 应该被拒绝', () => {
return expect(fetchError()).rejects.toThrow('Error');
});
3.4 钩子函数与测试隔离
describe 块内部可以使用四个钩子函数来管理测试生命周期:
| 钩子函数 | 执行时机 | 典型用途 |
|---|---|---|
beforeAll |
所有测试执行前运行一次 | 创建共享资源、数据库连接 |
beforeEach |
每个测试执行前运行 | 重置状态、初始化数据 |
afterEach |
每个测试执行后运行 | 清理临时文件、恢复设置 |
afterAll |
所有测试执行后运行 | 断开连接、清理全局资源 |
describe('用户管理模块', () => {
let testUser;
beforeEach(() => {
testUser = { id: 1, name: '测试用户' };
});
test('应该能获取用户信息', () => {
const user = getUser(testUser.id);
expect(user.name).toBe('测试用户');
});
test('应该能更新用户', () => {
const updatedUser = updateUser(testUser.id, { name: '新名称' });
expect(updatedUser.name).toBe('新名称');
// 下个测试前会重置 testUser
});
});
4. React Testing Library 入门
React Testing Library 的设计哲学与 Enzyme 等传统测试工具截然不同。它不关心组件的内部实现细节——不鼓励测试组件的 state、props 或方法调用——而是专注于测试组件的「公开接口」:用户能看到什么、能做什么。这种理念被称为「行为驱动测试」。
4.1 渲染组件
render 函数是 React Testing Library 的核心,它将 React 组件渲染到 DOM 中,并提供查询 API 来获取元素:
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('显示欢迎消息', () => {
render(<Greeting name="张三" />);
// 通过文本内容查找元素
const element = screen.getByText('你好,张三!');
expect(element).toBeInTheDocument();
});
4.2 查询 API 详解
React Testing Library 提供了多种查询方式来定位元素,它们在语义和实用性上有所不同:
| 查询类型 | 特点 | 失败时行为 | 推荐场景 |
|---|---|---|---|
getBy |
找不到则抛出错误 | 立即失败 | 确认元素一定存在 |
getAllBy |
返回所有匹配项 | 找不到则抛出错误 | 检查列表内容 |
queryBy |
找不到返回 null | 不抛错,返回 null | 检查元素不存在 |
queryAllBy |
返回所有匹配项 | 找不到返回空数组 | 检查多个元素 |
findBy |
异步等待出现 | 超时则失败 | 等待异步加载的内容 |
findAllBy |
异步等待所有匹配项 | 超时则失败 | 等待多个异步元素 |
import { render, screen } from '@testing-library/react';
import UserList from './UserList';
test('用户列表渲染测试', async () => {
render(<UserList />);
// 同步查询:元素必须立即存在
const loading = screen.queryByText('加载中...');
expect(loading).not.toBeInTheDocument();
// 异步查询:等待数据加载完成
const users = await screen.findAllByRole('listitem');
expect(users).toHaveLength(3);
});
4.3 元素属性查询
除了文本内容,还可以通过多种方式查询元素:
test('各种查询方式', () => {
render(
<form>
<label htmlFor="email">邮箱</label>
<input
type="email"
id="email"
placeholder="请输入邮箱"
data-testid="email-input"
/>
<button type="submit">提交</button>
</form>
);
// 按角色查找(最佳实践,推荐优先使用)
const button = screen.getByRole('button', { name: /提交/i });
// 按标签关联查找
const emailInput = screen.getByLabelText('邮箱');
// 按占位符查找
const placeholderInput = screen.getByPlaceholderText('请输入邮箱');
// 按 alt 文本查找(图片等)
const image = screen.getByAltText('用户头像');
// 按 data-testid 查找(最后手段)
const testInput = screen.getByTestId('email-input');
// 按显示文本查找(完整或部分)
const text = screen.getByText('邮箱');
const partialText = screen.getByText(/邮箱/); // 正则匹配
});
4.4 用户交互模拟
@testing-library/user-event 是官方推荐的用户交互库,比原生事件更贴近真实用户行为:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('表单提交测试', async () => {
const user = userEvent.setup(); // v14+ 需要 setup
render(<LoginForm />);
// 输入文本
await user.type(screen.getByLabelText(/用户名/i), 'admin');
await user.type(screen.getByLabelText(/密码/i), '123456');
// 点击复选框
await user.click(screen.getByRole('checkbox', { name: /记住我/i }));
// 点击按钮
await user.click(screen.getByRole('button', { name: /登录/i }));
// 验证提交后的结果
await screen.findByText('登录成功');
});
常见交互操作的模拟方式:
| 操作 | 方法 | 说明 |
|---|---|---|
| 点击 | user.click(element) |
支持单击、双击、右键 |
| 悬停 | user.hover(element) |
触发 hover 事件 |
| 输入 | user.type(element, text) |
模拟键盘输入,可包含特殊键 |
| 清空 | user.clear(element) |
清空输入框内容 |
| 选择 | user.selectOptions(element, value) |
选择下拉选项 |
| 拖拽 | user.dragAndDrop(source, target) |
拖拽操作 |
5. 实战:一个完整组件测试示例
让我们通过一个完整的计数器组件来演示如何综合运用 Jest 和 React Testing Library:
// Counter.jsx
import { useState } from 'react';
function Counter({ initialValue = 0 }) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
const reset = () => setCount(initialValue);
return (
<div>
<h1>计数器</h1>
<p data-testid="count-display">当前计数:{count}</p>
<button onClick={decrement} aria-label="减少">-</button>
<button onClick={increment} aria-label="增加">+</button>
<button onClick={reset} aria-label="重置">重置</button>
</div>
);
}
export default Counter;
对应的测试文件:
// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';
describe('Counter 组件测试', () => {
test('应该正确渲染初始值', () => {
render(<Counter initialValue={10} />);
expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:10');
});
test('点击增加按钮应该使计数加 1', async () => {
const user = userEvent.setup();
render(<Counter initialValue={0} />);
const incrementButton = screen.getByRole('button', { name: /增加/i });
await user.click(incrementButton);
await user.click(incrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:2');
});
test('点击减少按钮应该使计数减 1', async () => {
const user = userEvent.setup();
render(<Counter initialValue={5} />);
const decrementButton = screen.getByRole('button', { name: /减少/i });
await user.click(decrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:4');
});
test('点击重置按钮应该恢复到初始值', async () => {
const user = userEvent.setup();
render(<Counter initialValue={0} />);
// 先增加几次
const incrementButton = screen.getByRole('button', { name: /增加/i });
await user.click(incrementButton);
await user.click(incrementButton);
await user.click(incrementButton);
// 验证当前值
expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:3');
// 点击重置
const resetButton = screen.getByRole('button', { name: /重置/i });
await user.click(resetButton);
// 验证重置成功
expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:0');
});
test('测试边界情况:负数计数', async () => {
const user = userEvent.setup();
render(<Counter initialValue={0} />);
const decrementButton = screen.getByRole('button', { name: /减少/i });
await user.click(decrementButton);
expect(screen.getByTestId('count-display')).toHaveTextContent('当前计数:-1');
});
});
在这个测试文件中,我们覆盖了四个核心场景:初始渲染、递增操作、递减操作和重置功能。每个测试都遵循「Arrange-Act-Assert」模式:首先准备测试环境(Arrange),然后执行用户操作(Act),最后验证结果是否符合预期(Assert)。
6. 测试异步组件
现代应用中经常需要处理异步数据加载、API 调用等场景。React Testing Library 提供了 waitFor 和 waitForElementToBeRemoved 等工具来帮助测试异步行为:
// UserProfile.jsx
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}
fetchUser();
}, [userId]);
if (loading) return <p>加载中...</p>;
if (error) return <p>加载失败</p>;
if (!user) return <p>用户不存在</p>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;
```
对应的测试文件:
```jsx
// UserProfile.test.jsx
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import UserProfile from './UserProfile';
// Mock fetch API
beforeEach(() => {
jest.spyOn(global, 'fetch').mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('UserProfile 异步测试', () => {
test('应该显示加载状态,然后显示用户信息', async () => {
const mockUser = { name: '张三', email: 'zhangsan@example.com' };
// 模拟 fetch 延迟返回
global.fetch.mockImplementationOnce(() =>
new Promise(resolve =>
setTimeout(() => resolve({
ok: true,
json: () => Promise.resolve(mockUser)
}), 100)
)
);
render(<UserProfile userId={1} />);
// 验证加载状态
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待加载状态消失
await waitForElementToBeRemoved(() => screen.queryByText('加载中...'));
// 验证用户信息显示
expect(screen.getByText('张三')).toBeInTheDocument();
expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
});
test('应该处理 API 错误', async () => {
global.fetch.mockImplementationOnce(() =>
Promise.reject(new Error('网络错误'))
);
render(<UserProfile userId={999} />);
// 等待错误信息出现
await screen.findByText('加载失败');
});
test('用户 ID 变化时应该重新获取数据', async () => {
const user1 = { name: '用户1', email: 'user1@example.com' };
const user2 = { name: '用户2', email: 'user2@example.com' };
global.fetch
.mockImplementationOnce(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(user1) })
)
.mockImplementationOnce(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve(user2) })
);
const { rerender } = render(<UserProfile userId={1} />);
// 等待第一个用户加载完成
await screen.findByText('用户1');
// 改变 userId,重新渲染
rerender(<UserProfile userId={2} />);
// 验证新用户信息
await screen.findByText('用户2');
expect(screen.queryByText('用户1')).not.toBeInTheDocument();
});
});
```
---
## 7. Mock 技术详解
在单元测试中,我们经常需要隔离被测组件与外部依赖(如 API、第三方库等)。Jest 提供了强大的 Mock 功能来帮助我们完成这项工作。
### 7.1 函数 Mock
使用 `jest.fn()` 创建模拟函数,可以追踪函数调用情况:
```javascript
test('函数调用追踪', () => {
const mockCallback = jest.fn();
// 调用模拟函数
['a', 'b', 'c'].forEach(item => mockCallback(item));
// 验证调用次数
expect(mockCallback).toHaveBeenCalledTimes(3);
// 验证调用参数
expect(mockCallback).toHaveBeenCalledWith('a');
expect(mockCallback).toHaveBeenCalledWith('b');
expect(mockCallback).toHaveBeenCalledWith('c');
// 获取所有调用记录
console.log(mockCallback.mock.calls);
});
```
### 7.2 模块 Mock
当组件依赖外部模块时,可以使用 `jest.mock()` 进行整体 Mock:
```javascript
// api.js
export const fetchUser = (id) =>
fetch(`/api/users/${id}`).then(res => res.json());
export const updateUser = (id, data) =>
fetch(`/api/users/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
}).then(res => res.json());
测试文件:
// UserProfile.test.jsx
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
import * as api from './api';
// Mock 整个模块
jest.mock('./api');
test('应该显示从 API 获取的用户数据', async () => {
// 配置 Mock 函数的返回值
api.fetchUser.mockResolvedValue({
id: 1,
name: '李四',
email: 'lisi@example.com'
});
render(<UserProfile userId={1} />);
await screen.findByText('李四');
expect(screen.getByText('lisi@example.com')).toBeInTheDocument();
// 验证 API 被正确调用
expect(api.fetchUser).toHaveBeenCalledWith(1);
});
7.3 定时器 Mock
涉及 setTimeout、setInterval 的代码需要特殊处理:
// delayedMessage.js
export function showDelayedMessage(message, delay) {
return new Promise(resolve => {
setTimeout(() => {
resolve(message);
}, delay);
});
}
测试文件:
// delayedMessage.test.js
import { showDelayedMessage } from './delayedMessage';
test('定时器测试', () => {
jest.useFakeTimers(); // 启用假定时器
const promise = showDelayedMessage('Hello', 2000);
// 快进时间
jest.advanceTimersByTime(2000);
// 等待 Promise 完成
return expect(promise).resolves.toBe('Hello');
});
8. 测试最佳实践
在实际项目中,遵循良好的测试习惯能够让你的测试更可靠、更易维护:
保持测试专注且独立。每个测试用例应该只验证一个行为,避免在单个测试中检查多个不相关的功能点。这不仅使测试失败时的错误信息更清晰,也便于定位问题所在。更重要的是,所有测试必须能够以任意顺序独立运行,不能依赖其他测试的执行结果或副作用。
优先测试行为,而非实现。不要测试组件的内部 state、ref 或方法调用。用户的操作是基于可见的 UI 进行的,因此测试也应该基于用户视角编写。如果有一天你重构了组件的内部实现,只要行为不变,测试应该继续保持通过。
// ❌ 错误示例:测试实现细节
test('状态更新测试', () => {
render(<Counter />);
const button = screen.getByTestId('increment-button');
fireEvent.click(button);
expect(screen.getByTestId('counter-component').state.count).toBe(1);
});
// ✅ 正确示例:测试用户行为结果
test('点击按钮后计数应该增加', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: '+' }));
expect(screen.getByText('当前计数:1')).toBeInTheDocument();
});
为真实的用户交互编写测试。使用 userEvent 而非原始的 fireEvent,因为它更贴近真实的用户行为。userEvent 会触发一系列相关的事件(如 focus、input、change),而 fireEvent 只触发单个事件。
合理使用测试数据工厂。当需要创建相似的测试数据时,使用工厂函数来生成,避免代码重复:
// test-utils.js
export function createUser(overrides = {}) {
return {
id: 1,
name: '测试用户',
email: 'test@example.com',
role: 'user',
...overrides
};
}
// 测试文件中使用
test('应该正确显示管理员信息', () => {
const admin = createUser({ name: '管理员', role: 'admin' });
render(<UserCard user={admin} />);
expect(screen.getByText('管理员')).toBeInTheDocument();
});
9. 代码覆盖率
代码覆盖率反映了测试对代码的覆盖程度。Jest 内置了覆盖率报告功能,使用 jest --coverage 命令即可生成。常见的覆盖率指标包括:
| 指标 | 含义 | 参考标准 |
|---|---|---|
| 语句覆盖率 | 每条语句是否被执行 | 80%+ |
| 分支覆盖率 | 每个条件分支是否都被测试 | 80%+ |
| 函数覆盖率 | 每个函数是否被调用 | 80%+ |
| 行覆盖率 | 代码每行是否被测试 | 80%+ |
需要注意的是,高覆盖率并不意味着高质量的测试。盲目追求 100% 覆盖率可能导致为无意义的代码编写测试,或者只测试简单的 happy path。更重要的是确保关键业务逻辑和用户交互流程被充分测试。
10. 常见问题与解决方案
测试找不到元素。首先确认元素是否在渲染后立即存在,如果是异步加载的内容,需要使用 findBy 或等待异步操作完成。其次检查元素是否被 display: none 或 visibility: hidden 隐藏,这类元素对屏幕阅读器不可见,React Testing Library 默认也不会返回。
测试结果不稳定。这通常是由于测试之间的状态污染造成的。确保在 afterEach 钩子中清理副作用,使用 cleanup() 函数重置 DOM,并且避免在测试之间共享可变状态。
Mock 不起作用。确认 jest.mock() 调用位于文件顶部,在任何 imports 之前。检查 Mock 的实现是否正确返回了测试所需的数据格式,特别是处理 Promise 时要注意使用 mockResolvedValue 而非直接返回值。
性能问题。如果测试套件运行缓慢,考虑将测试文件拆分为更小的单元,使用 test.only 只运行当前正在开发的测试,并为慢速 I/O 操作添加 Mock。
掌握 Jest 和 React Testing Library 的组合使用,能够让你在保持高效开发节奏的同时,确保代码质量和应用的稳定性。测试不是负担,而是你重构代码时的安全网、添加新功能时的定心丸。从今天开始,为你的每一个 React 组件编写测试吧。

暂无评论,快来抢沙发吧!