Vitest 架构概览:基于 Vite 的测试框架是如何组织的
前置知识
- ›对 Vite 有基本了解(开发服务器、插件机制、模块转换)
- ›熟悉 npm/pnpm workspaces 和 monorepo 的基本概念
- ›具备 TypeScript 工作经验
Vitest 架构概览:基于 Vite 的测试框架是如何组织的
Vitest 并不是一个"碰巧用了 Vite"的测试运行器——它本质上就是一个 Vite 插件,将开发服务器改造成了一套完整的测试平台。理解这一核心设计决策,是读懂整个代码库的钥匙:为什么配置会流经 Vite 的插件钩子,为什么每个 workspace 项目都有独立的 ViteDevServer 实例,以及为什么测试文件会经过与应用代码相同的转换管道。
本文将为你绘制这片领域的全貌。我们会逐一梳理 monorepo 的 17 个子包,厘清 Node 编排层与 Worker 运行时之间的关键边界,剖析核心的 Vitest 类,并理解双层 API 设计如何将测试时关注点与编程式关注点清晰隔离。
Monorepo 结构与包索引
Vitest 采用 pnpm workspace 组织,子包位于 packages/ 目录下,文档、示例和集成测试则分别存放在独立目录中。workspace 的定义十分简洁:
packages:
- docs
- packages/*
- examples/*
- test/*
以下是完整的包索引:
| 包名 | 用途 |
|---|---|
vitest |
核心框架:CLI、配置、编排、pool、reporters、运行时 workers |
@vitest/runner |
框架无关的测试运行器:DSL(describe/test/it)、收集、执行、钩子、fixtures |
@vitest/expect |
基于 Chai 构建的断言库,兼容 Jest 风格的 matchers |
@vitest/spy |
Mock/spy 系统(vi.fn()、vi.spyOn()),基于 tinyspy 构建 |
@vitest/snapshot |
快照测试:内联快照与文件快照 |
@vitest/mocker |
模块 mock 基础设施(vi.mock()、vi.hoisted()) |
@vitest/utils |
共享工具:错误处理、source maps、序列化、diff |
@vitest/pretty-format |
用于快照和 diff 的值序列化(fork 自 Jest) |
@vitest/browser |
浏览器测试编排 |
@vitest/browser-playwright |
Playwright 浏览器 provider |
@vitest/browser-webdriverio |
WebDriverIO 浏览器 provider |
@vitest/browser-preview |
浏览器预览 UI 组件 |
@vitest/coverage-v8 |
V8 代码覆盖率 provider |
@vitest/coverage-istanbul |
Istanbul 代码覆盖率 provider |
@vitest/ui |
基于 Vue 构建的可视化看板 UI |
@vitest/web-worker |
用于 Node 测试环境的 Web Worker polyfill |
@vitest/ws-client |
供 UI 通信使用的 WebSocket 客户端 |
graph TD
subgraph "Core"
V[vitest]
R["@vitest/runner"]
E["@vitest/expect"]
S["@vitest/spy"]
SN["@vitest/snapshot"]
M["@vitest/mocker"]
U["@vitest/utils"]
PF["@vitest/pretty-format"]
end
subgraph "Browser"
B["@vitest/browser"]
BP["@vitest/browser-playwright"]
BW["@vitest/browser-webdriverio"]
BPR["@vitest/browser-preview"]
end
subgraph "Coverage"
CV8["@vitest/coverage-v8"]
CIS["@vitest/coverage-istanbul"]
end
subgraph "UI & Tools"
UI["@vitest/ui"]
WW["@vitest/web-worker"]
WS["@vitest/ws-client"]
end
V --> R
V --> E
V --> S
V --> SN
V --> M
V --> U
E --> U
E --> PF
SN --> PF
B --> V
UI --> WS
这里有一个关键的设计思路:严格的分层。@vitest/runner 对 vitest 没有任何依赖——它以框架无关的方式定义了测试 DSL 和执行引擎。vitest 包则在此基础上组合这些子包,借助 Vite 的模块转换管道和 Node.js 的 worker 管理将它们串联起来。
提示: 浏览代码库时,建议从
packages/vitest/src/public/入手——这里存放的是明确的 API 边界定义,能帮你快速定位任何使用场景所对应的内部模块。
两个执行域:Node 与 Runtime
Vitest 最重要的架构边界,是两个执行域之间的分割:
-
Node 域(
packages/vitest/src/node/)——主进程,负责统筹全局:CLI 解析、配置解析、Vite 服务器管理、pool 创建、reporter 分发、文件监听以及 WebSocket API。 -
Runtime 域(
packages/vitest/src/runtime/)——Worker 线程或子进程,负责实际执行测试文件:环境初始化、通过 Vite 转换管道加载模块、收集测试用例并执行。
flowchart LR
subgraph "Node Domain (Main Process)"
CLI[CLI / Programmatic API]
Core[Vitest Class]
Config[Config Resolution]
Pool[Pool Manager]
Reporters[Reporters]
State[StateManager]
end
subgraph "Runtime Domain (Workers)"
Worker[Worker Entry]
Env[Environment Setup]
Runner[Test Runner]
Modules[Module Loading via Vite]
end
CLI --> Core
Core --> Config
Core --> Pool
Core --> Reporters
Core --> State
Pool -- "birpc over MessagePort" --> Worker
Worker --> Env
Worker --> Runner
Runner --> Modules
Modules -- "RPC fetch()" --> Core
Runner -- "RPC events" --> State
这种分离是出于隔离性和并行性的考量。测试文件可能依赖 jsdom、happy-dom 或其他会操作全局状态的环境,将它们运行在各自独立的 worker 中可以避免相互干扰。两个域之间的通信桥梁是 birpc——一个双向 RPC 库,能在 MessagePort(线程模式)或 process.send(fork 模式)之上提供透明的函数调用接口。
Vitest 类——核心编排器
位于 packages/vitest/src/node/core.ts 的 Vitest 类是整个框架的重心,掌管着所有主要子系统:
classDiagram
class Vitest {
+version: string
+logger: Logger
+projects: TestProject[]
+watcher: VitestWatcher
+vcs: VCSProvider
-pool: ProcessPool
-_vite: ViteDevServer
-_state: StateManager
-_cache: VitestCache
-_snapshot: SnapshotManager
-_testRun: TestRun
+config: ResolvedConfig
+vite: ViteDevServer
+state: StateManager
+snapshot: SnapshotManager
+cache: VitestCache
+_setServer(options, server)
+start(cliFilters)
+close()
}
Vitest --> ViteDevServer
Vitest --> "1..*" TestProject
Vitest --> ProcessPool
Vitest --> StateManager
Vitest --> SnapshotManager
Vitest --> VitestCache
Vitest --> TestRun
Vitest --> VitestWatcher
构造函数故意保持轻量——只创建 Logger、VitestSpecifications 和 VitestWatcher。真正的初始化发生在 _setServer() 方法中,该方法在 Vite 的 configureServer 钩子触发后被调用:
packages/vitest/src/node/core.ts#L207-L231
这个方法负责解析配置、创建 StateManager、VitestCache、SnapshotManager 和 TestRun,随后初始化模块运行器与 VCS provider。这样的设计使 Vitest 能够在配置文件变更时重新初始化——_setServer() 会在重建之前仔细清理所有旧状态。
多项目 Workspace 架构
Vitest 支持并行运行多个项目,每个项目拥有独立的配置、Vite 服务器和模块解析器。packages/vitest/src/node/project.ts#L45-L93 中的 TestProject 类代表一个 workspace 项目:
flowchart TD
V[Vitest] --> P1[TestProject 'unit']
V --> P2[TestProject 'integration']
V --> P3[TestProject 'e2e-chromium']
P1 --> VS1[ViteDevServer]
P1 --> C1[ResolvedConfig]
P1 --> R1[VitestResolver]
P2 --> VS2[ViteDevServer]
P2 --> C2[ResolvedConfig]
P2 --> R2[VitestResolver]
P3 --> VS3[ViteDevServer]
P3 --> C3[ResolvedConfig]
P3 --> R3[VitestResolver]
每个 TestProject 都有专属的 ViteDevServer 实例,因此每个项目可以拥有不同的 Vite 插件、路径别名和模块解析配置。packages/vitest/src/node/projects/resolveProjects.ts#L28-L34 中的项目解析逻辑支持三种定义方式:
- 配置文件路径 — 通过 glob 解析的
vitest.config.ts或vite.config.ts文件 - 目录路径 — 扫描目录以查找配置文件
- 内联对象 — 直接在 workspace 定义中提供的配置对象
项目名称必须唯一,系统会在返回之前对此进行校验。浏览器项目有特殊处理逻辑——配置了 browser.instances 的单个项目会派生出多个子项目,每个浏览器对应一个。
构建入口与双层 API
rollup.config.js 揭示了一套经过精心设计的双层 API:
flowchart TD
subgraph "Test-time API (vitest)"
IDX["src/public/index.ts"]
IDX --> describe & test & it & expect & vi
end
subgraph "Programmatic Node API (vitest/node)"
NODE["src/public/node.ts"]
NODE --> createVitest & startVitest & reporters & config_types
end
subgraph "Worker Entries"
WT["workers/threads"]
WF["workers/forks"]
WVM["workers/vmThreads"]
WVF["workers/vmForks"]
end
subgraph "Other Entries"
CLI["cli"]
COV["coverage"]
SNAP["snapshot"]
end
测试时 API(src/public/index.ts)重新导出了测试文件所需的一切:describe、test、it、expect、vi、bench、各类钩子以及类型工具。它从 @vitest/runner、@vitest/expect 以及 vitest 自身的集成模块中聚合导入。
编程式 Node API(src/public/node.ts)则导出了以编程方式控制 Vitest 所需的一切:createVitest、startVitest、reporter 类、pool workers、配置类型以及 sequencer。IDE 扩展和自定义工具链都依赖这套 API。
这种分离是有意为之的——测试文件不应该导入 Node 侧的编排代码,构建工具也不应该导入测试全局变量。Worker 入口被单独打包以提升性能:导入 workers/threads 不会把整个框架都拉进来。
Vite 作为核心支柱
Vitest 不是一个使用 Vite 的独立工具——它本身就是一个 Vite 插件数组。packages/vitest/src/node/plugins/index.ts#L26-L291 中的 VitestPlugin 函数返回一组插件,将 Vite 转变为测试运行器:
flowchart TD
VP["VitestPlugin()"] --> Core["vitest (core plugin)"]
VP --> ME["MetaEnvReplacerPlugin"]
VP --> CSS["CSSEnablerPlugin"]
VP --> COV["CoverageTransform"]
VP --> RES["VitestCoreResolver"]
VP --> MOCK["MocksPlugins"]
VP --> OPT["VitestOptimizer"]
VP --> NORM["NormalizeURLPlugin"]
VP --> MRT["ModuleRunnerTransform"]
Core -- "config()" --> MergeDefaults["Merge configDefaults with user config"]
Core -- "configResolved()" --> SetupVitest["Store config, set env variables"]
Core -- "configureServer()" --> Init["vitest._setServer()"]
核心 vitest 插件使用了三个 Vite 钩子:
config()— 将 Vitest 的configDefaults与用户配置合并,配置服务器选项(禁用 HMR、设置 API 端口),并调整 esbuild/oxc 的编译目标。configResolved()— 在所有插件运行完毕后执行最终的配置合并,处理 UI 插件注入,并将解析后的配置存储到 Vite 配置对象上。configureServer()— 在 Vite 服务器就绪后调用vitest._setServer(),完成初始化。
各子插件负责处理专项逻辑:CoverageTransform 在转换阶段为代码注入覆盖率插桩,MocksPlugins 在转换层拦截 vi.mock() 调用,VitestOptimizer 管理依赖预构建优化,MetaEnvReplacerPlugin 将 import.meta.env 替换为 process.env,以支持运行时动态赋值。
提示: 排查配置问题时,
plugins/index.ts第 44 行的config()钩子是 Vitest 默认值与你的配置首次合并的地方——这里是最终解析配置的真正起点。
下一步
架构全貌已经厘清,接下来我们将追踪真实的执行路径。下一篇文章将从二进制入口开始,完整跟踪一条 vitest run 命令的生命周期:CLI 解析、配置文件发现、Vite 服务器创建、完整的插件钩子生命周期、workspace 解析,以及配置序列化传递给 workers 的过程。理解这套启动流程,是排查配置问题和在 Vitest 编程式 API 之上构建自定义工具的必备基础。