资讯动态

React单元测试选型与实战:Jest、Vitest和Testing Library配置指南

发布时间:2026/10/15 12:11:58 来源:尧图企业网站定制
做 React 项目到现在单元测试这块我前前后后折腾了不少时间。早期项目用 Enzyme 写测试配置吃劲、API 别扭不说React 官方后来慢慢转向了函数组件和 hooksEnzyme 的支持力度肉眼可见地下滑社区里骂声一片。后来换了方案从 Jest 到 Testing Library再到最近团队里有人推 Vitest整个选型过程踩了不少坑。这篇就把我的框架选型思路、落地配置、典型测试写法和常见翻车现场一次性讲清楚。写这篇东西之前我在团队内部做过一次分享当时整理了一张对照表从成本、性能、断言能力、组件测试友好度几个维度做了评估。测试框架选型这件事本质不是“哪个最流行”而是“哪个和你项目的 React 版本、构建工具、团队维护能力匹配”。看完这篇你至少能解决三件事知道该优先用哪套组合拿到一份可以直接抄的配置文件以后遇到 act 警告和异步测试不稳定不再拍脑袋。1. 框架选型不是拍脑袋先搞清楚要测什么1.1 单元测试在 React 项目里的真实边界很多新人一上来就问“React 单元测试用 Jest 还是 Mocha”这个问题本身就问歪了。React 单元测试这几年已经不仅仅是“写一段 JS 断言函数输出”更多是组件渲染、用户交互、异步状态变化这三类内容。我习惯把 React 项目里的自动化测试分成四层纯逻辑层工具函数、状态计算、数据处理不依赖 DOM测试成本最低。组件渲染层验证组件在给定 props 下渲染出期望的结构、文本、属性。交互层点击按钮、输入表单、触发事件后 UI 是否正确更新。集成层多个组件配合、路由跳转、接口请求后整体行为是否符合预期。真正的单元测试重点在前两层。交互层和集成层虽然也经常写在单元测试文件里但严格来说它们已经偏向组件测试。框架选型的时候不要指望一个框架把四层全部完美覆盖。我的经验是核心逻辑用轻量 runner 加断言库组件相关用 Jest 或者 Vitest 配合 Testing Library端到端场景另开一条线。理解了这个边界再去选框架逻辑就顺了。没人应该因为别人都在用某个工具就盲从但 React 官方几年下来推荐的 Testing Library 路线确实是对的。1.2 三个候选方案的优劣势盘点目前项目里能选的三个主流方向我用真实对比过的方式来说避免单纯背文档。Jest 是事实上的老大哥。它自带断言库、mock 能力、覆盖率统计生态最全。React Testing Library 的作者也长期基于 Jest 编写示例。如果说项目里有老代码、历史包袱Jest 的兼容性通常是最好的。缺点是初始化速度在大型 monorepo 里越来越慢watch 模式冷启动体感很明显内存占用也不小。Vitest 是 Vite 时代的原生产物。它复用了 Vite 的 transform 和依赖预构建能力所以配置文件能少写一大半。对已经用 Vite 建项目的人来说Vitest 不需要再维护一套 babel 配置。速度上它能用原生 ESM 跑测试超大项目里的增量更新比 Jest 快一个量级。缺点也明显遇到老库、CJS 模块、奇葩的 Node 环境报错信息有时候比较晦涩。Testing Library 严格不是一个 runner而是一套测试工具它让你把组件当作黑盒从用户视角查询文本、角色、标签来断言。早期很多人用的 Enzyme 则是白盒思路可以深入组件实例、shallow 渲染、直接操作 props 和 state。React 版本升级后Enzyme 适配滞后加上它培养了一批“写一堆实现细节断言”的坏习惯所以我现在对新人一律不建议再引入 Enzyme。1.3 我的最终组合Jest 打底RTL 为主Vitest 看场景这里直接给结论。主力项目是 CRA 脚手架或者 webpack 自建的优先 Jest 加 React Testing Library。新项目如果是 Vite 起步我会直接用 Vitest减少配置摩擦。没有谁绝对天下第一关键看你不想为什么事买单。我团队里现在三套项目跑的情况如下场景推荐方案原因老项目、历史组件多Jest RTL jest-dom生态成熟迁移成本可控mock 能力全面新项目、Vite 构建Vitest RTL testing-library/user-event配置少、冷启动快与 Vite 共用配置组件库本身要跑多维测试Jest RTL 为主另接快照策略组件库对兼容性要求高Jest 容器兼容性更好大量数据转换逻辑Jest/Vitest 纯函数断言没必要引入 DOM 环境轻量直接测这个结论不是拍脑袋。我踩过 Enzyme 的 React 17 适配坑也经历过两个项目组为了统一架子反复推倒重来。框架选型花一天想清楚省的是后面每个人每天的浪费。2. 推荐配置实战先把环境跑起来2.1 Jest 配置逐行拆解很多人配置 Jest 失败是因为不理解它默认只跑 Node 环境而 React 组件测试需要的是一个假的 DOM 环境。先给一份我常用的 Jest 配置这个是基于常见的 React 技术栈实践的补充示例适用于大部分 webpack 项目。// jest.config.js module.exports { testEnvironment: jsdom, roots: [rootDir/src], transform: { ^.\\.(js|jsx|ts|tsx)$: babel-jest, }, setupFilesAfterEnv: [rootDir/src/setupTests.js], moduleNameMapper: { \\.(css|less|scss|sass)$: identity-obj-proxy, \\.(svg|png|jpg)$: rootDir/__mocks__/fileMock.js, ^/(.*)$: rootDir/src/$1, }, testMatch: [ **/__tests__/**/*.[jt]s?(x), **/?(*.)(spec|test).[jt]s?(x), ], collectCoverageFrom: [ src/**/*.{js,jsx,ts,tsx}, !src/**/*.d.ts, !src/main.jsx, !src/routes/**, ], };testEnvironment 用 jsdom你的组件里才能拿到 window、document、localStorage。transform 交给 babel-jest前提是你项目里有 babel 配置并且装了babel/preset-env和babel/preset-react新项目还要加上babel/preset-typescript。moduleNameMapper 这段很多人忽略但样式文件和静态资源都要靠它“骗”过去。不 mock 的话Jest 遇到.css文件直接报语法错误。我这几年见过的测试环境起不来的问题九成是卡在这一步。路径别名/也必须和你的 jsconfig 或者 webpack alias 保持一致否则业务代码里import /utils/xx全挂。setupFilesAfterEnv 指向的文件里通常会做两件事引入 jest-dom 断言扩展以及清理每次测试之间的副作用。setupTests.js 长这样import testing-library/jest-dom; // 如果需要 mock 全局 fetch也可以在这里统一处理有了它断言里才能写expect(screen.getByText(保存)).toBeInTheDocument()。没有 jest-dom你就只能退回去用expect(element).not.toBeNull()这种弱断言查出来的错误信息也不够直观。2.2 怎么让测试文件里也能用最新的 ES 语法Jest 默认只能解析 CommonJSReact 组件代码里到处都是import、export、?.可选链、hooks 语法这些在 Node 原生环境下会直接爆。transform 配置本质上就是告诉 Jest“这些语法先给我转成它能吃的格式再跑测试”。如果你用的 babel-jestbabel.config.js 里至少要有这些module.exports { presets: [ [babel/preset-env, { targets: { node: current } }], [babel/preset-react, { runtime: automatic }], babel/preset-typescript, ], };runtime: automatic这个值得单独说。React 17 之后的新 JSX 转换不需要每个文件都import React from react改 automatic 之后测试环境少了很多不必要的 import速度也有小幅提升。遇到老项目 babel 版本比较旧的要先升级babel/core、babel-jest再检查 preset 有没有装全。2.3 Vitest 配置更省心的替代路线如果项目已经用 Vite我强烈建议直接上 Vitest。它的配置方式很直白和 Jest 的路数一样但更轻。import { defineConfig } from vitest/config; import react from vitejs/plugin-react; export default defineConfig({ plugins: [react()], test: { environment: jsdom, globals: true, setupFiles: [./src/setupTests.ts], css: false, alias: { : /src, }, }, });globals 打开之后测试文件里不需要 importdescribe、it、expect减少样板代码。css 设成 false 的意思是不去处理样式文件和 Jest 里 identity-obj-proxy 的作用等价。Vite 项目有大把 alias 配置Vitest 能直接复用不会像 Jest 那样要单独再维护一份 moduleNameMapper。我最初从 Jest 迁 Vitest 时的不适感来自 mock 语法。Vitest 的vi.fn()基本等价 Jest 的jest.fn()但有些第三方库的 mock 行为要靠vi.mock里的工厂函数。碰到老库的时候代码写起来没有 Jest 里那么顺这时候别硬怼查一查该库是否提供 ESM 版本大多数问题都能解决。3. 核心实操一类组件测试从零到能跑3.1 被测组件长什么样写一个具体例子。假定有个用户登录卡片组件它接收一个异步校验函数点击登录按钮之后根据返回结果展示成功或错误信息。这个组件正好覆盖渲染、交互、异步三个常见测试点。import { useState } from react; export default function LoginCard({ onLogin }) { const [username, setUsername] useState(); const [password, setPassword] useState(); const [error, setError] useState(); const [loading, setLoading] useState(false); const handleSubmit async (e) { e.preventDefault(); setLoading(true); setError(); try { await onLogin(username, password); } catch (err) { setError(err.message || 登录失败); } finally { setLoading(false); } }; return ( form onSubmit{handleSubmit} label htmlForusername用户名/label input idusername value{username} onChange{(e) setUsername(e.target.value)} / label htmlForpassword密码/label input idpassword typepassword value{password} onChange{(e) setPassword(e.target.value)} / {error p rolealert{error}/p} button typesubmit disabled{loading} {loading ? 登录中... : 登录} /button /form ); }这里注意两个细节。错误信息外面加了rolealert这不仅仅是无障碍增强它让测试里查询错误信息更稳定——你可以用screen.getByRole(alert)一次性锁定不用纠结文本具体是什么。按钮的 disabled 状态也是测试可观察的关键点loading 时要断言按钮不可重复点击。3.2 渲染断言、交互模拟与异步结果验证测试文件第一块是基础渲染断言import { render, screen } from testing-library/react; import userEvent from testing-library/user-event; import LoginCard from ./LoginCard; test(渲染登录表单基础元素, () { render(LoginCard onLogin{jest.fn()} /); expect(screen.getByLabelText(用户名)).toBeInTheDocument(); expect(screen.getByLabelText(密码)).toBeInTheDocument(); expect(screen.getByRole(button, { name: 登录 })).toBeEnabled(); });getByLabelText 能直接关联 label 和 input这个能力来自 jsdom 对表单语义的支持。用 text 查询按钮虽然也常见但 getByRole 加 name 的写法更贴近用户真实看到的东西而且将来组件需要调整文案时测试不容易碎。交互测试是重点这里必须用 userEvent 而不是 fireEventtest(输入用户名密码后调用登录接口, async () { const onLogin jest.fn().mockResolvedValue({ ok: true }); const user userEvent.setup(); render(LoginCard onLogin{onLogin} /); await user.type(screen.getByLabelText(用户名), tester); await user.type(screen.getByLabelText(密码), 123456); await user.click(screen.getByRole(button, { name: 登录 })); expect(onLogin).toHaveBeenCalledWith(tester, 123456); });看上去简单但里面埋了一个很多新手会踩的大坑不要用fireEvent.change(input, { target: { value: xxx } })模拟输入。fireEvent 只触发事件本身不走 React 的 onChange 合成事件完整链路某些情况下状态没更新测试白写。userEvent 的type方法是逐个字符触发更接近真实键盘输入配合await能让异步 state 更新稳定进入下一个宏任务。我试过好多次把 fireEvent 改成 userEvent 之后所谓的“偶发失败”就消失了。异步结果验证的写法和普通断言有点区别要配合findBy系列test(登录失败时展示错误信息, async () { const onLogin jest .fn() .mockRejectedValue(new Error(密码错误)); const user userEvent.setup(); render(LoginCard onLogin{onLogin} /); await user.type(screen.getByLabelText(用户名), tester); await user.type(screen.getByLabelText(密码), bad); await user.click(screen.getByRole(button, { name: 登录 })); expect(await screen.findByRole(alert)).toHaveTextContent(密码错误); expect(screen.getByRole(button, { name: 登录 })).toBeEnabled(); });findByRole会轮询等待元素出现默认超时时间是 1000ms应对异步 setState 足够。断言错误文本时尽量只断言关键片段别把整个错误文案写死否则后端只要改一个标点你的测试就红。3.3 接口 Mock 的两种姿势和适用场景单元测试最忌讳真的发起网络请求。上面例子中用的方式是依赖注入由测试传入 mock 函数这是最干净的做法因为被测组件根本不知道外部世界长什么样。但真实项目里组件内部往往直接调用统一的请求模块比如import request from /utils/request。这时就要用 Jest 的jest.mock。import request from /utils/request; jest.mock(/utils/request, () ({ post: jest.fn(), })); test(使用全局 mock 请求模块, async () { request.post.mockResolvedValue({ code: 0, data: ok }); // render 组件并触发交互 });这种 mock 方式的坑在于 mock 要写在 import 之后、describe 之前而且工厂函数里不能引用外部变量否则会报 mock 提升相关的错误。习惯上我会把所有的jest.mock集中放在测试文件头部看起来清晰排查起来也好找。如果是 Vitestvi.mock行为类似但工厂函数内部引用vi.fn()要特别小心作用域问题。依赖注入和全局 mock 不是二选一。我的经验是新写的组件尽量用依赖注入老代码不好改的用全局 mock两条路都能走通但别混着用不然测试读起来精神分裂。3.4 覆盖率报告的解读与落地脚本最后把运行脚本写进 package.json{ scripts: { test: jest, test:watch: jest --watch, test:coverage: jest --coverage } }覆盖率不是越高越好。我见过团队为了达标硬写一堆 render 完就结束的假测试数字好看实际价值为零。建议大家重点关注关键业务组件的行覆盖率和分支覆盖率工具函数尽量做到 90% 以上纯 UI 展示组件的样式分支倒不必强求。collectCoverageFrom里把路由入口、类型声明文件排除掉能让报告更贴近真实业务代码。4. 常见问题与排查技巧实录4.1 act 警告一半是环境问题一半是异步没等完Warning: An update to LoginCard inside a test was not wrapped in act(...)这个警告几乎每个写 React 测试的人都会遇到。出现的原因很简单测试代码触发了一个状态更新但这个更新发生在断言已经结束之后React 没法确定这段更新是否应该影响当前的 DOM。最常见的触发场景是异步请求和 setTimeout。你点了按钮接口返回后的 setState 还没跑完测试已经执行到断言React 就会警告。解决办法不是去全局关掉警告——那是自欺欺人而是让测试真正等待异步完成。我总结的排查顺序检查异步断言用没用await screen.findByXxx()。检查 userEvent 方法前有没有加await漏了await user.type会连锁产生状态更新错位。碰到 setTimeout 场景用jest.useFakeTimers()并配合act手动推进时间。如果是组件挂载后 useEffect 里发请求render 之前就把 mock 的 resolve 准备好。使用 fake timers 时要小心一旦开启很多 userEvent 内部等待逻辑也会受影响。通常我会在具体测试里用beforeEach和afterEach控制开关不开全局 fake timers。4.2 测试环境里没有的 API 怎么补jsdom 模拟的是浏览器环境但不是所有浏览器 API 都有完整实现。最常见的两个是window.matchMedia和ResizeObserver很多 UI 组件库在初始化时就要访问它们没有就会直接抛错。处理办法是在 setupTests.js 里手动补Object.defineProperty(window, matchMedia, { writable: true, value: (query) ({ matches: false, media: query, onchange: null, addListener: vi.fn(), removeListener: vi.fn(), addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), }), });这一份 mock 我基本是到处复制实用得很。遇到组件库用到 canvas 或者 IntersectionObserver 的同理补一份 stub 就行。核心思路是测试环境只保证“够用”缺什么补什么不要试图把整个浏览器都搬进来。4.3 组件里引用了图片和样式导致测试崩溃这是入门阶段最频繁的“环境问题”。某次我们项目里有个组件引用了logo.svgJest 跑起来直接报 “Unexpected token”一查是 SVG 文件被当成 JS 解析了。后来就是在 moduleNameMapper 里加一条规则把所有图片类资源统一映射到一个空 mock。这个方法直到现在还在用。样式文件同理。CSS Modules 的import styles from ./index.module.css在测试里会变成访问styles.foo如果没配置 identity-obj-proxy你得到的 styles 对象就是 undefined。配置好之后styles.foo返回字符串foo组件引用样式的地方就不会断。4.4 常见问题速查表症状原因解决方案测试报 window is not defined环境错配成 nodetestEnvironment 设为 jsdomCSS 文件解析报错moduleNameMapper 缺样式映射使用 identity-obj-proxyact 警告满天飞异步更新未等待改findBy查询或补await userEvent点击按钮后状态没变用了 fireEvent 而不是 userEvent换userEvent.setup()后 awaitmatchMedia is not a functionjsdom 缺少 matchMedia在 setup 文件里补 matchMedia stubmock 的模块返回 undefinedjest.mock 工厂函数写错确保工厂里返回真实对象测试偶发失败但代码没问题测试间共享状态未清理在 afterEach 里cleanup和重置 mockTS 类型报错但能跑jest-dom 类型未加载配置types: [testing-library/jest-dom]这张表是真实踩坑记录整理出来的不是为了凑篇幅。特别是“测试偶发失败”这条和“代码没问题”往往是假象多数是全局 mock 没清、或者 userEvent 没 await问题会在并发测试时被放大。4.5 别忽视 watch 模式下的内存与资源占用最后说一个容易被忽略的实操细节。Jest watch 模式在大型项目里默认监听全仓库变更仓库一大会出现 CPU 飙升、卡顿。我的习惯是只监听 src 目录jest --watch --watchPathIgnorePatterns node_modules dist如果是 Vitest本身就做了模块缓存和依赖预构建冷启动体感轻很多。如果你还在为测试速度焦虑先看一眼测试文件是不是在毫无必要地 import 整棵组件树。很多测试慢不是因为框架而是因为被测组件连带加载了全局状态、路由、样式和一堆第三方依赖这是架构问题框架救不了你。遇到这种组件优先考虑怎么拆依赖而不是急着换测试框架。说到底框架选型这件事没有一劳永逸的银弹。我个人实际执行时的判断优先级始终是先看构建工具再看项目里 React 版本和第三方库的年代最后才看团队成员熟悉哪套写法。后两者权重反而更高因为工具再强没人能稳定维护也是白搭。你可以把我这篇里的组合直接抄回去先用着等跑通第一组测试之后再按项目实际情况把配置慢慢磨成适合自己的形态。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑