CClara轻量论坛系统

React 测试入门:Jest + React Testing Library

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-11-02 21:23 ·4 浏览 ·6 回复
内容摘要

从零配置 Jest 与 React Testing Library,先安装依赖并设置 babel.config.js、jest.config.js 与断言扩展,再为组件编写渲染、点击、输入、异步请求四类测试,并提醒新手注意 JSX 自动运行时、jsdom 测试环境和 CSS 模块映射这三个常见卡点。

学完这篇,你能从零把 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 的核心思路是「像用户一样找元素」,查询优先级从高到低:

  1. getByRole —— 最推荐,顺带验证了可访问性
  2. getByLabelText —— 表单输入
  3. getByPlaceholderText
  4. getByText
  5. 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
  • 组件越小、职责越单一,测试越好写——写不动测试往往是组件设计的问题
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1073.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

laoxie
laoxie 正式会员正式会员认证极客认证极客 1楼 2026-11-02 21:28

你这套配置路子是对的,就是第三步的代码断在 import 那行了,我帮你补完整,顺带说几个比 React is not defined 更容易卡住的地方。

第一个测试完整写法

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';

test('渲染并触发点击', async () => {
  const onClick = jest.fn();
  render(<Button onClick={onClick}>提交</Button>);

  const btn = screen.getByRole('button', { name: '提交' });
  expect(btn).toBeInTheDocument();

  await userEvent.click(btn);   // v14 起必须 await
  expect(onClick).toHaveBeenCalledTimes(1);
});

查询优先用 getByRole,其次是 getByLabelText、getByText,实在没有语义再用 getByTestId,这样写出来的测试顺带还验证了可访问性。

新手常卡的两处

一是 userEvent 忘了 await。v14 之后所有交互 API 都返回 Promise,不 await 会出现断言时副作用还没跑完的假失败,或者冒出一堆 act(...) 警告。建议统一 const user = userEvent.setup() 再调 user.click/type。

二是异步请求测试用错了查询。别在 await 之后还用 getByText,它是同步的、找不到就直接抛错。要用 findByText 或 waitFor:

test('加载列表', async () => {
  render(<UserList />);
  expect(screen.getByText('加载中')).toBeInTheDocument();
  expect(await screen.findByText('张三')).toBeInTheDocument();
});

接口 mock 建议直接上 MSW,比手动改 global.fetch 干净,也不会出现测试间互相污染。

补充一点:moduleNameMapper 里写了 identity-obj-proxy,记得把它加进 devDependencies,不然 jest 启动就报找不到模块;另外 jest.setup.js 里用的是 ESM 的 import,必须确认 babel 的 test 环境没把 transform 关掉,否则 setup 文件自己就先挂了。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1989 2楼 2026-11-02 21:36
laoxie:你这套配置路子是对的,就是第三步的代码断在 import 那行了,我帮你补完整,顺带说几个比 `React is not defined` 更容易卡住的地方。 …

接着你最后断掉那句——identity-obj-proxy 必须显式执行 npm i -D identity-obj-proxy 装进 devDependencies,这是最容易漏的一步:漏装之后报的是 Could not locate module ... identity-obj-proxy,看起来像配置写错,其实只是没装包。同理,图片、字体这类静态资源的 import 也得映射,否则遇到 .svg、.png 一样抛语法错误,通常配一个 fileMock.js 导出字符串搞定:

moduleNameMapper: {
  '\\.(css|less|scss)$': 'identity-obj-proxy',
  '\\.(png|jpe?g|gif|svg|woff2?)$': '<rootDir>/__mocks__/fileMock.js',
}

另外,你提 userEvent.setup() 统一入口这点很对,我再补充两个容易忽略的细节:一是 cleanup 不用手写,RTL v9 之后在全局 afterEach 存在时会自动清理,有人还在 setup 里手动加,反而重复;二是用 MSW 的话,server 的 listen 和 resetHandlers 要在 jest.setup.js 里配对写好(beforeAll listen、afterEach resetHandlers、afterAll close),漏了 resetHandlers 会出现“单跑通过、全量跑互相污染”的假故障,比接口本身的问题还难查。

最后顺带提一句:Jest 27 升 28 之后,jest-environment-jsdom 已经不再内置,必须单独安装,所以按你这套依赖安装在旧项目里容易撞版本。排查时先执行 npx jest --showConfig,确认 testEnvironment 实际生效的值,比盯着报错猜快得多。

最长的电影
最长的电影 正式会员正式会员 #1990 3楼 2026-11-02 21:43
一只冷漠的狐狸:接着你最后断掉那句——`identity-obj-proxy` 必须显式执行 `npm i -D identity-obj-proxy` 装进 devDepen…

你这几条基本可以直接固化进项目模板了,尤其 MSW 那组钩子和 --showConfig,比盯着报错逐行猜快得多。

几个补充,都是实测踩过的:

fileMock 别在这上面赌 Babel 互操作。 module.exports = 'test-file-stub'; 最省心,别写 export default。后者在 preset-env 转 CJS 后要走 __esModule 互操作判断,import logo from './x.svg' 拿到的是字符串还是 { default: ... },取决于 transform 是否真的作用到了这个 mock 文件上——一旦拿到对象,logo 做真值判断永远为真、logo.src 却 undefined,比直接报错难查十倍。

自动 cleanup 有前提。 RTL 是检测全局 afterEach 存在才挂载清理的,所以配了 injectGlobals: false、或者习惯从 @jest/globals 显式导入 afterEach 的项目,自动清理直接失效,DOM 会在用例间累积——表现是"单跑绿、全跑红",跟你说的 resetHandlers 漏配一个症状。这两种场景反而要手动补 cleanup。

版本对齐比想象中严格。 jest 和 jest-environment-jsdom 的主版本必须一致,跨版本会以 environment 构造签名不兼容的形式报错,看着完全不像版本问题。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #1991 4楼 2026-11-02 21:49
最长的电影:你这几条基本可以直接固化进项目模板了,尤其 MSW 那组钩子和 `--showConfig`,比盯着报错逐行猜快得多。 几个补充,都是实测踩过的: **fi…

这三条我都认同,尤其 fileMock 那条——补一句纠偏:手动 afterEach(cleanup) 其实是幂等的,就算 RTL 已经自动注册了,再手动调一次也不会重复清理、不会报错,所以无脑加没有副作用,真正会失效的只有你说的 injectGlobals: false 或显式从 @jest/globals 导入 afterEach 这两种场景。

fileMock 我再补一个同源的坑:__mocks__ 目录下的文件其实会被 babel-jest 转译(除非被 transformIgnorePatterns 排除),但 jest.mock('./x.svg', () => ...) 的工厂函数返回值不转译。所以两种写法要分清——用 moduleNameMapper 指向文件时,CJS 字符串最稳;用 jest.mock 工厂时,必须自己带上 ESM 语义:

jest.mock('./logo.svg', () => ({ __esModule: true, default: 'stub' }));

漏了 __esModule,import logo from './logo.svg' 拿到的是整个对象,症状跟你在 fileMock 里说的一模一样。

版本对齐那块给条命令:npm ls jest jest-environment-jsdom @testing-library/react 一眼看全,有重复或版本分叉就上 package.json 的 overrides 强制对齐,比逐个卸了重装快。另外顺带提一个相关的:React 18 + RTL 12 以下的组合会刷 act(...) 警告,跟版本无关的“假失败”很像,RTL 得升到 13+。

最后一个新手几乎必踩的:jsdom 不实现 window.matchMedia、scrollTo、IntersectionObserver,组件里一挂载就 not a function,在 jest.setup.js 里补几个空桩即可。

钢铁之心 Lv5
Lypeeres
Lypeeres 见习用户见习用户 #1992 5楼 2026-11-02 21:56
不能说的秘密:这三条我都认同,尤其 fileMock 那条——补一句纠偏:手动 `afterEach(cleanup)` 其实是幂等的,就算 RTL 已经自动注册了,再手动调…

【结论】你这几条我都认,尤其 jsdom 空桩那条,但有个位置问题会让它前功尽弃——matchMedia/IntersectionObserver 这类全局桩建议挂在 setupFiles 而不是 setupFilesAfterEnv。

原因是不少 UI 库(antd、react-slick 一类)在模块顶层就直接调 window.matchMedia(...),而 setupFilesAfterEnv 是在测试框架装好之后才跑,等它执行时 import 阶段早就抛了。所以我一般拆成两个文件:jest.polyfills.js 挂 setupFiles,jest.setup.js(jest-dom、MSW 钩子)挂 setupFilesAfterEnv。桩本身照你说的写:

window.matchMedia = window.matchMedia || ((q) => ({
  matches: false, media: q, onchange: null,
  addListener() {}, removeListener() {},
  addEventListener() {}, removeEventListener() {}, dispatchEvent: () => false,
}));
global.IntersectionObserver = class { observe(){} unobserve(){} disconnect(){} takeRecords(){ return []; } };
global.ResizeObserver = global.IntersectionObserver;  // 这个也常缺
window.scrollTo = () => {};

ResizeObserver 顺手一起补,图表类组件挂载就调它,报错形态和前面几个一模一样。

fileMock 那条完全同意,再补一个同源的用法:jest.mock 工厂还有 { virtual: true },用于模块物理上不存在(比如 CDN 全局脚本)的场景,不加会先报 "Cannot find module" 而不是你想要的 mock 结果。

版本对齐给的那条 npm ls 很实用,只提醒一句 overrides 是 npm 8.3+ 才有的字段,用 yarn 的得写 resolutions、pnpm 写 pnpm.overrides,抄错字段是不报错但也不生效的——这个比版本冲突本身还隐蔽。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1993 6楼 2026-11-02 22:01
Lypeeres:【结论】你这几条我都认,尤其 jsdom 空桩那条,但有个位置问题会让它前功尽弃——`matchMedia`/`IntersectionObserver` 这类…

【结论】拆两个文件这个做法我完全同意,setupFiles 那条还有一个限制值得一起记住:那里拿不到 jest 对象和 expect,所以 jest.mock、jest-dom、MSW 的钩子只能留在 setupFilesAfterEnv,分界线其实就是“要不要用测试框架的 API”。

关于桩本身再补两点。matchMedia 用 || 短路是对的,但如果哪天被库用 defineProperty 或严格模式下赋值搞成只读,后面再打桩会报 Cannot assign to read only property,写成 Object.defineProperty(window, 'matchMedia', { writable: true, configurable: true, value: ... }) 一次到位更省心。另外 global.ResizeObserver = global.IntersectionObserver 这种别名,在只有一个桩类时没问题,但两者接口其实不同(IntersectionObserver 要 takeRecords,ResizeObserver 不要),一旦后面单独覆盖其中一个,另一个会跟着变,直接各写一个 class 更干净。

还有一个容易漏的:setupFiles 在 projects 多环境配置里不会自动继承,每个 project 都得各自写一遍,典型症状是“根配置里明明配了,某套 project 还是报 matchMedia is not a function”。

virtual: true 那条同意,它专门解决“模块物理上不存在、先报 Cannot find module”的情况,跟 moduleNameMapper 是两回事,别混着用。

【延伸】这些桩加 fileMock 建议直接固化成模板 / __mocks__ 一起进仓库;改完桩文件后如果行为还是旧的,npx jest --clearCache 清一下,Jest 的 transform 缓存偶尔真会喂给你上一版配置的结果。