学完这篇,你能从零把 Jest + React Testing Library 跑起来,给一个组件写出「渲染、点击、输入、异步请求」四类测试,并知道新手最常卡在哪。
第一步:装依赖
如果你用的是 Create React App,Jest 和 RTL 已经内置,直接跳到第二步。手动搭的项目(Vite、自建 webpack)执行:
npm i -D jest jest-environment-jsdom babel-jest \
@babel/preset-env @babel/preset-react \
@testing-library/react @testing-library/jest-dom @testing-library/user-event
Vite 项目其实用 Vitest 更省事,但 Jest 生态更老、资料更多,公司项目里遇到的多半还是它。
根目录建 babel.config.js,让 Jest 认识 JSX:
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
['@babel/preset-react', { runtime: 'automatic' }],
],
};
注意:runtime: 'automatic' 很关键。少了它,组件里没写 import React 就会报 React is not defined。
第二步:配置 jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
};
再建 jest.setup.js,引入断言扩展:
import '@testing-library/jest-dom';
package.json 里加脚本:
"scripts": { "test": "jest", "test:watch": "jest --watch" }
注意:testEnvironment 必须是 jsdom,默认的 node 环境里没有 document,跑起来第一行就报 document is not defined。CSS 也要映射掉,否则 import './a.css' 会直接抛语法错误(需要装 identity-obj-proxy)。
第三步:写第一个测试
组件:
// Button.jsx
export default function Button({ onClick, children }) {
return <button onClick={onClick}>{children}</button>;
}
测试:
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import Button from './Button';
test('渲染按钮文案', () => {
render(<Button>提交</Button>);
expect(screen.getByRole('button', { name: '提交' })).toBeInTheDocument();
});
RTL 的核心思路是「像用户一样找元素」,查询优先级从高到低:
getByRole —— 最推荐,顺带验证了可访问性
getByLabelText —— 表单输入
getByPlaceholderText
getByText
getByTestId —— 兜底,前几种都做不到时再用
注意:别用 container.querySelector('.btn')。类名一改测试就红,这种测试维护成本比收益高。
第四步:模拟用户交互
用 userEvent 代替 fireEvent,它更接近真实浏览器行为(会触发 focus、hover 等一连串事件)。
import userEvent from '@testing-library/user-event';
test('点击触发回调', async () => {
const handle = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handle}>提交</Button>);
await user.click(screen.getByRole('button', { name: '提交' }));
expect(handle).toHaveBeenCalledTimes(1);
});
输入框同理:
await user.type(screen.getByLabelText('用户名'), 'react');
expect(screen.getByLabelText('用户名')).toHaveValue('react');
注意:userEvent.setup() 要在 render 之前调用,且所有 user.xxx() 都要 await。忘了 await,断言会在事件生效前执行,出现莫名其妙的失败。
第五步:处理异步请求
组件里 await fetch(...) 拿数据渲染,测试要用 findBy*(内部带等待重试):
test('加载完成后显示用户名', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({ json: () => Promise.resolve({ name: '张三' }) })
);
render(<UserCard />);
expect(await screen.findByText('张三')).toBeInTheDocument();
});
不是查询、而是断言别的东西时,用 waitFor:
await waitFor(() => expect(mockFn).toHaveBeenCalled());
注意:会出现 act(...) 警告,基本都源于「组件还没更新完你就断言了」。用 findBy* 或 waitFor 包住异步断言即可,不要靠 setTimeout 硬等。
第六步:mock 掉外部依赖
只测当前组件时,把请求层、路由、图片都换成替身:
jest.mock('../api/user', () => ({
getUser: jest.fn(() => Promise.resolve({ name: '张三' })),
}));
每个测试之间要清理:
afterEach(() => {
jest.clearAllMocks();
});
注意:如果你把实现的内部细节(某个 state 变量、某个私有函数)mock 掉,测试就不再能反映真实行为了——mock 的边界应停在「网络、时间、随机数」这类外部世界。
小结
- 依赖四件套:
jest、jest-environment-jsdom、@testing-library/react、@testing-library/jest-dom
testEnvironment: 'jsdom' + setupFilesAfterEnv 引 jest-dom,缺一个都跑不起来
- 查询优先
getByRole,交互统一 userEvent 并 await
- 异步用
findBy* / waitFor,别用定时器硬等
- 断言「用户看得见什么」,不要断言类名和内部 state
- 组件越小、职责越单一,测试越好写——写不动测试往往是组件设计的问题