从 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.ts。createCLI() 函数创建一个 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 模式:若 isCI 为 true 或 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.ts、vitest.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.ts、bar.spec.mjs、baz.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 配置被精简为仅保留 reportsDirectory、provider、enabled 和 customProviderModule;deps 配置则丢弃了完整的 optimizer 配置,只保留 enabled 标志。
packages/vitest/src/node/config/serializeConfig.ts#L6-L62
这份序列化后的配置就是 worker 内部测试运行器所使用的配置。它是 ResolvedConfig 的子集——足以运行测试,但不足以修改 Vite 服务器或管理 reporter。
下一步
理解了启动流程和配置解析管道之后,我们就可以深入探索测试实际执行时发生了什么。下一篇文章将聚焦于 Vitest 的三层 pool 架构——Pool、PoolRunner 和 PoolWorker——以及 birpc 通信桥接机制、worker 的启动与环境初始化过程,还有测试结果如何经由 StateManager 和 TestRun 流转至各个 reporter。