Supabase 仓库的 Vitest 测试环境实践:从 jsdom 配置到自定义 Environment
本文以 Vitest 的测试环境(Test Environments)为主题,系统讲解 node、jsdom、happy-dom、edge-runtime 四类环境的选用原则与配置方法,并以 Supabase 官方仓库中 Studio、UI 组件库等真实项目的 Vitest 配置与 Polyfill 实现为佐证,帮助你在单测中正确搭建浏览器 API 环境、按文件切换环境、编写自定义 Environment,并处理 CSS/资源与外部依赖的常见坑。
一、可用环境与适用场景
Vitest 提供四种内置测试环境,各自模拟不同的运行时能力:
| 环境 | 说明 |
|---|---|
node(默认) |
Node.js 环境,无任何浏览器 API |
jsdom |
类浏览器环境,提供较完整的 DOM API |
happy-dom |
jsdom 的更快速替代,API 覆盖面较少 |
edge-runtime |
Vercel Edge Runtime 环境 |
选型的核心逻辑是"够用即可":纯逻辑/工具函数代码跑在默认的 node 环境最快、最接近真实执行;只有当被测代码真正触碰 document、window、localStorage 等浏览器 API 时,才需要切到 jsdom 或 happy-dom。
Supabase 仓库中的真实环境分布
Supabase 仓库内多个包各自维护 Vitest 配置,其环境选择正好印证了上述原则:
- apps/studio/vitest.config.ts 明确设置
environment: 'jsdom',因为 Studio 是大量 React 组件的前端应用,几乎所有用例都要操作 DOM; - packages/ui/vitest.config.ts 与 packages/ui-patterns/vitest.config.ts 同样使用
environment: 'jsdom'; - packages/ai-commands/vitest.config.ts 显式声明
environment: 'node',测试的是纯逻辑模块; - apps/docs/vitest.config.ts 与 apps/www/vitest.config.ts 则完全不写
environment字段,依赖默认的node环境——它们主要测试构建脚本、中间件与数据生成逻辑,不需要浏览器 API。
从源码结构看,仓库的环境选择呈现清晰分层:UI 组件层用 jsdom,逻辑/文档层保持 node。值得注意的是 apps/studio/vitest.config.ts 中有一行注释:
environment: 'jsdom', // TODO(kamil): This should be set per test via header in .tsx files only
这表明团队正倾向于用下一节介绍的"魔法注释"按文件精细控制环境,而非全局一刀切。
二、环境配置与包安装
基础配置
在 vitest.config.ts 中通过 test.environment 指定环境,并可配合 environmentOptions 传入环境专属参数:
// vitest.config.ts
defineConfig({
test: {
environment: 'jsdom',
// Environment-specific options
environmentOptions: {
jsdom: {
url: 'http://localhost',
},
},
},
})
安装环境包
jsdom 与 happy-dom 不是 Vitest 内置的,需要作为开发依赖单独安装:
# jsdom
npm i -D jsdom
# happy-dom (faster, fewer APIs)
npm i -D happy-dom
Supabase 仓库采用 pnpm workspace 管理版本:各包通过 "vitest": "catalog:" 引用统一版本(见 apps/studio/package.json),而 packages/ai-commands/package.json 这类 node 环境包则不需要引入任何 DOM 模拟库。
三、按文件切换环境:@vitest-environment 魔法注释
当同一工程中既有纯逻辑测试、又有 DOM 测试时,不必为每个目录拆项目——直接在测试文件顶部加一行注释即可覆盖该文件的环境:
// @vitest-environment jsdom
import { expect, test } from 'vitest'
test('DOM test', () => {
const div = document.createElement('div')
expect(div).toBeInstanceOf(HTMLDivElement)
})
这个注释的作用范围仅限当前文件,优先级高于配置文件中的全局 environment,是处理"混合环境"最轻量的手段,也是 Supabase Studio 配置中 TODO 注释所指的目标做法。
四、jsdom 环境实战:DOM 操作与 API 覆盖
jsdom 提供完整的浏览器环境模拟,可以直接操作 DOM、访问 window API:
// @vitest-environment jsdom
test('DOM manipulation', () => {
document.body.innerHTML = '<div id="app"></div>'
const app = document.getElementById('app')
app.textContent = 'Hello'
expect(app.textContent).toBe('Hello')
})
test('window APIs', () => {
expect(window.location.href).toBeDefined()
expect(localStorage).toBeDefined()
})
jsdom 专属选项
environmentOptions.jsdom 支持以下参数(与 jsdom 构造选项对齐):
defineConfig({
test: {
environmentOptions: {
jsdom: {
url: 'http://localhost:3000', // window.location 的初始值
html: '<!DOCTYPE html><html><body></body></html>', // 初始文档
userAgent: 'custom-agent', // navigator.userAgent
resources: 'usable', // 是否加载外部资源(CSS/图片等)
},
},
},
})
其中 url 直接影响依赖 location.href 的断言与路由逻辑;resources: 'usable' 会启用外部资源加载,仅在测试确实需要时开启,否则会拖慢测试速度。
jsdom 的短板:缺失 API 需要 Polyfill
jsdom 模拟的是"结构完整"的浏览器,而非"能力完整"的浏览器——matchMedia、ResizeObserver、scrollIntoView 等 API 并不实现。Supabase 仓库的两个 Polyfill 文件是处理这些短板的范例:
Studio 的全局 Polyfill(通过 apps/studio/vitest.config.ts 的 setupFiles 引入,实现在 apps/studio/tests/setup/polyfills.ts):
- 使用
jsdom-testing-mocks的configMocks({ act })自动 patchscrollTo等滚动 API 并接入 React 的act; - 手工 mock
window.matchMedia,返回一个包含matches、addListener、removeListener、addEventListener等完整结构的函数——这是大量响应式 UI 组件库测试的必备项; - 从 Node 原生模块把
TextDecoder/TextEncoder、ReadableStream/TransformStream挂载到全局(jsdom 默认不提供 Web Streams); - 当
window.localStorage.getItem不可用时,用Map实现一个完整的localStorage代理(含key、lengthgetter)并挂到window与globalThis上; - 补充
window.HTMLElement.prototype.hasPointerCapture等缺失原型方法。
文件内还有一段值得注意的注释提醒:restoreMocks: true 会导致这里的 global mockImplementation 在测试开始前被重置,说明 Polyfill 时机与 Vitest mock 恢复策略之间存在交互,配置时需注意。
UI 组件包的 Polyfill(packages/ui-patterns/vitest.setup.ts)则展示了最小化组合:mock matchMedia、ResizeObserver、scrollIntoView,mock next/navigation 为 next-router-mock,并导入 @testing-library/jest-dom/vitest 扩展断言,最后在 afterEach 中执行 cleanup()。两个文件放在一起对照可以看出:jsdom 环境的生产级配置 = 环境选择 + setupFiles 中成体系的 Polyfill,缺一不可。
五、happy-dom:更快的替代
happy-dom 的 DOM API 覆盖少于 jsdom,但执行速度更快,适合只需要基础 DOM 操作(创建元素、设置属性/类名)的测试:
// @vitest-environment happy-dom
test('basic DOM', () => {
const el = document.createElement('div')
el.className = 'test'
expect(el.className).toBe('test')
})
选型建议:如果用例只依赖 createElement/属性读写这类基础能力,happy-dom 是更经济的选择;一旦需要较复杂的 CSS 解析、事件代理或较新的 DOM 标准,就回到 jsdom。Supabase 仓库当前全部采用 jsdom,说明其 UI 测试对 DOM 能力的依赖超出了 happy-dom 的覆盖范围。
六、多环境共存:使用 projects 拆分
当项目需要"单元测试走 node、DOM 测试走 jsdom"的结构性分工时,用 test.projects 按目录划分环境,每个 project 拥有独立的 include 与 environment:
defineConfig({
test: {
projects: [
{
test: {
name: 'unit',
include: ['tests/unit/**/*.test.ts'],
environment: 'node',
},
},
{
test: {
name: 'dom',
include: ['tests/dom/**/*.test.ts'],
environment: 'jsdom',
},
},
],
},
})
Supabase 仓库本身采用的是另一种"多环境并存"形态:不在单工程内拆 projects,而是把 node 环境(apps/docs/vitest.config.ts、apps/www/vitest.config.ts)与 jsdom 环境(Studio、UI 包)拆成独立包、各自持有 vitest 配置——两者殊途同归,核心都是让不同测试群体落在最合适的运行时里。
七、编写自定义 Environment
当内置环境都不贴合需求(例如需要预置全局状态、自定义 viteEnvironment 行为)时,可以实现 Environment 接口:
// vitest-environment-custom/index.ts
import type { Environment } from 'vitest/runtime'
export default <Environment>{
name: 'custom',
viteEnvironment: 'ssr', // or 'client'
setup() {
// Setup global state
globalThis.myGlobal = 'value'
return {
teardown() {
delete globalThis.myGlobal
},
}
},
}
然后在配置中引用:
defineConfig({
test: {
environment: 'custom',
},
})
setup() 在测试文件执行前运行,其返回的 teardown 在结束后清理,保证环境不跨文件泄漏——这一"setup/teardown 成对"的约束与 Vitest 的钩子生命周期一致。
Environment + VM:完全隔离
需要把测试代码放进独立 VM 上下文(彻底隔离全局对象)时,自定义环境可实现 setupVM():
export default <Environment>{
name: 'isolated',
viteEnvironment: 'ssr',
async setupVM() {
const vm = await import('node:vm')
const context = vm.createContext()
return {
getVmContext() {
return context
},
teardown() {},
}
},
setup() {
return { teardown() {} }
},
}
getVmContext() 返回的 Node vm 上下文将成为测试代码的真实全局环境,适合测试对沙箱隔离本身有要求的模块。
八、Browser Mode:真浏览器测试,而非环境
必须区分两个概念:environment 是在进程内模拟浏览器,而 Browser Mode 是把测试真正跑在真实浏览器中。对于像素级渲染、真实事件循环、WebGL 等场景,环境模拟永远不够,应启用:
defineConfig({
test: {
browser: {
enabled: true,
name: 'chromium', // or 'firefox', 'webkit'
provider: 'playwright',
},
},
})
判断标准:如果你的测试在 jsdom 里通过、在真浏览器里失败(或反过来),说明被测代码依赖了模拟层与真实实现的行为差异,此时该用例应迁入 Browser Mode,而不是继续给 jsdom 打补丁。
九、CSS 与静态资源处理
在 jsdom/happy-dom 下,CSS 是否参与处理需要显式声明。默认情况下 Vitest 会跳过 CSS 转换,仅当组件依赖 CSS Modules 生成的类名映射时才需要开启:
defineConfig({
test: {
css: true, // Process CSS
// Or with options
css: {
include: /\.module\.css$/,
modules: {
classNameStrategy: 'non-scoped',
},
},
},
})
css: { include: /\.module\.css$/ } 表示只处理 CSS Module 文件,其余样式依旧被忽略,兼顾了测试速度与类名断言的准确性;classNameStrategy: 'non-scoped' 控制模块类名是否保留 hash 作用域。
十、外部依赖报错:用 server.deps.inline 内联
当 node_modules 中某个包因 CSS import、资源引用或浏览器专属写法导致解析失败时,说明 Vitest 把它当作"外部依赖"直接执行了。解决办法是将其拉入 Vite 转换管线:
defineConfig({
test: {
server: {
deps: {
inline: ['problematic-package'],
},
},
},
})
被 inline 的包会经过 Vite 的完整转换(JSX、CSS、别名等),代价是速度略降,因此应尽量只内联真正出错的包,而非通配全部。
十一、关键要点小结
- 默认环境是
node,无浏览器 API——纯逻辑测试保持默认即可(Supabase 的 apps/www、apps/docs 即如此); - 完整浏览器模拟用
jsdom,基础 DOM 且追求速度用happy-dom(Supabase 的 apps/studio 与 UI 包均为前者); - 单文件切环境用
// @vitest-environment注释,多套环境并存用test.projects拆分; - jsdom 的缺失 API(matchMedia、ResizeObserver、Streams、localStorage 等)需在
setupFiles中成体系地 Polyfill,可参考 apps/studio/tests/setup/polyfills.ts 与 packages/ui-patterns/vitest.setup.ts 的实际写法; - Browser Mode 面向真实浏览器,与
environment是两个独立维度,不要混为一谈; - CSS 处理与依赖内联(
server.deps.inline)是 DOM 环境下最常见的两类报错来源,分别用css选项和 inline 列表针对性解决。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00