一提到 React 单元测试,很多人的第一反应是“选 Jest”,第二反应是“要不要配 Testing Library”。但最近两年事情起了变化:Vitest 带着 Vite 的性能优势冲了进来,社区关于“Jest 和 Vitest 到底选谁”的争论一直没停过。我自己前后折腾过几个规模不小的 React 项目,也从主推 Jest 到部分迁移 Vitest,过程中踩了不少坑,也整理出了一套相对稳定的选型和落地方案。这篇不是框架的官方文档翻译,也不是教科书式的测试理论,而是把我在真实项目里“选型、落地、排坑”的经历拆开了讲清楚。适合正在给 React 项目搭测试环境的小伙伴,也适合已经在写测试但总被各种问题卡住的开发者。
1. 测试框架选型前的核心认知
1.1 先搞清楚“测什么”再选“用什么”
很多人一上来就查“Jest 和 Vitest 哪个好”,实际上这是把问题问反了。选型之前要先想清楚:这个项目的测试到底要覆盖哪些层面?React 项目的测试对象大致分三层。
第一层是组件渲染测试:验证组件接收不同 props 时能否正确渲染出对应的 DOM 结构,这层测试跑得最快,频率最高,也是保证 UI 不崩的基础。第二层是交互行为测试:模拟用户点击、输入、滚动等操作,验证事件回调是否正确触发、状态是否按预期更新。第三层是集成场景测试:多个组件协作,配合真实或 mock 的请求,验证一条完整业务流程能否跑通。比如“登录 → 拉取用户信息 → 渲染到首页”这种链路。
三种测试对框架的需求完全不同。如果只做基础渲染,Jest 配 jsdom 就够用;如果做大量交互测试,Testing Library 的 user-event 发挥空间更大;如果业务里异步请求密集,那么对 mock 体系、fake timer 的成熟度要求就上来了。先列清楚要覆盖的场景,再确定框架,你的选型才有依据,否则就是跟风。
1.2 框架兼容性:这是选型里最容易被忽略的一环
React 项目很少有纯 JS 的业务代码,几乎都会配上 TypeScript、Babel、Webpack/Vite、ESLint。这些构建链路上的工具,每一环都可能成为测试框架的阻力点。
Jest 的优势非常明显:它做了十几年的测试框架,对 Babel、ts-jest、各种 mock 库的适配成熟到令人发指,基本上网上能搜到的问题都有答案。Jest 28 之后原生支持 ESM,但说实话,在真实项目里跑通 ESM 仍然比 CJS 费劲,很多老项目至今还是通过 transform 配置来兼容。
Vitest 是 Vite 生态的一部分,如果你项目本身就用 Vite 构建,Vitest 几乎可以零成本复用同一份配置——alias、resolve、plugins 全部继承,这体验是 Jest 完全没法比的。但 Vitest 对 Webpack 项目的兼容性就一般了,它本身设计就是跑在 Vite 的模块体系上,硬塞进 Webpack 项目属于自找麻烦。
我个人的判断标准很简单:项目是 Vite 技术栈,优先考虑 Vitest;项目是 Webpack/CRA 老体系,老老实实用 Jest,别做那个“顺便迁移一下测试框架”的勇者。
2. 核心对比:Jest 与 Vitest 的真实差距
2.1 性能与开发者体验之争
论单次运行速度,Vitest 在大型项目里优势明显,因为它内部用了 Vite 的 transform 机制,测试文件可以做按需编译,还能借助 esbuild 做依赖预打包。Jest 每次跑测试都要走一遍完整的 transform pipeline,文件多了以后确实能感受到那种“卡顿感”。
但这里有个容易误导人的点:Vitest 的“快”更多体现在 watch 模式和缓存机制上。开发模式下改了代码,热更新基本是毫秒级响应,这个体验对 TDD 流程来说太重要了。如果是 CI 环境里的单次全量运行,两者差距会缩小——Jest 跑完一个 2000 个测试文件的中型项目可能三分钟,Vitest 可能一分半,但两者都不会快到你忽略它们存在的地步。
真正影响开发体验的还是 watch 交互。Jest 的 watch 模式一直是标杆,支持p过滤文件、t过滤单条测试名、f跑失败用例。Vitest 在这块做了完整的对齐,同时还保留了 Vite 那种终端 UI 风格。实际用下来,两者在这个纬度上打平,谁也没输。
2.2 生态成熟度与坑的密度
Jest 最大的护城河是生态。.toMatchSnapshot()、jest.mock()、jest.useFakeTimers()这些 API,你在任何资料、博客、问答社区里都能找到现成的例子。配@testing-library/jest-dom后,toBeInTheDocument()这类断言也成了社区默认标准。遇到问题,搜jest [关键词],答案几乎不会让你白等。
Vitest 的 API 设计上刻意对齐了 Jest:describe/it/expect一模一样,vi.fn()对应jest.fn(),vi.mock()对应jest.mock()。迁移一个 Jest 测试到 Vitest,改动量非常可控。但生态的差距还是体现在细枝末节:比如某些第三方开源库的 mock 方案是专门写给 Jest 的,拿给 Vitest 用就会出现兼容问题,需要额外包一层 vi.mock 手动处理。
我实际遇到过一次:一个用于监听浏览器事件的开源工具库,其官方测试示例全是 Jest 代码,在 Vitest 下跑,mock 方法完全失效,后来只能自己包一层 wrapper 才解决。这种事不能说 Vitest 不好,但确实暴露它“还没有把生态缝隙全部填满”的现实。
2.3 配置对比:一个要组装,一个像开箱
Jest 配 TypeScript 的时候要选方案:babel-jest、ts-jest、或者 swc。ts-jest 慢但类型安全完整,babel 快但不做类型检查,swc 是性能和功能兼顾的选择。然后还要处理moduleNameMapper、transformIgnorePatterns、testEnvironment、setupFilesAfterEach这些字段,一套组合拳下来,光是配置就要折腾大半天。
Vitest 这边就轻松很多:内置 esbuild transform,TS 开箱即用,test.environment默认就是 jsdom 且可以直接配environment: 'happy-dom'一键切换,对于组件测试来说内置了globals: true就能直接在每个文件里用 describe/it 而不需要 import。配置量体感能省一半。
不是说 Jest 配置繁琐就该被淘汰,稳定、生态大就是它的价值。但如果你的项目是 Vite 起步,还要硬配 Jest,那属实是给自己找不痛快。
3. 组件测试工具链:Testing Library 为什么是必选项
3.1 从“测细节”到“测行为”
很多刚接触组件测试的人会下意识地写“断言组件内部 state 变了”“断言某个生命周期函数被调用了”。这种测试方式的问题在于:它把组件当作一个白盒来测试,一旦内部实现重写,测试跟着碎,维护成本极高。
React Testing Library(RTL)的核心哲学恰恰相反:像用户一样测试。用户不关心组件内部有没有useState、useMemo,用户只关心页面上能不能看到内容、点击按钮后有没有反馈。所以 RTL 提供的方法是getByRole('button', { name: '提交' })、queryByText('加载中')、findByAltText('用户头像')这类基于可访问性标签和用户可见文本的查询方式。
这个思路转变,才是 React 组件测试里最重要的一课。RTL 逼着你思考产品的用户价值,而不是为了凑覆盖率去测一些无关紧要的实现细节。
3.2 核心 API:query、render、user-event
RTL 的查询方法按返回值类型分成三大类:getBy*、queryBy*、findBy*。精髓在findBy*上——它专门用来等异步 UI 出现。比如点击按钮后,组件内部发了一个请求,请求结束后才渲染出“登录成功”,这时候用await screen.findByText('登录成功')就是最优雅的写法,底层自动帮你轮询等待。
user-event包也值得单独提。它替代了过去常用的fireEvent,fireEvent.change()只触发一次 DOM 事件,而userEvent.type()会模拟真实键盘敲击的多事件序列:keydown、keypress、keyup、input,每个字符都会触发一轮。这个差异在验证受控组件、表单联动时影响很大。我强烈建议新项目一律用 userEvent,别再走回 fireEvent 的老路。
3.3 什么场景别用 RTL
RTL 也有它的盲区。比如你想测试一个组件内部非常复杂的纯逻辑函数,或者要断言某段 canvas 绘制结果是否正确——这类场景已经不属于“用户可见行为”的范畴,硬用 RTL 写反而会非常别扭。更合适的是把这些纯逻辑抽出来单独做函数测试,canvas 则用专门的截图对比方案去测。
另外,RTL 不直接支持测试 React 组件抛出的错误边界。虽然可以自己包一个 class component 来触发 error,但说实话,错误边界在真实项目中往往和监控系统绑在一起,显式的自测收益不大,不如把它交给 e2e 或手动验证。
4. 实操:从零给 React 项目搭一套健壮的测试环境
4.1 选定技术栈一步到位的配置样例
以 Vite + React + TypeScript 项目为例,我推荐直接使用 Vitest。
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom然后配置vite.config.ts:
import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], test: { globals: true, environment: 'jsdom', setupFiles: './src/test/setup.ts', css: false, mockReset: true, restoreMocks: true, clearMocks: true, coverage: { provider: 'v8', reporter: ['text', 'html', 'lcov'], include: ['src/**/*.{ts,tsx}'], exclude: ['src/main.tsx', 'src/vite-env.d.ts'], }, }, });setup.ts里注册 jest-dom 的断言扩展:
import '@testing-library/jest-dom/vitest'; import { cleanup } from '@testing-library/react'; import { afterEach } from 'vitest'; afterEach(() => { cleanup(); });几个关键点的取舍理由:
globals: true可以少写一堆import { describe } from 'vitest',代价是让 TypeScript 需要额外引入vitest/globals类型声明。我更偏向在tsconfig.json的types里加上它,省心。css: false是必须的,很多人一开始没关 CSS,结果测试跑了半天全在编译 SCSS,浪费生命。mockReset/restoreMocks/clearMocks三个全部打开,目的只有一个:每个测试用例之间不共享任何 mock 状态,测试互相隔离。
这个配置对中小型前端项目来说,基本可以“一套走天下”。
4.2 手写第一个组件的完整测试
假设我们有个UserCard.tsx,props 是user对象和onFollow回调,UI 上展示姓名和一个“关注”按钮。
import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { UserCard } from './UserCard'; test('展示用户姓名,并点击关注触发回调', async () => { const onFollow = vi.fn(); render(<UserCard user={{ name: '小明' }} onFollow={onFollow} />); expect(screen.getByText('小明')).toBeInTheDocument(); const followButton = screen.getByRole('button', { name: '关注' }); await userEvent.click(followButton); expect(onFollow).toHaveBeenCalledTimes(1); expect(onFollow).toHaveBeenCalledWith('小明'); });这个测试里体现了几个关键点:
- 通过
getByRole('button')而不是getByText('关注')来定位按钮,是 RTL 推荐的优先策略,因为前者强制组件具备可访问性信息(role),盲人用户读屏时也是这么识别按钮的。 - 用 userEvent 模拟点击,尽量贴近真实用户行为。
- 断言回调的入参是
'小明',比只断言toHaveBeenCalled()要严格得多,能在未来某个时刻帮你抓出参数传递错误。
4.3 fixture 与 mock 的最佳实践
组件测试里,数据准备往往会占掉大量代码。每次都在测试里手动写user: { name: '小明', age: 18 }这种对象,时间一长就是噩梦。我强烈建议做一个fixtures.ts目录专放不同场景的假数据,比如mockUser、mockAdminUser,测试里直接引用,既保证一致性也方便改结构。
mock 的定位也很讲究。对于发请求的组件,简单粗暴的做法是 mockfetch或axios。我更推荐用msw(Mock Service Worker)。它的好处在于:mock 层直接拦在网络请求层面,组件里不需要感知自己在跑 mock,代码能保持和线上几乎一致的行为,测试也不依赖任何真实后端。配合node-fetch或 jsdom 环境,启动成本也不高。
实际教训:曾经有一版用
vi.mock('@/api/user')的方式 mock 整个业务 API 模块,结果后来接口签名一变,测试文件里十几处 mock 内容全要跟着改,费时费力。换用 msw 后,接口变化只需要维护一份 handler,测试与组件代码几乎解除耦合。
5. 常见问题与排查技巧实录
5.1 act 警告怎么消灭不掉的
几乎每个 React 测试新手都会撞到Warning: An update to X inside a test was not wrapped in act(...)这个警告。根源就是:组件里有异步更新(state 更新、请求回调等)发生在了测试的断言窗口之外。
解决思路分两步。第一步,把组件更新收敛到 callback 或 await 里。像上面 userEvent 的 await,它会自动包一层 act,这也是我推荐 userEvent 的另一个原因。第二步,如果是定时器惹的祸,用vi.useFakeTimers()配合await vi.advanceTimersByTimeAsync(),只跑 promise 微任务不跑真实定时器。
还有一种是findBy*查询可以顺手解决:把它用上,查询天然带异步等待,不依赖手动 act。
5.2 testing-library 的查询问题:getAllBy 和 too many elements
getByText('提交')明明不报错,却抛了multiple elements异常,十有八九是那种渲染了多个相同文案的情况,比如列表卡片里每张卡片都带“确认”按钮。这时候先别急着改成getAllByText()[0],那个写法等于把自己锁死在第一个元素上,等 UI 顺序一变就挂了。更稳的方案是缩小范围:
const card = screen.getByTestId('card-1'); const submitBtn = within(card).getByRole('button');或者用更精确的 query 条件,比如getByRole('button', { name: /确认\s订单/ }),配合正则可以精准命中。within这个 API 是 RTL 里强烈推荐的一个细节,它让你能在局部 DOM 内查询,避免了全局查询互相污染。
5.3 alias 与 tsconfig 路径解析
用 Vitest 的时候,如果测试文件里import { thing } from '@/shared/thing'报 Cannot find module,大概率是路径解析没传给它。Vite 本身已经读了tsconfig里的paths,但 Vitest 的resolve.alias还是需要你显式配一遍:
resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, },或者更省事的是装vite-tsconfig-path插件去自动读 tsconfig。这个问题看着小,但几乎每个 Vite/Vitest 新项目都能遇到一次,我干脆放到这里集中提醒。
5.4 快照测试该不该用
快照测试初期的诱惑很大:一个.toMatchSnapshot()就能锁定整个 DOM 树,覆盖率蹭蹭涨。但真实项目的痛苦在于:任何一个小改动,哪怕只是加了个无关紧要的>