最近在项目里用React Testing Library写测试,踩了不少坑,也积累了一些经验,今天就来写一篇实战文章,聊聊React Testing Library的使用方法,以及我踩过的那些坑,希望能给大家一些参考,让大家少走弯路。
一、为什么选择React Testing Library
先说说为什么选择React Testing Library,而不是其他的测试库,比如Enzyme。
以前,我们项目里用的是Enzyme来测试React组件,Enzyme功能很强大,能shallow渲染,能mount渲染,能访问组件的state和props,能模拟事件,用起来很方便。但是,用了一段时间之后,我们发现了一些问题。
首先,Enzyme的测试,很容易依赖组件的实现细节,比如测试的时候,会访问组件的state,会调用组件的方法,会检查组件的props,这样的测试,虽然能通过,但是一旦组件的实现变了,比如改了state的名字,改了方法的名字,测试就会失败,哪怕组件的行为没有变,用户用起来没有任何区别。这样的测试,很脆弱,维护成本很高。
其次,Enzyme的shallow渲染,虽然快,但是不会渲染子组件,很多时候测试不完整,一些集成的问题发现不了。而mount渲染,虽然完整,但是慢,而且容易有副作用,测试之间互相影响,很不稳定。
后来,我们了解到了React Testing Library,它的核心理念是,测试应该关注用户的行为,而不是组件的实现细节。它不提供访问组件state、props、方法的API,而是提供了一系列模拟用户行为的API,比如getByText、getByLabelText、getByRole、fireEvent、waitFor等,让你像用户一样去操作组件,去测试组件的行为。
这样的测试,有几个好处:
- 更可靠,因为测试的是用户的行为,而不是实现细节,组件的实现变了,只要行为没变,测试就不会失败,维护成本低。
- 更有价值,因为测试的是用户真正会用到的功能,能真正发现用户会遇到的问题,而不是一些实现细节的问题。
- 更简单,因为API很少,很容易上手,不需要学习很多复杂的概念,就能写出好的测试。
所以,我们决定把项目里的测试,从Enzyme迁移到React Testing Library,迁移的过程中,踩了不少坑,也积累了一些经验,下面就来分享一下。
二、环境搭建
先说说环境搭建,React Testing Library一般和Jest一起用,Jest是测试运行器,React Testing Library是测试工具库,两者配合使用。
首先,安装依赖:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest babel-jest @babel/core @babel/preset-env @babel/preset-react这里解释一下各个包的作用:
@testing-library/react:React Testing Library的核心包,提供渲染、查询、事件等API。@testing-library/jest-dom:提供了一些自定义的Jest匹配器,比如toBeInTheDocument、toHaveTextContent、toBeVisible等,让断言更简单,更易读。@testing-library/user-event:提供了更高级的用户事件模拟,比如type、click、hover等,比fireEvent更接近真实的用户行为。jest:测试运行器,负责运行测试,提供断言、mock等功能。babel-jest、@babel/core、@babel/preset-env、@babel/preset-react:Babel相关的包,用来转译JSX和ES6+代码,让Jest能理解。
然后,配置Babel,在项目根目录创建.babelrc文件:
{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}如果你的项目用了TypeScript,还要加上@babel/preset-typescript。
然后,配置Jest,在项目根目录创建jest.config.js文件:
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js'
}
};这里解释一下:
testEnvironment: 'jsdom':用jsdom作为测试环境,模拟浏览器的DOM环境,这样才能测试React组件。setupFilesAfterEnv:指定测试运行前的 setup 文件,在这个文件里,可以引入jest-dom,做一些全局的配置。moduleNameMapper:用来mock静态资源,比如CSS、图片等,因为Jest不理解这些文件,需要mock掉。
然后,创建jest.setup.js文件:
import '@testing-library/jest-dom';这样,就引入了jest-dom的自定义匹配器,可以在所有测试里用了。
如果你的项目用了CSS Module,或者用了一些全局的CSS,还需要做一些额外的配置,这里就不详细说了。
最后,在package.json里加上测试脚本:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}这样,环境就搭建好了,可以开始写测试了。
三、基本用法
环境搭好了,先说说基本用法,写一个简单的测试例子。
假设我们有一个简单的按钮组件,Button.js:
import React from 'react';
function Button({ onClick, children }) {
return (
<button onClick={onClick} className="btn">
{children}
</button>
);
}
export default Button;我们来写一个测试,Button.test.js:
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
test('按钮能正常渲染,并且点击时触发onClick', () => {
// 渲染组件
const handleClick = jest.fn();
render(<Button onClick={handleClick}>点击我</Button>);
// 查询元素,用getByText,通过文本内容找到按钮
const button = screen.getByText('点击我');
// 断言按钮在文档中
expect(button).toBeInTheDocument();
// 模拟点击事件
fireEvent.click(button);
// 断言onClick被调用了一次
expect(handleClick).toHaveBeenCalledTimes(1);
});这个测试很简单,但是展示了React Testing Library的基本用法:
- 用
render渲染组件。 - 用
screen.getByText查询元素,通过文本内容找到按钮。 - 用
expect做断言,判断元素是否在文档中。 - 用
fireEvent.click模拟用户点击。 - 用
jest.fn()创建mock函数,判断回调是否被调用。
这就是React Testing Library的基本用法,很简单,很直观,就像用户在操作一样。
四、常用的查询API
React Testing Library提供了很多查询API,用来查找元素,这是最常用的,也是最容易踩坑的地方,下面就来介绍一下常用的查询API。
1. getBy / queryBy / findBy 的区别
这是最容易混淆的,也是最容易踩坑的地方。React Testing Library的查询API,有三种前缀:getBy、queryBy、findBy,它们的区别是:
getBy:查询元素,如果找到了,返回元素;如果没找到,或者找到了多个,就会抛出错误,测试失败。适合用在元素一定存在的场景,如果不存在,就是bug,测试应该失败。queryBy:查询元素,如果找到了,返回元素;如果没找到,返回null,不会抛出错误。适合用在元素可能不存在的场景,比如测试元素是否被隐藏,是否不存在。findBy:异步查询元素,返回一个Promise,会等待元素出现,如果在超时时间内找到了,就resolve,返回元素;如果超时了还没找到,就reject,测试失败。适合用在异步渲染的场景,比如组件需要请求数据,数据回来之后才渲染元素。
而且,这三种前缀,都有对应的复数形式:getAllBy、queryAllBy、findAllBy,用来查询多个元素。
举个例子:
// 元素一定存在,用getBy
const button = screen.getByText('提交');
// 元素可能不存在,用queryBy
const errorMessage = screen.queryByText('错误信息');
expect(errorMessage).toBeNull(); // 断言错误信息不存在
// 异步渲染,用findBy
const asyncContent = await screen.findByText('加载完成');这个区别很重要,很多人踩坑,就是因为用错了前缀,比如异步渲染的元素,用了getBy,结果测试失败,因为元素还没渲染出来;或者元素可能不存在的场景,用了getBy,结果没找到元素,测试失败。
2. 常用的查询方式
除了前缀,还有后缀,也就是查询的方式,常用的有:
ByText:通过文本内容查询,适合查询按钮、链接、标题等有文本的元素。ByLabelText:通过label的文本查询,适合查询表单元素,比如input、select等,因为表单元素一般都有label,通过label查询,更符合用户的使用习惯。ByPlaceholderText:通过placeholder查询,适合查询没有label的input,通过placeholder文本查询。ByAltText:通过alt属性查询,适合查询img元素,通过alt文本查询。ByTitle:通过title属性查询,适合查询有title属性的元素。ByRole:通过ARIA role查询,适合查询各种元素,比如button、link、heading、checkbox等,这是最推荐的查询方式,因为它最接近用户的感知,用户就是通过角色来识别元素的。ByTestId:通过data-testid属性查询,这是最后的选择,当其他查询方式都不适用的时候,才用这个,因为它需要在组件里加data-testid属性,有点侵入性。
举个例子:
// 通过文本查询
const submitButton = screen.getByText('提交');
// 通过label查询表单元素
const nameInput = screen.getByLabelText('姓名');
// 通过placeholder查询
const searchInput = screen.getByPlaceholderText('请输入搜索内容');
// 通过alt查询图片
const logo = screen.getByAltText('Logo');
// 通过role查询
const button = screen.getByRole('button', { name: '提交' });
const heading = screen.getByRole('heading', { level: 1 });
// 通过testid查询
const customElement = screen.getByTestId('custom-element');这里要特别推荐ByRole,因为它最接近用户的感知,而且能同时检查元素的可访问性,比如如果一个按钮没有正确的role,或者没有可访问的名字,就查不到,这样能促使你写出更具可访问性的组件。
而且,ByRole还支持很多选项,比如name、level、checked、disabled等,能很精确地查询元素。
五、常见场景的测试方法
介绍完基本用法和查询API,再来说说几个常见场景的测试方法,这些都是我在项目里经常用到的。
1. 测试表单
表单是最常见的组件,也是测试的重点。测试表单,主要是测试输入、验证、提交等功能。
举个例子,一个登录表单:
import React, { useState } from 'react';
function LoginForm({ onSubmit }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
if (!username || !password) {
setError('用户名和密码不能为空');
return;
}
onSubmit({ username, password });
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username">用户名</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div>
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
{error && <div role="alert">{error}</div>}
<button type="submit">登录</button>
</form>
);
}
export default LoginForm;测试这个表单:
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import LoginForm from './LoginForm';
test('表单能正常输入和提交', () => {
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
// 输入用户名和密码
fireEvent.change(screen.getByLabelText('用户名'), {
target: { value: 'testuser' }
});
fireEvent.change(screen.getByLabelText('密码'), {
target: { value: 'testpass' }
});
// 点击登录按钮
fireEvent.click(screen.getByText('登录'));
// 断言onSubmit被调用,并且参数正确
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
username: 'testuser',
password: 'testpass'
});
});
test('表单验证:用户名和密码为空时显示错误', () => {
render(<LoginForm onSubmit={jest.fn()} />);
// 直接点击登录,不输入内容
fireEvent.click(screen.getByText('登录'));
// 断言错误信息显示
expect(screen.getByRole('alert')).toHaveTextContent('用户名和密码不能为空');
});这里要注意,输入表单元素的时候,用fireEvent.change,并且传入{ target: { value: 'xxx' } },模拟用户输入。如果用的是@testing-library/user-event,可以用userEvent.type(input, 'xxx'),更接近真实的用户输入,会触发keydown、keypress、keyup等事件,更可靠。
2. 测试异步组件
异步组件,就是需要请求数据,或者有异步操作的组件,这种组件的测试,需要用findBy或者waitFor来等待异步操作完成。
举个例子,一个用户信息组件,需要请求API获取数据:
import React, { useState, useEffect } from 'react';
import { fetchUser } from './api';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
fetchUser(userId)
.then((data) => {
setUser(data);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [userId]);
if (loading) return <div>加载中...</div>;
if (error) return <div role="alert">{error}</div>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
export default UserProfile;测试这个组件,需要mock API请求:
import React from 'react';
import { render, screen } from '@testing-library/react';
import UserProfile from './UserProfile';
import { fetchUser } from './api';
// mock整个api模块
jest.mock('./api');
test('加载成功时显示用户信息', async () => {
// mock fetchUser的返回值
fetchUser.mockResolvedValue({
name: '张三',
email: 'zhangsan@example.com'
});
render(<UserProfile userId={1} />);
// 先断言加载中显示
expect(screen.getByText('加载中...')).toBeInTheDocument();
// 等待用户信息显示,用findBy
const userName = await screen.findByText('张三');
expect(userName).toBeInTheDocument();
expect(screen.getByText('zhangsan@example.com')).toBeInTheDocument();
// 断言加载中消失了,用queryBy
expect(screen.queryByText('加载中...')).toBeNull();
});
test('加载失败时显示错误信息', async () => {
// mock fetchUser抛出错误
fetchUser.mockRejectedValue(new Error('网络错误'));
render(<UserProfile userId={1} />);
// 等待错误信息显示
const errorMessage = await screen.findByRole('alert');
expect(errorMessage).toHaveTextContent('网络错误');
});这里要注意几个点:
- 用
jest.mock('./api')mock整个API模块,然后用fetchUser.mockResolvedValue或者fetchUser.mockRejectedValue来mock返回值或者错误。 - 异步渲染的元素,用
findBy来等待,它会自动轮询,直到元素出现或者超时。 - 断言元素不存在的时候,用
queryBy,因为getBy找不到会报错。
3. 测试Context和Reducer
如果你的组件用了React Context,或者useReducer,测试的时候,需要把组件包裹在Provider里。
举个例子,一个用了Context的主题切换组件:
// ThemeContext.js
import React, { createContext, useContext, useState } from 'react';
const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme(theme === 'light' ? 'dark' : 'light');
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}
// ThemeButton.js
import React from 'react';
import { useTheme } from './ThemeContext';
function ThemeButton() {
const { theme, toggleTheme } = useTheme();
return (
<button onClick={toggleTheme}>
当前主题:{theme === 'light' ? '浅色' : '深色'}
</button>
);
}
export default ThemeButton;测试这个组件,需要包裹在ThemeProvider里:
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
import ThemeButton from './ThemeButton';
test('主题切换按钮能正常工作', () => {
render(
<ThemeProvider>
<ThemeButton />
</ThemeProvider>
);
// 初始是浅色主题
const button = screen.getByText('当前主题:浅色');
expect(button).toBeInTheDocument();
// 点击切换主题
fireEvent.click(button);
// 切换成深色主题
expect(screen.getByText('当前主题:深色')).toBeInTheDocument();
});如果很多测试都需要包裹Provider,可以写一个自定义的render函数,把Provider包裹进去:
// test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';
const AllTheProviders = ({ children }) => {
return <ThemeProvider>{children}</ThemeProvider>;
};
const customRender = (ui, options) =>
render(ui, { wrapper: AllTheProviders, ...options });
// 重新导出所有的testing-library的API
export * from '@testing-library/react';
// 覆盖原来的render
export { customRender as render };然后,在测试里,从test-utils导入render,就不用每次都包裹Provider了:
import { render, screen, fireEvent } from './test-utils';
import ThemeButton from './ThemeButton';
test('主题切换按钮能正常工作', () => {
render(<ThemeButton />);
// ...
});这个技巧很实用,尤其是当你的应用有很多Context的时候,能让测试代码更简洁。
六、那些年我踩过的坑
说了这么多用法,再来说说我踩过的那些坑,这些都是我在项目里实际遇到的,希望大家能避免。
坑一:用getBy查询异步渲染的元素,导致测试失败
这是最常见的坑,很多人刚开始用React Testing Library的时候,不知道getBy和findBy的区别,异步渲染的元素,用了getBy,结果测试失败,因为元素还没渲染出来。
比如,上面的UserProfile组件,如果你用screen.getByText('张三'),就会失败,因为数据还没回来,元素还没渲染。这时候,应该用await screen.findByText('张三'),它会等待元素出现。
所以,记住:同步渲染的元素,用getBy;异步渲染的元素,用findBy;元素可能不存在的,用queryBy。
坑二:测试之间互相影响,单独跑通过,一起跑失败
这个坑,很多人都遇到过,测试单独跑的时候都能通过,但是一起跑的时候,有的就失败了,这一般是因为测试之间有共享的状态,互相影响。
比如,你mock了一个模块,在一个测试里改了mock的返回值,没有重置,影响了下一个测试;或者,你在组件里用了全局的变量,测试之间没有清理;或者,你用了setTimeout、setInterval,没有清理,影响了下一个测试。
解决方法:
- 每个测试之后,用
jest.clearAllMocks()清理mock,或者用beforeEach重置mock。 - 用
afterEach(cleanup)清理渲染的组件,React Testing Library会自动清理,但是如果你用了自定义的render,要确保清理了。 - 用了定时器的,用
jest.useFakeTimers(),并且在测试结束后jest.useRealTimers(),或者用jest.clearAllTimers()清理。 - 尽量不要用全局的状态,每个测试都独立,不依赖其他测试的结果。
坑三:fireEvent和真实用户行为不一致,导致测试通过但是实际有问题
fireEvent,是React Testing Library提供的事件模拟工具,但是它只是触发了DOM事件,和真实的用户行为还是有区别的。比如,fireEvent.change(input, { target: { value: 'xxx' } }),只是触发了change事件,设置了value,但是不会触发keydown、keypress、keyup等事件,也不会触发输入框的真实输入行为,如果你的组件里监听了这些事件,或者有一些和输入相关的逻辑,fireEvent可能测试不出来问题。
解决方法:用@testing-library/user-event,它提供了更高级的用户事件模拟,比如userEvent.type(input, 'xxx'),会模拟真实的用户输入,触发keydown、keypress、keyup、input、change等事件,更接近真实的用户行为,也更可靠。
所以,推荐大家用@testing-library/user-event来模拟用户交互,而不是直接用fireEvent,除非是一些user-event不支持的事件。
坑四:用了太多的data-testid,导致测试和实现耦合
data-testid,是React Testing Library提供的最后的查询方式,当其他查询方式都不适用的时候,才用它。但是很多人,为了方便,不管什么元素,都加个data-testid,然后用getByTestId查询,这样的测试,虽然能通过,但是和实现耦合了,因为你需要在组件里加data-testid属性,如果组件改了,data-testid忘了改,测试就会失败。而且,这样的测试,也不关注用户的行为,用户根本看不到data-testid。
解决方法:尽量用用户能感知到的查询方式,比如ByRole、ByText、ByLabelText、ByPlaceholderText等,这些才是用户真正会用到的,也更符合React Testing Library的理念。只有当这些方式都不适用的时候,比如一些自定义的组件,没有文本,没有label,没有role,才用data-testid。
坑五:测试了太多的实现细节,导致测试很脆弱
这个坑,是从Enzyme迁移过来的人最容易犯的,因为Enzyme很容易访问组件的state、props、方法,所以很多人写测试的时候,会测试这些实现细节,比如测试state的值对不对,测试某个方法有没有被调用,测试props有没有传对。这样的测试,虽然能通过,但是很脆弱,组件的实现一变,测试就失败了,哪怕用户的行为没有变。
React Testing Library的理念,就是不要测试实现细节,要测试用户的行为。所以,写测试的时候,要问自己:用户会关心这个吗?用户会操作这个吗?如果答案是否定的,就不要测试。
比如,不要测试组件的state,要测试用户能看到的内容,比如渲染出来的文本、表单的值、错误信息等。不要测试组件的内部方法有没有被调用,要测试用户操作之后,界面有没有变化,回调有没有被调用。不要测试props有没有传对,要测试子组件渲染出来的内容对不对。
这样的测试,才是可靠的,有价值的,维护成本低的。
坑六:异步测试没有等待,导致测试不稳定
异步测试,是最容易不稳定的,有时候通过,有时候失败,这一般是因为没有正确地等待异步操作完成。比如,你用了setTimeout,但是没有等待它执行;你mock了API,但是没有等待数据渲染;你用了Promise,但是没有await。
解决方法:
- 异步渲染的元素,用findBy,它会自动等待。
- 用了定时器的,用
jest.useFakeTimers(),然后用jest.runAllTimers()或者jest.advanceTimersByTime()来推进时间,不要用真实的setTimeout等待。 - 有多个异步操作的,用
waitFor来等待条件满足,比如await waitFor(() => expect(mockFn).toHaveBeenCalled())。 - 不要用
setTimeout来等待异步操作,比如setTimeout(() => { expect(...) }, 1000),这样的测试很不稳定,应该用findBy或者waitFor。
七、最佳实践
最后,总结一些最佳实践,帮助大家写出更好的测试。
- 关注用户行为,不要关注实现细节:这是React Testing Library的核心理念,也是最重要的一点。测试应该像用户一样去操作组件,去测试用户能看到、能用到的功能,而不是测试组件的内部实现。
- 优先用ByRole查询:ByRole是最推荐的查询方式,因为它最接近用户的感知,还能检查可访问性。其次是ByText、ByLabelText、ByPlaceholderText等,最后才是ByTestId。
- 用user-event模拟用户交互:user-event比fireEvent更接近真实的用户行为,更可靠,推荐优先使用。
- 每个测试独立,不依赖其他测试:每个测试都应该能独立运行,不依赖其他测试的结果,也不影响其他测试。测试之间要清理状态,清理mock,清理定时器。
- 异步操作用findBy和waitFor:异步渲染的元素,用findBy等待;复杂的异步条件,用waitFor等待。不要用setTimeout等待,也不要用真实的时间等待。
- 不要测试太多,也不要测试太少:测试不是越多越好,也不是越少越好,要测试关键的功能,关键的用户路径,保证核心功能正常。不要测试一些无关紧要的细节,也不要漏掉关键的功能。
- 测试要易读,易维护:测试代码也是代码,也要写得清晰,易读,易维护。用有意义的测试名称,用清晰的步骤,用合适的注释,让别人能看懂你的测试在测什么。
- 持续集成,经常运行测试:写了测试,就要经常运行,保证测试一直通过。可以把测试加到CI/CD流程里,每次提交代码都自动运行测试,有问题及时发现,及时修复。
八、写在最后
React Testing Library,是一个非常优秀的React测试库,它的理念很先进,用法很简单,写出来的测试更可靠,更有价值,维护成本更低。如果你还在用Enzyme,或者还没有写测试,强烈推荐你试试React Testing Library,相信你一定会喜欢上它。
当然,React Testing Library也不是银弹,它也有自己的适用场景,也有一些不足,比如对于一些复杂的动画、拖拽、Canvas等,测试起来比较困难,这时候可能需要其他的工具,或者E2E测试来补充。但是,对于大部分的React组件,React Testing Library都能很好地胜任。
这篇文章,分享了我在使用React Testing Library过程中的一些经验和踩过的坑,希望能给大家一些参考,让大家少走弯路。如果大家有什么问题,或者有什么其他的经验,欢迎在评论区交流。
最后,测试是软件开发中非常重要的一环,它能保证代码的质量,能让我们重构的时候有信心,能让我们交付的时候更放心。希望大家都能重视测试,写出高质量的测试,交付高质量的软件。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录