如何在 React 中进行单元测试?(Jest + React Testing Library)
在 React 中进行单元测试,目前业界最标准的组合是 Jest(测试运行器/断言库)加上 React Testing Library (RTL)(用于渲染组件并模拟用户行为)。
RTL 的核心哲学是:“你的测试越像软件的使用方式,它们就越能给你信心。” 这意味着你应该测试用户看到的内容(如文本、按钮),而不是代码的实现细节(如 state 状态、类名)。
以下是一份循序渐进的实战指南。
1. 环境搭建
如果你是使用 Create React App 或 Vite 创建的项目,这些库通常已经预装好了。
如果是手动配置,你需要安装以下依赖:
bash
npm install --save-dev jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
- Jest: 运行测试的框架。
- @testing-library/react: 渲染 React 组件。
- @testing-library/jest-dom: 提供了一组实用的自定义匹配器(如
toBeInTheDocument)。 - @testing-library/user-event: 模拟真实的用户浏览器交互(比内置的
fireEvent更强大)。
2. 基础测试:渲染与断言
假设我们有一个简单的组件 Greeting.js:
jsx
// Greeting.js
import React from 'react';
const Greeting = ({ name }) => {
return <h1>Hello, {name}!</h1>;
};
export default Greeting;
编写测试 (Greeting.test.js):
javascript
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom'; // 引入扩展的断言库
import Greeting from './Greeting';
test('renders correct greeting message', () => {
// 1. 渲染组件
render(<Greeting name="World" />);
// 2. 查询元素 (模拟用户寻找屏幕上的文字)
// 使用正则 /Hello, World!/i 忽略大小写
const greetingElement = screen.getByText(/Hello, World!/i);
// 3. 断言 (判断结果是否符合预期)
expect(greetingElement).toBeInTheDocument();
});
3. 交互测试:点击与输入
测试用户交互时,推荐使用 user-event。
假设有一个计数器组件 Counter.js:
jsx
// Counter.js
import React, { useState } from 'react';
const Counter = () => {
const [count, setCount] = useState(0);
return (
<div>
<p>Current Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
};
export default Counter;
编写测试 (Counter.test.js):
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import Counter from './Counter';
test('increments count when button is clicked', async () => {
// 初始化 userEvent
const user = userEvent.setup();
render(<Counter />);
// 验证初始状态
const countElement = screen.getByText(/Current Count: 0/i);
expect(countElement).toBeInTheDocument();
// 找到按钮
// getByRole 是首选查询方式,因为它最贴近无障碍阅读器和用户的使用习惯
const button = screen.getByRole('button', { name: /increment/i });
// 模拟点击 (这是一个异步操作)
await user.click(button);
// 验证点击后的状态
expect(screen.getByText(/Current Count: 1/i)).toBeInTheDocument();
});
4. 异步测试:模拟 API 请求
在单元测试中,我们不应该发起真实的 HTTP 请求。我们需要 Mock(模拟)数据。
假设有一个用户列表组件 UserList.js:
jsx
// UserList.js
import React, { useEffect, useState } from 'react';
const UserList = () => {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then((res) => res.json())
.then((data) => setUsers(data));
}, []);
if (users.length === 0) return <div>Loading...</div>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
};
export default UserList;
编写测试 (UserList.test.js):
javascript
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserList from './UserList';
// 模拟 fetch 函数
global.fetch = jest.fn();
test('renders users after fetching', async () => {
// 1. 设定 Mock 返回的数据
const mockUsers = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
fetch.mockResolvedValueOnce({
json: async () => mockUsers,
});
render(<UserList />);
// 2. 验证 Loading 状态是否出现
expect(screen.getByText(/Loading.../i)).toBeInTheDocument();
// 3. 等待异步元素出现
// 注意:这里必须用 findBy...,因为它会等待元素出现(默认超时 1000ms)
// getBy... 如果找不到会立即报错
const userAlice = await screen.findByText('Alice');
expect(userAlice).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
// 验证 fetch 被调用了一次
expect(fetch).toHaveBeenCalledTimes(1);
});
5. 核心概念速查表
在使用 RTL 时,最让人困惑的是选择哪种查询方式(Query)。请遵循以下优先级:
A. 查询类型 (Query Types)
getBy...:- 用于查找肯定存在的元素。
- 如果找不到,测试直接失败(抛出错误)。
- 示例:
getByText,getByRole.
queryBy...:- 用于断言元素不存在。
- 如果找不到,返回
null(不会报错)。 - 示例:
expect(screen.queryByText(/error/i)).not.toBeInTheDocument().
findBy...:- 用于异步操作(如 API 调用后)。
- 返回一个 Promise,会重试直到元素出现或超时。
- 必须配合
await使用。
B. 查询优先级 (Priority)
getByRole: 最推荐。如getByRole('button', {name: 'Submit'})。这能确保你的应用对屏幕阅读器友好。getByLabelText: 适用于表单输入框。getByPlaceholderText: 适用于表单。getByText: 适用于非交互式文本(div, span, p)。getByTestId: 最后手段。当以上都无法定位时,在代码里加data-testid="custom-element",然后用此方法查找。
6. 最佳实践总结
- 不要测试实现细节:不要去测组件内部的
state是什么,或者组件用了什么class。要测用户看到了什么,点击了什么。 - 使用
screen对象:不要解构render的返回值,直接import { screen } from ...并在全局使用它,代码更简洁。 - 多用
user-event:尽量少用fireEvent,因为user-event更真实地模拟了浏览器行为(例如:点击前会触发 hover,输入前会触发 focus)。 - Mock 外部依赖:所有的 API 请求、第三方复杂的库(如地图、图表)都应该被 Mock,保证单元测试的独立性和速度。
通过掌握以上模式,你就可以覆盖 React 开发中 90% 的单元测试场景了。