Read OSS

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 的定义十分简洁:

pnpm-workspace.yaml#L20-L24

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/runnervitest 没有任何依赖——它以框架无关的方式定义了测试 DSL 和执行引擎。vitest 包则在此基础上组合这些子包,借助 Vite 的模块转换管道和 Node.js 的 worker 管理将它们串联起来。

提示: 浏览代码库时,建议从 packages/vitest/src/public/ 入手——这里存放的是明确的 API 边界定义,能帮你快速定位任何使用场景所对应的内部模块。

两个执行域:Node 与 Runtime

Vitest 最重要的架构边界,是两个执行域之间的分割:

  1. Node 域packages/vitest/src/node/)——主进程,负责统筹全局:CLI 解析、配置解析、Vite 服务器管理、pool 创建、reporter 分发、文件监听以及 WebSocket API。

  2. 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

这种分离是出于隔离性和并行性的考量。测试文件可能依赖 jsdomhappy-dom 或其他会操作全局状态的环境,将它们运行在各自独立的 worker 中可以避免相互干扰。两个域之间的通信桥梁是 birpc——一个双向 RPC 库,能在 MessagePort(线程模式)或 process.send(fork 模式)之上提供透明的函数调用接口。

Vitest 类——核心编排器

位于 packages/vitest/src/node/core.tsVitest 类是整个框架的重心,掌管着所有主要子系统:

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

构造函数故意保持轻量——只创建 LoggerVitestSpecificationsVitestWatcher。真正的初始化发生在 _setServer() 方法中,该方法在 Vite 的 configureServer 钩子触发后被调用:

packages/vitest/src/node/core.ts#L207-L231

这个方法负责解析配置、创建 StateManagerVitestCacheSnapshotManagerTestRun,随后初始化模块运行器与 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 中的项目解析逻辑支持三种定义方式:

  1. 配置文件路径 — 通过 glob 解析的 vitest.config.tsvite.config.ts 文件
  2. 目录路径 — 扫描目录以查找配置文件
  3. 内联对象 — 直接在 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)重新导出了测试文件所需的一切:describetestitexpectvibench、各类钩子以及类型工具。它从 @vitest/runner@vitest/expect 以及 vitest 自身的集成模块中聚合导入。

编程式 Node API(src/public/node.ts)则导出了以编程方式控制 Vitest 所需的一切:createViteststartVitest、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 管理依赖预构建优化,MetaEnvReplacerPluginimport.meta.env 替换为 process.env,以支持运行时动态赋值。

提示: 排查配置问题时,plugins/index.ts 第 44 行的 config() 钩子是 Vitest 默认值与你的配置首次合并的地方——这里是最终解析配置的真正起点。

下一步

架构全貌已经厘清,接下来我们将追踪真实的执行路径。下一篇文章将从二进制入口开始,完整跟踪一条 vitest run 命令的生命周期:CLI 解析、配置文件发现、Vite 服务器创建、完整的插件钩子生命周期、workspace 解析,以及配置序列化传递给 workers 的过程。理解这套启动流程,是排查配置问题和在 Vitest 编程式 API 之上构建自定义工具的必备基础。