Read OSS

从 CLI 到配置:Vitest 的启动与配置解析流程

中级

前置知识

  • 第 1 篇:架构与项目结构
  • 对 Vite 插件钩子系统(config、configResolved)有基本了解
  • 熟悉 CLI 参数解析的基本概念

从 CLI 到配置:Vitest 的启动与配置解析流程

当你在终端输入 vitest 的那一刻,一套精心编排的启动流程随之展开:二进制入口加载编译后的 JavaScript,基于 cac 构建的 CLI 解析器处理你的参数,配置文件被定位,注入了 Vitest 插件的 Vite 开发服务器被创建。随后插件钩子依次触发——config()configResolved()configureServer()——最终,Vitest 类携带着已解析的配置、项目列表和 reporter,完成初始化。

理解这条启动链路至关重要。无论是排查某个配置项为何未生效、基于编程式 API 构建 IDE 集成,还是通过自定义插件扩展 Vitest,你都需要清楚地知道自己的代码究竟介入了哪个环节。

二进制入口与 CLI 解析

一切从 packages/vitest/vitest.mjs 开始,这个文件只有两行:

#!/usr/bin/env node
import './dist/cli.js'

编译后的 dist/cli.js 源自 packages/vitest/src/node/cli.ts,同样极为简洁:

import { createCLI } from './cli/cac'
createCLI().parse()

CLI 的实际构建逻辑位于 packages/vitest/src/node/cli/cac.tscreateCLI() 函数创建一个 cac 实例,从类型化的配置对象中注册所有 CLI 选项,并定义各个可用命令:

packages/vitest/src/node/cli/cac.ts#L166-L206

flowchart TD
    BIN["vitest.mjs"] --> CLI["createCLI().parse()"]
    CLI --> CMD{Command?}
    CMD -- "run [...filters]" --> run["start('test', filters, {run: true})"]
    CMD -- "watch [...filters]" --> watch["start('test', filters, {watch: true})"]
    CMD -- "dev [...filters]" --> watch
    CMD -- "bench [...filters]" --> bench["start('benchmark', filters)"]
    CMD -- "list [...filters]" --> collect["collect('test', filters)"]
    CMD -- "init <project>" --> init["init project scaffolding"]
    CMD -- "[...filters] (default)" --> default_cmd["start('test', filters)"]
    run & watch & default_cmd --> startVitest

默认命令(不带子命令)会路由到 start('test', ...),并根据环境自动判断是否启用 watch 模式:若 isCItrue 或 stdin 不是 TTY,则禁用 watch 模式。run 命令则通过设置 options.run = true 来明确关闭 watch 模式。

提示: vitest/node 导出的 parseCLI() 函数支持以编程方式解析 Vitest CLI 字符串,非常适合用于构建那些需要理解 Vitest 调用参数、但无需实际运行的工具。

startVitest() —— 编程式入口

所有 CLI 命令最终都会调用 packages/vitest/src/node/cli/cli-api.ts#L56-L62 中的 startVitest()。这个函数身兼两职——它既是 CLI 的后端实现,也是从 vitest/node 导出的公共编程式 API。

sequenceDiagram
    participant CLI as CLI / User Code
    participant SV as startVitest()
    participant CV as createVitest()
    participant Vite as Vite Dev Server
    participant Vitest as Vitest Instance

    CLI->>SV: startVitest(mode, filters, options)
    SV->>CV: createVitest(mode, options, overrides)
    CV->>Vite: createViteServer(config + VitestPlugin)
    Vite-->>Vitest: configureServer() → _setServer()
    CV-->>SV: return Vitest instance
    SV->>SV: Ensure coverage packages installed
    SV->>SV: Register console shortcuts (if TTY + watch)
    SV->>Vitest: ctx.start(cliFilters)

创建 Vitest 实例后,startVitest() 会依次处理几种特殊模式——列出标签、清除缓存、合并报告、独立模式——最后才进入正常的 ctx.start(cliFilters) 流程。此外,它还负责确认 coverage provider 所需的包已安装(必要时提示用户),并在 watch 模式下注册键盘快捷键。

该函数还会注册一个 onAfterSetServer 回调,确保当 Vite 服务器因配置变更而重启时,测试执行能够自动恢复。

配置文件发现与 Vite 服务器创建

packages/vitest/src/node/create.ts 中的 createVitest() 函数是 Vitest 实例与 Vite 服务器相遇的地方:

flowchart TD
    CV["createVitest()"] --> NewCtx["new Vitest(mode, options)"]
    CV --> FindConfig{"Config path?"}
    FindConfig -- "options.config = false" --> NoConfig["No config file"]
    FindConfig -- "options.config = path" --> Resolve["resolveModule(path)"]
    FindConfig -- "none specified" --> Discover["find.any(configFiles, {cwd: root})"]
    
    Discover --> Names["CONFIG_NAMES × CONFIG_EXTENSIONS"]
    Names --> |"vitest.config.ts<br>vitest.config.mts<br>vitest.config.cts<br>vitest.config.js<br>...<br>vite.config.ts<br>..."| FirstMatch["First match wins"]

    NoConfig & Resolve & FirstMatch --> CreateVite["createViteServer({<br>  configFile,<br>  mode,<br>  plugins: VitestPlugin<br>})"]
    CreateVite --> Server["Vite Dev Server ready"]

配置文件的查找依赖 empathic/find,它会从 packages/vitest/src/constants.ts#L8-L14 定义的候选列表中匹配第一个存在的文件:

export const CONFIG_NAMES: string[] = ['vitest.config', 'vite.config']
export const CONFIG_EXTENSIONS: string[] = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
export const configFiles: string[] = CONFIG_NAMES.flatMap(name =>
  CONFIG_EXTENSIONS.map(ext => name + ext),
)

这会生成 12 个候选文件名:vitest.config.tsvitest.config.mts、……、vite.config.cjs。Vitest 专属配置文件的优先级高于 Vite 配置文件。最终,Vite 服务器以 VitestPlugin 作为 plugins 数组、携带配置文件路径,并以 'test''benchmark' 作为 mode 完成创建。

VitestPlugin 钩子生命周期

正如第 1 篇所介绍的,VitestPlugin 返回一个 Vite 插件数组。在 Vite 服务器创建过程中,核心插件的钩子按以下顺序触发:

packages/vitest/src/node/plugins/index.ts#L44-L59 —— config() 钩子执行初步合并:

const testConfig = deepMerge(
  {} as UserConfig,
  configDefaults,
  removeUndefinedValues(viteConfig.test ?? {}),
  options,
)

合并顺序至关重要:先是 configDefaults,然后是你 Vite 配置中的 test 字段,最后是 CLI 选项。CLI 选项具有最高优先级。该钩子还会从 Vite 配置中剥离 define 值(单独存储以便注入 worker),同时禁用 HMR,并对服务器进行测试模式下的专项配置。

第 212 行configResolved() 钩子执行最终合并——此时其他所有 Vite 插件的修改均已生效。它将最终配置以非枚举属性 _vitest 的形式挂载到 Vite 配置对象上。

最后,第 262 行configureServer() 触发 Vitest 的实际初始化:

configureServer: {
  async handler(server) {
    await vitest._setServer(options, server)
    if (options.api && options.watch) {
      (await import('../../api/setup')).setup(vitest)
    }
    if (!options.watch) {
      await server.watcher.close()
    }
  },
},

配置解析与默认值

packages/vitest/src/node/config/resolveConfig.ts 中的 resolveConfig() 函数负责将用户配置与 Vite 已解析配置合并,生成最终的 ResolvedConfig。它会规范化路径、解析 sequencer、校验选项组合,并应用各环境下的特定默认值。

packages/vitest/src/defaults.ts#L63-L139 中的默认值体现了 Vitest 的设计理念:

export const configDefaults = Object.freeze({
  allowOnly: !isCI,
  isolate: true,
  watch: !isCI && process.stdin.isTTY && !isAgent,
  globals: false,
  environment: 'node',
  include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'],
  exclude: ['**/node_modules/**', '**/.git/**'],
  teardownTimeout: 10000,
  maxConcurrency: 5,
  slowTestThreshold: 300,
  // ...
})

几个值得关注的默认值:isolate: true 表示每个测试文件都在独立的模块上下文中运行;watch 模式仅在非 CI 环境、stdin 为 TTY 且不存在 agent 环境时才会开启;globals: false 意味着 describe/test/expect 默认不会注入全局作用域——你需要显式导入它们。

默认的 include 模式 **/*.{test,spec}.?(c|m)[jt]s?(x) 可以匹配 foo.test.tsbar.spec.mjsbaz.test.cjsx 等文件。默认的 exclude 始终过滤 node_modules.git

提示: 如果遇到"Vitest 找不到测试文件"的问题,首先检查 include 模式。默认规则要求文件名中包含 .test..spec.——这是新用户最常踩的坑,尤其是习惯了基于目录发现测试文件的用户。

Workspace 与多项目解析

_setServer() 完成基础配置解析后,会触发项目解析流程。resolveProjects() 函数通过三条路径处理 workspace 定义:

flowchart TD
    Def["Workspace definitions"] --> Type{Type?}
    Type -- "string (static path)" --> Static["resolve & stat"]
    Static --> IsFile{File?}
    IsFile -- "yes" --> ValidName{"Matches config pattern?"}
    ValidName -- "yes" --> ConfigFiles["Add to config files"]
    IsFile -- "no (directory)" --> ScanDir["Scan for config file"]
    ScanDir -- "found" --> ConfigFiles
    ScanDir -- "not found" --> NonConfig["Non-config directory project"]

    Type -- "string (glob)" --> Glob["glob() match"]
    Glob --> ConfigFiles & NonConfig

    Type -- "object / function" --> Inline["Inline project config"]

    ConfigFiles --> Init["initializeProject()"]
    NonConfig --> Init
    Inline --> Init
    Init --> Unique{"Names unique?"}
    Unique -- "yes" --> Browser["resolveBrowserProjects()"]
    Unique -- "no" --> Error["Throw duplicate name error"]

每个项目都通过 initializeProject() 完成初始化——创建新的 TestProject,用 WorkspaceVitestPlugin 搭建独立的 Vite 服务器,并独立解析其配置。--pool--globals--bail 等 CLI 覆盖选项会被传播到所有项目。

解析过程基于可用 CPU 核心数,通过 limitConcurrency() 并发执行。初始化失败的项目会被统一收集,以 AggregateError 的形式报告,而不是遇到第一个错误就立即中止。

向 Worker 序列化配置

Node 侧完成配置解析后,配置需要跨越进程边界传递给 worker 线程。packages/vitest/src/node/config/serializeConfig.ts 中的 serializeConfig() 函数会生成一个 SerializedConfig——一个纯对象,其中剔除了所有函数、类实例,以及任何无法通过 structuredClone() 或 JSON 序列化传递的内容。

flowchart LR
    RC[ResolvedConfig] --> SC["serializeConfig()"]
    SC --> Stripped["SerializedConfig<br>(plain object)"]
    Stripped -- "MessagePort / process.send" --> Worker["Worker Process"]
    
    SC -.- Note["Strips:<br>- Functions<br>- Class instances<br>- Server references<br>- Plugin arrays<br><br>Keeps:<br>- Scalar values<br>- File paths<br>- Pattern strings<br>- Timeout values"]

该函数逐一挑选需要的字段,而非通过泛化的"过滤不可序列化内容"来实现。这是一个刻意的设计决策——它确保每新增一个配置字段,都必须明确判断 worker 是否需要它。coverage 配置被精简为仅保留 reportsDirectoryproviderenabledcustomProviderModule;deps 配置则丢弃了完整的 optimizer 配置,只保留 enabled 标志。

packages/vitest/src/node/config/serializeConfig.ts#L6-L62

这份序列化后的配置就是 worker 内部测试运行器所使用的配置。它是 ResolvedConfig 的子集——足以运行测试,但不足以修改 Vite 服务器或管理 reporter。

下一步

理解了启动流程和配置解析管道之后,我们就可以深入探索测试实际执行时发生了什么。下一篇文章将聚焦于 Vitest 的三层 pool 架构——PoolPoolRunnerPoolWorker——以及 birpc 通信桥接机制、worker 的启动与环境初始化过程,还有测试结果如何经由 StateManagerTestRun 流转至各个 reporter。