1. 为什么React测试的选型会决定项目体验
先说个我自己的经历。去年在某团队接手一个中后台项目,测试代码有三千多个用例,看上去覆盖率挺漂亮,但每次跑全量测试要十五分钟以上,而且随便改动一个公共组件,就会连带挂掉百来个无关用例。整个团队后来对测试的态度从"必须写"变成了"能躲就躲",新来的同学写用例完全靠复制粘贴,断言逻辑都看不懂。
后来我们花了两周时间把测试框架整体换了一遍,跑全量测试的时间从十五分钟降到了两分多钟,用例数量少了三分之一,但有效覆盖反而更高了。这两周的差别,核心根本不是某个框架好不好用,而是选型这件事从一开始就决定了后面所有测试代码的形态。
在React生态里,大家说的单元测试框架,通常指的是四样东西的组合:
- 测试运行器(Test Runner):负责发现用例、执行用例、输出报告,代表是Jest和Vitest
- 断言库(Assertion Library):负责判断"实际结果是否符合预期",Jest自带,Vitest也自带
- 组件挂载与查询工具(Renderer + Query API):负责把React组件渲染到DOM上,并查找元素做交互,代表是React Testing Library(RTL)和Enzyme
- mock工具:负责替换掉网络请求、子组件、时间函数等外部依赖
这四个层面各自选型,组合起来才叫"框架选型"。很多人张口就说"我们用Jest做单测",其实他只用到了运行器和断言库,组件渲染用的还是RTL,这是三套东西,不是一套。
这篇内容就是围绕这四个层面的选型和落地展开的,适合刚接手React项目测试体系、想把现有测试从"能跑"提升到"好用"的开发者参考。
2. 四个主流框架的横向对比,别被"维护者弃坑"吓住
2.1 Jest和Vitest,运行器层面的核心差异
Jest是React测试事实上的标准,facebook内部维护,文档完善,生态成熟,遇到问题搜索引擎基本都能找到答案。但Jest有几个历史包袱:默认跑在Node环境里,对ESM的支持一直不彻底,使用了它自己那套模块解析和沙箱机制,所以配置起来会有一堆transform相关的规则。
Vitest是Vite生态下的测试框架,底层利用Vite的transform能力,不需要单独配置Babel就能直接处理TSX、ES Modules,启动速度和热更新比Jest快一个量级。更重要的是,Vitest对ESM的处理是"原生支持",不会再遇到Jest那套"module.ts转CommonJS再转回来"的绕路问题。
用一个直观的例子说明差异。Jest里你写了一个ESM包,直接import会被报错,得靠transformIgnorePatterns反复调试;Vitest直接把esbuild接进来,jsx、ts、tsx全部开箱即用。如果你本身是Vite驱动的项目,尤其是Vite 5以上版本,Vitest是更省心的选择。
但如果项目是CRA(Create React App)生成的,还停在webpack构建体系里,硬上Vitest反而不划算,因为它基于Vite,和webpack的项目会存在重复构建的体系冲突,这种情况下老老实实用Jest更稳。
2.2 React Testing Library和Enzyme,组件测试的两种理念
Enzyme当年是Airbnb主导的React组件测试工具,它允许你访问组件实例的内部状态、手动调用生命周期方法、通过find查DOM节点。听起来能力很强,但它测试的是"组件怎么实现",而不是"组件做了什么"。比如一个按钮点击后弹出弹窗,Enzyme的写法经常是检查handleClick是否被调用、state是否改变,而不是检查弹窗是否真的出现在屏幕里。
React Testing Library遵循的哲学正好相反:测试用户能看到和交互的东西,而不是测试组件的内部实现。它不提供组件实例层面的查询,只让你通过角色、文本、标签等面向用户的方式去查DOM。一个RTL测试不会因为你在重构时把state改成useReducer而失败,只要页面行为对用户来说没变,测试就是绿的。
我个人的建议非常直接:新项目一律用RTL,老的Enzyme测试也不要大规模迁移,新增用例全部用RTL写,慢慢替换。理由后面第三部分会详细展开。
2.3 实测对比表
下面这张表是我基于同一个组件Demo在三个框架组合下跑出的实测数据,组件是一个带异步加载的表单页面,包含45个用例:
| 对比项 | Jest + RTL | Vitest + RTL | Jest + Enzyme |
|---|---|---|---|
| 首次安装配置耗时 | 20分钟 | 8分钟 | 25分钟 |
| 45个用例执行耗时 | 12.6秒 | 3.1秒 | 18.9秒 |
| TS类型推断准确度 | 中,需额外配置 | 高,原生支持 | 低,需大量any |
| Debug定位难度 | 中,报错栈清晰 | 低,报错直接指向源码 | 高,内部封装多 |
| 维护方状态 | 维护稳定 | active,社区活跃 | 已停止维护or仅维护模式 |
Enzyme现在基本已经处于冻结状态了,Airbnb也放弃维护,新项目如果选Enzyme是在给自己造技术债。Jest和Vitest的选择取决于构建体系,RTL是组件测试层的必选项。
3. 落地一套可跑的测试配置,把最常见的一组坑提前踩完
3.1 以Vitest + RTL为例的完整配置流程
如果你像我一样在一个中后台项目里从零搭建测试环境,推荐一个"能跑通的最小配置"路线。先装依赖:
npm i -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom然后需要在vite.config.ts(或vitest.config.ts)里加一个test字段,以下是一份比较完整的配置:
import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], css: false, coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], include: ['src/**/*.{ts,tsx}'], exclude: ['src/main.tsx', 'src/**/*.d.ts', 'src/test/**'] } } });这里的setup.ts文件用来注册jest-dom的匹配器,还有一个非常重要的全局清理函数:
import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; afterEach(() => { cleanup(); });RTL的cleanup会自动卸载渲染出来的组件,如果你忘了写这个全局afterEach,多个用例之间可能会出现DOM重复、事件监听冲突、甚至"找不到元素"的假失败。RTL官方文档里写了推荐这么做,但很多人照着官方集成文档抄完之后,根本不会注意到这个cleanup的全局注册步骤。
3.2 三个最容易让你卡住的配置问题
第一个是jsdom和Node环境的差异。有些用例跑在Node环境里没问题,但一旦涉及DOM操作就会报错,因为Node环境没有浏览器那套全局对象。解决办法就是给测试配置environment: 'jsdom',注意Vitest里面还支持per-file覆盖,某个特殊用例如果你确定它不需要DOM,可以在文件顶部写// @vitest-environment node单独指定。
第二个是CSS和静态资源的处理。在Jest时代,import一个.less文件几乎必然需要moduleNameMapper来mock成空对象,否则跑起来就报错。Vitest里有css: false配置项来跳过CSS处理,但注意如果你的组件里有import图片、SVG、字体文件的逻辑,有可能仍然报错,这个时候需要在test里配置alias或手动mock。最简单的方案是建一个mock文件处理静态资源,然后在setup.ts里注册进去。
第三个是transformIgnorePatterns。这个主要是Jest用户会遇到的,Vitest基本没有这个问题。Jest默认把node_modules里的包都忽略掉,如果你引用的某个依赖是ESM格式的(很多组件库已经切到ESM-only了),在Jest的Node环境里面会直接爆SyntaxError。解决办法是在transformIgnorePatterns里把这个包的名字排除掉,例如:
{ "transformIgnorePatterns": ["node_modules/(?!react-dnd|@dnd-kit)/"] }注意这个正则是"排除node_modules里除了这几个包之外的所有内容"的反向匹配写法,网上很多文章会把括号里的名单写反。
3.3 从CRA/Jest迁移到Vitest的注意点
如果已经有一个Jest测试体系,我不建议一把梭全量重写。迁移可以采用"双轨并行":先新建vitest.config.ts,确保Vite本身的构建不受影响,然后逐个目录迁移用例,把Jest里jest.mock的部分逐步替换成vi.mock,全局的jest.fn()改成vi.fn(),断言库的expect(...).toBe()语法不变(Vitest的断言API跟Jest是基本兼容的)。
不过有一个小坑容易踩:Vitest里vi.mock的语法比Jest严格,它默认不会提升mock,所以在import之前声明mock时有坑。习惯上写在文件顶部的vi.mock('node_modules包名', () => ({ ... }))正常工作,但如果你在某个describe块内部做条件式mock,Vitest会直接报错,提示你mock只能在顶层使用。Jest反而在这种场景下更宽容,所以迁移的时候如果看到"vi.mock is not defined"或者"Cannot use vi.mock outside of a test file",要首先检查你是不是把mock写在了describe里。
4. 组件测试怎么写才不空转:查询、事件与异步的正确姿势
4.1 为什么要按"用户视角"选查询方式
RTL的查询API有一大堆,getByRole、getByLabelText、getByPlaceholderText、getByText、queryBy、findBy等等。选错了查询方式,最直接的后果是测试极其脆弱——你改一行文本或者换一个CSS类名,测试就炸了。
我推荐一个层级策略:
- 优先getByRole:它最贴近辅助功能和用户语义,而且能顺带校验可访问性。比如按钮就找
getByRole('button', { name: '提交' }),下拉框就找getByRole('combobox')。 - 其次getByLabelText:跟表单控件关联性最强,label写对了,测试和用户体验双赢。
- 再用getByText和getByPlaceholderText:适合非交互性内容的查找,比如校验错误提示。
注意,不到万不得已不要用container.querySelector,因为它绕过RTL的查询语义,测出来的结果可能和用户感知不符,会给重构带来虚假红灯。
4.2 事件触发用userEvent,不要用fireEvent
RTL里有两套事件触发方式:fireEvent和userEvent。fireEvent是同步的、直接派发事件的,userEvent是异步的、模拟真实用户操作的(会触发click、dblClick、hover、键盘输入等浏览器合成行为)。
在绝大部分真实场景里,组件对事件的响应不是同步完成的,比如点击按钮后有一个setTimeout修改状态、有loading的闪烁、有异步校验。fireEvent直接派发事件会跳过很多浏览器层面的默认行为,导致测试里你点了按钮,界面状态却迟迟不更新,你以为是组件bug,其实是触发方式太粗糙。
userEvent的调用方式几乎全是异步的:
import userEvent from '@testing-library/user-event'; import { render, screen, waitFor } from '@testing-library/react'; test('点击提交按钮后显示loading', async () => { const user = userEvent.setup(); render(<SubmitForm />); await user.click(screen.getByRole('button', { name: '提交' })); expect(await screen.findByText('提交中...')).toBeInTheDocument(); });注意userEvent.setup()必须在每个用例里单独调用一次,不要抽成公共变量在多个用例之间共享,因为它的内部状态可能残留上次操作。我在不少团队代码评审里见过这个反模式,用例偶尔挂、但重跑就绿了,基本都是这个原因。
4.3 异步请求的三种等待姿势
写React组件测试最常见的场景就是"等待数据加载完成"。这有三种写法,我按优先级从高到低说明:
- findBy系列查询:
await screen.findByText('某个异步出现的文案')。最简单直接,返回一个Promise,轮询直到找到元素或超时。 - waitFor包裹断言:当你要等一个值符合某种条件时,比如等待一个按钮变成可点:
await waitFor(() => { expect(screen.getByRole('button', { name: '提交' })).toBeEnabled(); });- waitForElementToBeRemoved:等一个元素消失时用的。比如等待loading spinner消失,表达语义特别优雅:
await waitForElementToBeRemoved(() => screen.queryByTestId('loading-spinner'));尽量用findBy,因为代码量最少。waitFor适合做多个断言组合。waitForElementToBeRemoved适合处理"轮播图loading、全局遮罩消失"这类场景,不少展示型的组件库比如弹窗、Table骨架屏,都需要这种等待。
5. Mock、异步与定时器:最容易翻车的三个环节
5.1 慎用子组件mock,默认让它真实渲染
很多刚接触RTL的人有一个习惯:测试父组件时把子组件mock掉,觉得可以隔绝依赖。但RTL的哲学是尽量渲染真实组件树,只mock掉跨边界的东西(比如请求、路由、状态管理)。
原因很简单:如果你把内部子组件全mock成假节点,那测试本质上只覆盖了父组件的"拼接逻辑",一旦子组件行为变更(例如文案变了、交互方式变了),父组件测试不会反映出来,等于测试处于一种"绿但是无效"的状态。
什么时候才应该mock子组件?两个场景:
- 某个子组件非常重,渲染时依赖浏览器API或第三方SDK,不mock掉测试跑不动。
- 子组件渲染会发起真实网络请求(图片加载、埋点上报),这时候mock掉可以保证测试不发出真实请求。
mock子组件的技术很简单:
vi.mock('./HeavyChart', () => ({ default: () => <div>MOCKED_CHART</div> }));在测试文件里再断言父组件渲染时包含MOCKED_CHART这个文本,以确认父组件正确调用了这个子组件。
5.2 fetch请求的四类mock策略
React组件内最常见的副作用就是网络请求。mock它有四个层次:
- mock整个fetch函数:在测试文件里
global.fetch = vi.fn(),然后针对不同断言场景设定resolve值。这是最底层的mock,但容易让代码实现细节(比如两段请求之间的顺序)暴露在测试里。 - 用MSW(Mock Service Worker)拦截网络:MSW在Node和浏览器里都能工作,通过Service Worker逻辑拦截真实请求。这种方案最接近真实环境,适合集成测试,缺点是每个用例都要写对应的handler,模板代码比较多。
- 用vitest的vi.mock局部mock某个请求工具模块:如果你的项目把请求封装到了
utils/request.ts里,直接mock这个模块,简单直接。 - 依赖注入:把请求函数作为prop或context依赖传入组件,测试时传mock版本。这个最优雅,但改造成本不小。
我的建议是:中小型组件测试直接用vi.mock模块方案,重交互、多接口联动的页面用MSW。不要每一次都mock fetch,这样用例和"用户感知"之间的关联会越来越远。
5.3 定时器与动画导致的"找不到元素"
React测试里定时器最大的坑是fake timers和真实异步等待混用。如果你开了fake timers,又用了await waitFor(...),你可能会遇到一个死等现象:waitFor内部实现也依赖setTimeout来轮询,而你把这个setTimeout给fake了,轮询永远不执行。
Vitest里控制fake timers的API是:
vi.useFakeTimers(); test('展示倒计时', async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); render(<Countdown initial={10} />); await user.click(...); vi.advanceTimersByTime(3000); expect(...).toBe(...); });关键一步是advanceTimers: vi.advanceTimersByTime传给userEvent.setup,这样userEvent内部触发的行为也能感知fake timers,否则事件触发后的任务会一直排队,而你手动advance完也看不到变化。
另外,某些动画库(CSS transition、framer-motion动效)在jsdom下会真实启动定时器,增加测试的不确定性。遇到这类组件,建议在测试环境禁掉动画效果,比如给Transition组件的duration传0,或者提供一个全局的Animation.debug禁用开关。这不是逃避,而是测试不必为视觉效果负责,只需负责状态变化。
6. 覆盖率、执行效率与企业级扩展方向
6.1 覆盖率指标该怎么设,才有实际约束力
覆盖率数字好看不等于测试有效,这是很多团队的认知误区。但完全不看覆盖率也不行,否则代码review时没人知道哪些代码彻底没跑过。
我实践的设置方式分三层:
| 指标 | 目标线 | 触发动作 |
|---|---|---|
| statements(语句覆盖) | 80% | 低于则CI失败 |
| branches(分支覆盖) | 70% | 新提交需要补充分支用例 |
| functions(函数覆盖) | 85% | 新增公共函数必须补充测试 |
| lines(行覆盖) | 80% | 与statements近似,可合并管理 |
关键是把CI里的覆盖率阈值作为硬性门槛,这样才会倒逼开发者写用例。但阈值不能一开始就定到90%,否则团队会抓狂,然后开始写"为了覆盖而覆盖"的空转测试,甚至把不该测的私有函数也强行导出来测。
还有一个高效做法:用vitest的coverage.include限定只统计src里的业务代码,把测试文件本身、类型声明文件、构建入口全部排除。不然你的覆盖率会被一堆d.ts文件稀释,数字很虚。
6.2 测试执行效率的实战优化
跑单测的速度很大程度上由两个东西决定:并发控制和模块加载量。
Vitest默认是fork并行执行的,每个文件一个进程,如果你的测试文件很多但都很小,进程启动的开销反而大于测试本身时间,这种情况可以通过fileParallelism: true和合理配置maxWorkers来调整并行度。我更推荐的做法是把纯逻辑工具的测试和组件测试分目录放,因为纯逻辑测试不需要jsdom环境,它们应该跑在node环境里,启动开销小一大截。
组件测试里最常见的时间杀手其实是CSS和静态资源的处理。如果组件import了三张png图片和两个less文件,每次跑测试就多出几百毫秒的解析时间,全项目几百个文件累积起来就很客观了。对这类场景,css: false只能跳过CSS输出,对于图片文件建议配合vite-plugin-ignore-static-import或者直接通过resolve.alias指向一个空文件来加速。
还有一招非常实用:把setupFiles里的全局操作做得足够轻。有人会在setup.ts里引入配套的UI库的完整样式、setup全局store、初始化多语言资源,这些都会拖慢每个测试文件的启动时间。setup里只放必须的东西,其他能在用例内按需import的,就不要全局引。
6.3 单测之外的边界:什么时候该上集成测试
单元测试负责验证"组件自身行为",但项目里总有一些跨模块的逻辑链:一个流程要经过首页、表单页、结果页三个页面,涉及路由传参、store状态更新、接口返回。这些东西靠单测来覆盖成本极高,而且每个步骤都要mock,测试意义会逐渐变弱。
在这种情况下,正确的做法不是把单测写成"假装是集成测试",而是把真正的集成测试放到另一层去,比如用Playwright做E2E,用MSW + Vitest组件业务流做轻量集成测试。很多团队最后的测试分层是这样的:
- 单元测试(Vitest + RTL):覆盖每一个独立的组件和工具函数
- 组件交互测试(Vitest + RTL + MSW):覆盖组件之间通过store/context协作的场景
- E2E测试(Playwright):覆盖核心业务链路,数量不求多,每个链路一条就够
这个分层的关键是:不要试图让一层测试覆盖所有风险。单元测试拼命mock,换来的是速度快,但覆盖不了"多个组件真实协作"的场景;E2E测试真实感最强,但跑得慢、维护成本高。两个都做,各司其职,测试体系才算健康。
7. 从这套选型里得到的几点实战体会
最后分享几个我自己用了很久的工作习惯,不算什么高深技巧,但确实能减少踩坑次数。
第一,写测试的优先级永远先于补测试。开发一个新组件的时候,先把能描述用户行为的测试骨架搭出来,比如"渲染后能看到XXX""点击后XXX消失""异步加载后XXX出现",然后再去实现组件逻辑。这个顺序的好处是,你会很自然地想到边界场景,而不是等代码写完了再回来凑用例。
第二,用describe嵌套来组织行为场景,而不是按组件方法组织。很多老测试代码是"测试某某方法A""测试某某方法B",看起来清晰,但实际上把组件内部实现暴露给了测试用例本身。我更习惯把describe按行为分:describe('当用户未登录时')、describe('当用户点击删除按钮'),这样阅读测试代码就像在读一份需求文档。
第三,遇到偶发失败先别急着改代码,先怀疑测试环境问题。jsdom下的下拉框事件、动画定时器、事件监听的竞争条件都可能导致偶发失败。我通常会用--sequence.shuffle false跑一遍确认是不是随机顺序导致的,然后检查是否有共享变量跨用例泄漏。
第四,测试代码也是需要code review的。很多团队只review业务代码,测试代码全是自己写了直接合。但恰恰测试代码容易藏污纳垢:不查结果、catch住异常假装成功、mock过多导致无意义断言。每周花一点时间review测试代码,收益率比review业务代码高得多。
如果你也在为React项目的测试体系纠结,我的建议是别迷信某一个框架、某一个工具,先从"用户行为可验证"这个角度去梳理你手头组件,再决定选什么运行器和渲染工具。选型选对了,后面每一行测试代码都会写得舒坦很多。