通过本完整指南精通 React Testing Library (RTL)。学习为您的 React 应用编写有效、可维护、以用户为中心的测试,重点介绍最佳实践和真实案例。
React Testing Library:全面指南
在当今快节奏的 Web 开发领域,确保 React 应用的质量和可靠性至关重要。React Testing Library (RTL) 已成为一种流行且有效的解决方案,用于编写关注用户视角的测试。本指南全面概述了 RTL,涵盖了从基本概念到高级技巧的所有内容,使您能够构建健壮且可维护的 React 应用。
为什么选择 React Testing Library?
传统的测试方法通常依赖于实现细节,这使得测试变得脆弱,并且容易因微小的代码更改而中断。而 RTL 则鼓励您像用户与组件交互那样来测试组件,专注于用户看到和体验到的内容。这种方法有几个关键优势:
- 以用户为中心的测试: RTL 提倡编写反映用户视角的测试,确保您的应用从最终用户的角度来看功能正常。
- 减少测试的脆弱性: 通过避免测试实现细节,RTL 测试在您重构代码时不太可能中断,从而使测试更易于维护和更健壮。
- 改进代码设计: RTL 鼓励您编写易于访问和使用的组件,从而带来更好的整体代码设计。
- 关注可访问性: RTL 使测试组件的可访问性变得更容易,确保您的应用对每个人都可用。
- 简化的测试流程: RTL 提供了一个简单直观的 API,使编写和维护测试变得更加容易。
设置您的测试环境
在开始使用 RTL 之前,您需要设置您的测试环境。这通常涉及安装必要的依赖项并配置您的测试框架。
先决条件
- Node.js 和 npm (或 yarn): 确保您的系统上已安装 Node.js 和 npm (或 yarn)。您可以从 Node.js 官方网站下载它们。
- React 项目: 您应该有一个现有的 React 项目,或使用 Create React App 或类似工具创建一个新项目。
安装
使用 npm 或 yarn 安装以下软件包:
npm install --save-dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react
或者,使用 yarn:
yarn add --dev @testing-library/react @testing-library/jest-dom jest babel-jest @babel/preset-env @babel/preset-react
软件包说明:
- @testing-library/react: 用于测试 React 组件的核心库。
- @testing-library/jest-dom: 提供用于断言 DOM 节点的自定义 Jest 匹配器。
- Jest: 一个流行的 JavaScript 测试框架。
- babel-jest: 一个 Jest 转换器,使用 Babel 来编译您的代码。
- @babel/preset-env: 一个 Babel 预设,用于确定支持目标环境所需的 Babel 插件和预设。
- @babel/preset-react: 用于 React 的 Babel 预设。
配置
在项目根目录下创建一个 `babel.config.js` 文件,内容如下:
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react'],
};
更新您的 `package.json` 文件以包含一个测试脚本:
{
"scripts": {
"test": "jest"
}
}
在项目根目录下创建一个 `jest.config.js` 文件来配置 Jest。一个最小化的配置可能如下所示:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['/src/setupTests.js'],
};
创建一个 `src/setupTests.js` 文件,内容如下。这可以确保 Jest DOM 匹配器在您所有的测试中都可用:
import '@testing-library/jest-dom/extend-expect';
编写您的第一个测试
我们从一个简单的例子开始。假设您有一个显示问候消息的 React 组件:
// src/components/Greeting.js
import React from 'react';
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
现在,让我们为这个组件编写一个测试:
// src/components/Greeting.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import Greeting from './Greeting';
test('渲染一条问候消息', () => {
render(<Greeting name="World" />);
const greetingElement = screen.getByText(/Hello, World!/i);
expect(greetingElement).toBeInTheDocument();
});
说明:
- `render`: 此函数将组件渲染到 DOM 中。
- `screen`: 此对象提供了查询 DOM 的方法。
- `getByText`: 此方法通过其文本内容查找元素。`/i` 标志使搜索不区分大小写。
- `expect`: 此函数用于对组件的行为进行断言。
- `toBeInTheDocument`: 此匹配器断言元素存在于 DOM 中。
要运行测试,请在终端中执行以下命令:
npm test
如果一切配置正确,测试应该会通过。
常见的 RTL 查询
RTL 提供了多种查询方法用于在 DOM 中查找元素。这些查询旨在模仿用户与您的应用交互的方式。
`getByRole`
此查询通过元素的 ARIA role 查找元素。尽可能使用 `getByRole` 是一个好习惯,因为它能促进可访问性,并确保您的测试对底层 DOM 结构的更改具有弹性。
<button role="button">Click me</button>
const buttonElement = screen.getByRole('button');
expect(buttonElement).toBeInTheDocument();
`getByLabelText`
此查询通过关联标签的文本查找元素。它对于测试表单元素很有用。
<label htmlFor="name">Name:</label>
<input type="text" id="name" />
const nameInputElement = screen.getByLabelText('Name:');
expect(nameInputElement).toBeInTheDocument();
`getByPlaceholderText`
此查询通过元素的占位符文本查找元素。
<input type="text" placeholder="Enter your email" />
const emailInputElement = screen.getByPlaceholderText('Enter your email');
expect(emailInputElement).toBeInTheDocument();
`getByAltText`
此查询通过图片的 alt 文本查找图片元素。为所有图片提供有意义的 alt 文本以确保可访问性非常重要。
<img src="logo.png" alt="Company Logo" />
const logoImageElement = screen.getByAltText('Company Logo');
expect(logoImageElement).toBeInTheDocument();
`getByTitle`
此查询通过元素的 title 属性查找元素。
<span title="Close">X</span>
const closeElement = screen.getByTitle('Close');
expect(closeElement).toBeInTheDocument();
`getByDisplayValue`
此查询通过元素的显示值查找元素。这对于测试带有预填充值的表单输入很有用。
<input type="text" value="Initial Value" />
const inputElement = screen.getByDisplayValue('Initial Value');
expect(inputElement).toBeInTheDocument();
`getAllBy*` 查询
除了 `getBy*` 查询,RTL 还提供了 `getAllBy*` 查询,它们返回一个匹配元素的数组。当您需要断言 DOM 中存在多个具有相同特征的元素时,这很有用。
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
const listItems = screen.getAllByRole('listitem');
expect(listItems).toHaveLength(3);
`queryBy*` 查询
`queryBy*` 查询与 `getBy*` 查询类似,但如果没有找到匹配的元素,它们会返回 `null` 而不是抛出错误。当您想断言某个元素*不*存在于 DOM 中时,这很有用。
const missingElement = screen.queryByText('Non-existent text');
expect(missingElement).toBeNull();
`findBy*` 查询
`findBy*` 查询是 `getBy*` 查询的异步版本。它们返回一个 Promise,在找到匹配元素时解析。这对于测试异步操作(例如从 API 获取数据)很有用。
// 模拟异步数据获取
const fetchData = () => new Promise(resolve => {
setTimeout(() => resolve('Data Loaded!'), 1000);
});
function MyComponent() {
const [data, setData] = React.useState(null);
React.useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}
test('异步加载数据', async () => {
render(<MyComponent />);
const dataElement = await screen.findByText('Data Loaded!');
expect(dataElement).toBeInTheDocument();
});
模拟用户交互
RTL 提供了 `fireEvent` 和 `userEvent` API 来模拟用户交互,例如点击按钮、在输入字段中键入内容以及提交表单。
`fireEvent`
`fireEvent` 允许您以编程方式触发 DOM 事件。它是一个较低级的 API,可让您对触发的事件进行精细控制。
<button onClick={() => alert('Button clicked!')}>Click me</button>
import { fireEvent } from '@testing-library/react';
test('模拟按钮点击', () => {
const alertMock = jest.spyOn(window, 'alert').mockImplementation(() => {});
render(<button onClick={() => alert('Button clicked!')}>Click me</button>);
const buttonElement = screen.getByRole('button');
fireEvent.click(buttonElement);
expect(alertMock).toHaveBeenCalledTimes(1);
alertMock.mockRestore();
});
`userEvent`
`userEvent` 是一个更高级别的 API,可以更真实地模拟用户交互。它处理诸如焦点管理和事件顺序之类的细节,使您的测试更健壮、更不容易出错。
<input type="text" onChange={e => console.log(e.target.value)} />
import userEvent from '@testing-library/user-event';
test('模拟在输入字段中键入', () => {
const inputElement = screen.getByRole('textbox');
userEvent.type(inputElement, 'Hello, world!');
expect(inputElement).toHaveValue('Hello, world!');
});
测试异步代码
许多 React 应用都涉及异步操作,例如从 API 获取数据。RTL 提供了多种工具来测试异步代码。
`waitFor`
`waitFor` 允许您在进行断言之前等待某个条件变为真。这对于测试需要一些时间才能完成的异步操作很有用。
function MyComponent() {
const [data, setData] = React.useState(null);
React.useEffect(() => {
setTimeout(() => {
setData('Data loaded!');
}, 1000);
}, []);
return <div>{data}</div>;
}
import { waitFor } from '@testing-library/react';
test('等待数据加载', async () => {
render(<MyComponent />);
await waitFor(() => screen.getByText('Data loaded!'));
const dataElement = screen.getByText('Data loaded!');
expect(dataElement).toBeInTheDocument();
});
`findBy*` 查询
如前所述,`findBy*` 查询是异步的,并返回一个在找到匹配元素时解析的 Promise。这对于测试导致 DOM 更改的异步操作很有用。
测试 Hooks
React Hooks 是封装了有状态逻辑的可复用函数。RTL 提供了 `@testing-library/react-hooks` 中的 `renderHook` 工具(自 v17 起已弃用,推荐直接使用 `@testing-library/react`)来独立测试自定义 Hooks。
// src/hooks/useCounter.js
import { useState } from 'react';
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => {
setCount(prevCount => prevCount + 1);
};
const decrement = () => {
setCount(prevCount => prevCount - 1);
};
return { count, increment, decrement };
}
export default useCounter;
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
test('增加计数器', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
说明:
- `renderHook`: 此函数渲染 Hook 并返回一个包含 Hook 结果的对象。
- `act`: 此函数用于包装任何导致状态更新的代码。这可确保 React 能够正确地批处理和处理更新。
高级测试技巧
一旦您掌握了 RTL 的基础知识,就可以探索更高级的测试技巧,以提高测试的质量和可维护性。
模拟模块
有时,您可能需要模拟外部模块或依赖项,以便在测试期间隔离组件并控制其行为。Jest 为此提供了强大的模拟 API。
// src/api/dataService.js
export const fetchData = async () => {
const response = await fetch('/api/data');
const data = await response.json();
return data;
};
// src/components/MyComponent.js
import React, { useState, useEffect } from 'react';
import { fetchData } from '../api/dataService';
function MyComponent() {
const [data, setData] = useState(null);
useEffect(() => {
fetchData().then(setData);
}, []);
return <div>{data}</div>;
}
// src/components/MyComponent.test.js
import { render, screen, waitFor } from '@testing-library/react';
import MyComponent from './MyComponent';
import * as dataService from '../api/dataService';
jest.mock('../api/dataService');
test('从 API 获取数据', async () => {
dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' });
render(<MyComponent />);
await waitFor(() => screen.getByText('Mocked data!'));
expect(screen.getByText('Mocked data!')).toBeInTheDocument();
expect(dataService.fetchData).toHaveBeenCalledTimes(1);
});
说明:
- `jest.mock('../api/dataService')`: 此行模拟了 `dataService` 模块。
- `dataService.fetchData.mockResolvedValue({ message: 'Mocked data!' })`: 此行配置了被模拟的 `fetchData` 函数,使其返回一个用指定数据解析的 Promise。
- `expect(dataService.fetchData).toHaveBeenCalledTimes(1)`: 此行断言被模拟的 `fetchData` 函数被调用了一次。
Context Providers
如果您的组件依赖于 Context Provider,您需要在测试期间将组件包装在该 provider 中。这可确保组件能够访问 context 的值。
// src/contexts/ThemeContext.js
import React, { createContext, useState } from 'react';
export const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(prevTheme => (prevTheme === 'light' ? 'dark' : 'light'));
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// src/components/MyComponent.js
import React, { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';
function MyComponent() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div style={{ backgroundColor: theme === 'light' ? '#fff' : '#000', color: theme === 'light' ? '#000' : '#fff' }}>
<p>Current theme: {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
);
}
// src/components/MyComponent.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import MyComponent from './MyComponent';
import { ThemeProvider } from '../contexts/ThemeContext';
test('切换主题', () => {
render(
<ThemeProvider>
<MyComponent />
</ThemeProvider>
);
const themeParagraph = screen.getByText(/Current theme: light/i);
const toggleButton = screen.getByRole('button', { name: /Toggle Theme/i });
expect(themeParagraph).toBeInTheDocument();
fireEvent.click(toggleButton);
expect(screen.getByText(/Current theme: dark/i)).toBeInTheDocument();
});
说明:
- 我们在测试期间将 `MyComponent` 包装在 `ThemeProvider` 中,以提供必要的 context。
使用 Router 进行测试
在测试使用 React Router 的组件时,您需要提供一个模拟的 Router context。您可以使用 `react-router-dom` 中的 `MemoryRouter` 组件来实现这一点。
// src/components/MyComponent.js
import React from 'react';
import { Link } from 'react-router-dom';
function MyComponent() {
return (
<div>
<Link to="/about">Go to About Page</Link>
</div>
);
}
// src/components/MyComponent.test.js
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import MyComponent from './MyComponent';
test('渲染一个指向 about 页面的链接', () => {
render(
<MemoryRouter>
<MyComponent />
</MemoryRouter>
);
const linkElement = screen.getByRole('link', { name: /Go to About Page/i });
expect(linkElement).toBeInTheDocument();
expect(linkElement).toHaveAttribute('href', '/about');
});
说明:
- 我们将 `MyComponent` 包装在 `MemoryRouter` 中以提供一个模拟的 Router context。
- 我们断言链接元素具有正确的 `href` 属性。
编写有效测试的最佳实践
以下是使用 RTL 编写测试时应遵循的一些最佳实践:
- 关注用户交互: 编写模拟用户如何与您的应用交互的测试。
- 避免测试实现细节: 不要测试组件的内部工作原理。相反,应关注可观察的行为。
- 编写清晰简洁的测试: 使您的测试易于理解和维护。
- 使用有意义的测试名称: 选择能准确描述被测行为的测试名称。
- 保持测试隔离: 避免测试之间的依赖关系。每个测试都应该是独立和自包含的。
- 测试边缘情况: 不要只测试正常路径。确保也测试边缘情况和错误条件。
- 先写测试后写代码: 考虑使用测试驱动开发(TDD)在编写代码之前编写测试。
- 遵循 "AAA" 模式: Arrange, Act, Assert (安排,行动,断言)。此模式有助于组织您的测试,使其更具可读性。
- 保持测试快速: 缓慢的测试会阻碍开发人员频繁运行它们。通过模拟网络请求和最小化 DOM 操作来优化测试速度。
- 使用描述性的错误消息: 当断言失败时,错误消息应提供足够的信息以快速识别失败原因。
结论
React Testing Library 是一个强大的工具,用于为您的 React 应用编写有效、可维护且以用户为中心的测试。通过遵循本指南中概述的原则和技巧,您可以构建满足用户需求的健壮可靠的应用。请记住,要从用户的角度进行测试,避免测试实现细节,并编写清晰简洁的测试。通过拥抱 RTL 并采纳最佳实践,无论您身在何处或您的全球受众有何特定要求,您都可以显著提高 React 项目的质量和可维护性。