Vitest 浏览器模式:别再用 jsdom 骗自己了
好的,我基于对 Vitest 浏览器模式的深入了解来写这篇文章。
Vitest 浏览器模式:别再用 jsdom 骗自己了
上周 code review 的时候,同事写了个组件测试,断言一个 tooltip 的定位是否正确。测试绿了,CI 也过了。结果上线后 tooltip 飞到了页面左上角。
原因很简单:jsdom 里 getBoundingClientRect() 永远返回全零。测试通过,只是因为你断言的对象压根不存在。
这事让我下定决心把组件测试迁到 Vitest 的浏览器模式。折腾了两周,踩了不少坑,这篇聊聊实际的迁移过程和关键决策。
jsdom 到底假在哪
先说清楚:jsdom 不是不能用,大量纯逻辑的测试它完全够了。但一旦涉及这些场景,它就开始"演"了:
// 在 jsdom 里跑这段
const el = document.createElement('div')
document.body.appendChild(el)
el.style.width = '200px'
console.log(el.getBoundingClientRect())
// → { x: 0, y: 0, width: 0, height: 0, ... }
// 全是零,因为 jsdom 根本没有布局引擎
console.log(getComputedStyle(el).width)
// → '200px' ← 这个倒是能拿到,但只是字符串解析,不是真正计算的
还有一些更隐蔽的坑:
-
IntersectionObserver、ResizeObserver不存在,得手动 mock - CSS 媒体查询不生效,
matchMedia返回的永远是你 mock 的值 -
focus()、blur()的行为和真实浏览器不一样 - Canvas、WebGL 相关 API 全部缺失
你 mock 得越多,测试就越像在测你的 mock 而不是测你的组件。
Vitest 浏览器模式是什么
Vitest 从 1.x 开始提供了 browser 模式(2.x 起趋于稳定)。核心思路很直接:测试代码直接跑在真实浏览器里,而不是 Node.js + jsdom 的模拟环境。
它的架构大概是这样:
Vitest (Node.js 侧)
├── 启动浏览器实例(通过 Provider)
├── 把测试文件打包,注入浏览器页面
├── 测试在浏览器里执行
└── 结果回传到 Node.js 侧汇总
Provider 目前支持三个:playwright、webdriverio、preview。我选了 Playwright,原因后面说。
搭起来
安装:
# vitest 本体 + 浏览器模块 + playwright provider
pnpm add -D vitest @vitest/browser playwright
配置 vitest.config.ts:
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
provider: 'playwright',
// 用 chromium 就够了,没必要三个都开
instances: [
{ browser: 'chromium' },
],
},
},
})
跑一个最基础的测试验证环境:
// src/__tests__/sanity.test.ts
import { expect, test } from 'vitest'
test('真实浏览器环境验证', () => {
const div = document.createElement('div')
div.style.width = '100px'
div.style.height = '50px'
div.style.position = 'absolute'
document.body.appendChild(div)
const rect = div.getBoundingClientRect()
// 在 jsdom 里这俩是 0,在真实浏览器里是正确值
expect(rect.width).toBe(100)
expect(rect.height).toBe(50)
div.remove()
})
如果这个测试过了,环境就没问题。
为什么选 Playwright 而不是 WebDriverIO
这个选择其实没啥悬念:
- Playwright 是 Chromium 内核直连,启动快、API 丰富、调试体验好。Vitest 团队自己也在推这个方向。
- WebDriverIO 走的是 WebDriver 协议,多一层通信开销,在纯单元测试场景下没什么优势。
- preview 模式跑在 iframe 里,没有真正隔离,不支持多浏览器。适合快速预览,不适合正经测试。
我在两个项目里分别试过 Playwright 和 WebDriverIO,前者在 200 个组件测试的场景下快了大概 40%。冷启动差距更明显。
组件测试怎么写
如果你用 React 或 Vue,需要装对应的渲染器。以 React 为例:
pnpm add -D @vitest/browser/context
Vitest 浏览器模式提供了一套内置的交互 API,不需要再装 @testing-library/user-event:
// components/Counter.test.tsx
import { render } from 'vitest-browser-react' // React 用这个
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import Counter from './Counter'
test('点击按钮后计数器加 1', async () => {
render(<Counter initialCount={0} />)
// page.getByRole 返回的是 Locator,跟 Playwright 的体验很像
const button = page.getByRole('button', { name: '加一' })
const display = page.getByTestId('count-display')
await expect.element(display).toHaveTextContent('0')
await button.click() // 真实的 click,不是 fireEvent 模拟的
await expect.element(display).toHaveTextContent('1')
})
几个关键差异点:
// ❌ testing-library 风格(jsdom 时代)
// fireEvent.click(button) → 同步的,合成事件
// screen.getByText('xxx') → 返回 DOM 元素
// ✅ vitest browser 风格
// await button.click() → 异步的,真实浏览器事件
// page.getByRole(...) → 返回 Locator,惰性求值
// await expect.element(el) → 专用的元素断言,自带重试
注意那个 expect.element(),这东西自带 retry 机制,等元素出现再断言。不用自己写 waitFor 了。
处理样式和布局相关的测试
这才是浏览器模式真正值回票价的地方。
// 测试 Tooltip 定位 —— 这在 jsdom 里根本没法测
import { render } from 'vitest-browser-react'
import { page } from '@vitest/browser/context'
import Tooltip from './Tooltip'
test('tooltip 出现在触发元素的下方', async () => {
render(
<div style={{ paddingTop: '100px' }}>
<Tooltip content="提示文字">
<button>hover me</button>
</Tooltip>
</div>
)
const trigger = page.getByRole('button', { name: 'hover me' })
await trigger.hover() // 真实的 hover,CSS :hover 伪类也会生效
const tooltip = page.getByRole('tooltip')
await expect.element(tooltip).toBeVisible()
// 拿到真实的位置信息
const triggerEl = trigger.element()
const tooltipEl = tooltip.element()
const triggerRect = triggerEl.getBoundingClientRect()
const tooltipRect = tooltipEl.getBoundingClientRect()
// tooltip 的顶部应该在 trigger 的底部附近
expect(tooltipRect.top).toBeGreaterThanOrEqual(triggerRect.bottom)
})
类似的,CSS 动画测试、媒体查询响应式测试、滚动行为测试——这些之前要么 mock 一堆要么直接放弃的场景,现在都能正经测了。
和现有 jsdom 测试共存
大概率你不会一次性迁移所有测试。好消息是,Vitest 支持同一个项目里两套测试环境共存:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
// 默认还是用 jsdom 跑(存量测试不受影响)
environment: 'jsdom',
browser: {
enabled: true,
provider: 'playwright',
instances: [
{ browser: 'chromium' },
],
},
},
})
然后通过文件命名或目录来区分:
src/
__tests__/
utils.test.ts ← 纯逻辑,jsdom 跑
__browser_tests__/
Tooltip.browser.test.tsx ← 涉及布局,浏览器模式跑
在 Vitest workspace 配置里分别指定就行。我个人倾向于用 xxx.browser.test.ts 这种命名来区分,比放不同目录直观。
性能:绕不开的话题
实话说,浏览器模式比 jsdom 慢。启动一个 Chromium 实例是有代价的。
我在一个中型项目(约 150 个组件测试)里的实际数据:
| 指标 | jsdom | Browser (Playwright) |
|---|---|---|
| 冷启动 | ~2s | ~5s |
| 单个测试平均 | ~50ms | ~120ms |
| 全量跑完 | ~12s | ~28s |
| CI 环境 | ~18s | ~45s |
慢了一倍多。但这不是不能接受,尤其考虑到测试的可信度提升了一个量级。
几个优化手段:
// 1. 复用浏览器实例(默认行为,确认没关掉就行)
// vitest.config.ts
{
test: {
browser: {
// headless 模式在 CI 里快不少
headless: true, // 默认就是 true,确认一下
},
},
}
// 2. 不需要每个测试都用浏览器模式
// 纯逻辑的单元测试继续用 jsdom,只有涉及 DOM 交互/布局的用浏览器
// 3. 并行度调整
// Playwright 的 chromium 实例比较吃内存,CI 机器配置不够的话可能要降并行数
{
test: {
browser: {
// 如果 CI 内存不够,适当降低
},
fileParallelism: true, // 文件级别并行
},
}
调试体验
这是我没预料到的一个加分项。
vitest --browser 启动后会开一个调试页面,能直接在浏览器里看到测试的渲染结果。组件测试失败的时候,你可以打开 DevTools 直接审查 DOM,看看是布局问题还是逻辑问题。
比起 jsdom 模式下对着一堆 prettyDOM() 输出猜问题,体验好太多了。
# 开发时用 headed 模式,能看到实际渲染
npx vitest --browser.headless=false
之前有个 Dialog 组件的测试,在 jsdom 下一直报 focus 不符合预期。切到浏览器模式后,打开 DevTools 一看,原来是 tabindex 设错了,焦点根本没有落到目标元素上。这种问题在 jsdom 里是发现不了的,因为 jsdom 的 focus 模型本身就是简化过的。
几个坑
1. 全局变量要小心
浏览器模式下 window、document 是真实的,但 Vitest 的一些 API(比如 vi.mock)还是跑在一个特殊的上下文里。模块级别的 mock 有些情况会有问题,遇到了就看报错信息排查,多数是执行时序导致的。
2. 文件系统 API 不可用
测试跑在浏览器里,fs、path 这些 Node.js API 用不了。如果你的组件测试辅助函数依赖了 Node 模块,需要拆分。
3. CSS 文件的处理
浏览器模式下 CSS 是真正被加载和应用的(而不是像 jsdom 那样被忽略或 mock),所以如果你的组件依赖全局样式,确保在测试环境里也能加载到。可以在 vitest.setup.ts 里 import 全局样式文件。
// vitest.setup.ts
import './src/styles/global.css'
// 浏览器模式下这个 import 会真正生效
4. CI 环境配置
GitHub Actions 里要装浏览器依赖:
# .github/workflows/test.yml
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
# 只装 chromium 就行,装全套太慢了
什么时候该用,什么时候没必要
并不是所有测试都得迁到浏览器模式。我的判断标准:
用浏览器模式:
- 组件测试涉及布局、定位、滚动
- 测试依赖 CSS 生效(比如
display: none的可见性判断) - 测试涉及焦点管理、键盘导航
- 测试用到了 Canvas、IntersectionObserver 等浏览器专有 API
- 你在 jsdom 里 mock 了超过 3 个浏览器 API —— 这是个信号
继续用 jsdom:
- 纯逻辑的 hooks 测试
- 状态管理相关的单元测试
- 简单的渲染快照测试
- 不涉及任何浏览器特有行为的组件
两者不是替代关系,是互补的。
聊到这
Vitest 浏览器模式解决的核心问题就一个:让你的组件测试跑在跟用户一样的环境里。
jsdom 用了这么多年,大家都习惯了"mock 一下就好"的心态。但 mock 多了以后,你测的到底是组件逻辑还是 mock 的正确性,有时候真说不清。
迁移成本确实有,速度也确实慢一些。但对于组件库、UI 密集型项目,或者你已经被 jsdom 的各种 mock 搞得头疼的场景,值得试试。
对了,Vitest 的浏览器模式 API 还在演进中,有些边界行为可能后续版本会调整。建议跟着 Vitest 的 release notes 走,别锁太老的版本。