@vitest/runner 内部解析:测试收集、执行与 Hook 系统
前置知识
- ›第 1 篇:架构与项目结构
- ›第 2-3 篇:启动流程与 worker 执行上下文
- ›熟悉测试框架基本概念(suite、hook、fixture、断言)
@vitest/runner 内部解析:测试收集、执行与 Hook 系统
@vitest/runner 是测试代码真正被赋予意义的地方。它定义了开发者熟悉的 describe/test/it API,将这些声明收集成层级化的任务树,按照严格的 hook 顺序执行测试,管理 fixture、控制并发限制,并支持重试机制。更重要的是,这一切都与 Vitest 的 Node 编排层完全解耦——它是一个独立的、与框架无关的测试引擎。
在第 3 篇中我们看到,worker 内部的 runBaseTests 函数会调用该包提供的 startTests() 和 collectTests()。本篇将深入探讨这两个函数的内部逻辑。
Suite/Test DSL:构建可链式调用的 API
公开 API 的入口是 packages/runner/src/suite.ts,suite 和 test 在这里被创建:
export const suite: SuiteAPI = createSuite()
export const test: TestAPI = createTest(...)
export const describe: SuiteAPI = suite
export const it: TestAPI = test
核心在于 createChainable()——这个工具函数将对象包裹在一个 Proxy 中,从而支持链式修饰符。当你写下 test.skip.each([1, 2])('adds %i', ...) 时,每次属性访问都会返回一个新的 Proxy,并在其中累积状态:
flowchart LR
test["test"] -- ".skip" --> skip["{ skip: true }"]
skip -- ".each([1,2])" --> each["{ skip: true, each: [1,2] }"]
each -- "('name', fn)" --> Call["createTest with accumulated context"]
test -- ".only" --> only["{ only: true }"]
test -- ".concurrent" --> conc["{ concurrent: true }"]
test -- ".todo" --> todo["{ todo: true }"]
test -- ".for([...])" --> for_["{ for: [...] }"]
支持的修饰符包括:.only、.skip、.todo、.each()、.for()、.concurrent、.sequential 和 .fails,可以任意顺序链式组合。createTaskCollector 函数负责在这一模式的核心处累积修饰符状态,并在函数最终被调用时创建对应的任务。
文件导入时调用 test('name', fn),并不会立即执行任何内容,而是通过 context.ts 中的 collectTask() 函数将任务注册到当前 suite 的收集器上。这种基于收集器的模式,正是实现「先收集、后执行」两阶段方案的关键。
测试收集:从文件导入到任务树
packages/runner/src/collect.ts#L23-L26 中的 collectTests() 函数驱动整个收集阶段:
flowchart TD
CT["collectTests(specs, runner)"] --> ForEach["For each file spec"]
ForEach --> CreateFile["createFileTask(filepath, root, name)"]
CreateFile --> Clear["clearCollectorContext(file, runner)"]
Clear --> Setup["Run setup files"]
Setup --> Import["runner.importFile(filepath, 'collect')"]
Import --> Collect["getDefaultSuite().collect(file)"]
Collect --> Tasks["Process tasks:<br>tests → file.tasks<br>suites → collect recursively<br>collectors → await collect"]
Tasks --> Hash["calculateSuiteHash(file)"]
Hash --> Filter["Apply filters:<br>- testLocations<br>- testNamePattern<br>- testIds<br>- tags"]
Filter --> Modes["interpretTaskModes:<br>.only handling<br>.skip propagation"]
关键在于 packages/runner/src/context.ts#L21-L24 中的 collectorContext:
export const collectorContext: RuntimeContext = {
tasks: [],
currentSuite: null,
}
当 runner 调用 runner.importFile(filepath, 'collect') 时,测试文件会同步执行(如果有顶层 await 则异步执行)。每次调用 describe() 都会创建一个 SuiteCollector 并将其推入 collectorContext.tasks。describe 回调内部的每次 test() 调用都会注册到该 suite 的收集器上。runWithSuite() 函数负责管理 suite 栈:
export async function runWithSuite(suite, fn) {
const prev = collectorContext.currentSuite
collectorContext.currentSuite = suite
await fn()
collectorContext.currentSuite = prev
}
正是这种栈机制,让嵌套的 describe 块得以构建出树形结构。导入完成后,getDefaultSuite().collect(file) 处理累积的任务,并最终构建出 File → Suite → Test 的层级结构。每个任务都会根据其位置和名称获得一个确定性的哈希值。
packages/runner/src/collect.ts#L54-L100
测试执行:startTests() 与运行循环
startTests() 函数触发执行阶段。它首先调用 collectTests()(收集始终是第一步),然后运行收集好的任务树。packages/runner/src/run.ts 中的运行循环以深度优先方式遍历任务树:
flowchart TD
ST["startTests(files, runner)"] --> ForFile["For each file"]
ForFile --> RunFile["runSuite(file)"]
RunFile --> BA["Run beforeAll hooks"]
BA --> ForTask["For each task in suite"]
ForTask --> IsTest{Task type?}
IsTest -- "test" --> RunTest["runTest(test)"]
IsTest -- "suite" --> RunSuite["runSuite(suite) (recursive)"]
RunTest --> BE["Run beforeEach hooks (all ancestor suites)"]
BE --> AE_around["Run aroundEach wrappers"]
AE_around --> Fn["Execute test function"]
Fn --> AEH["Run afterEach hooks (reversed)"]
AEH --> Retry{Failed + retries left?}
Retry -- "yes" --> RunTest
Retry -- "no" --> ForTask
ForTask -- "done" --> AA["Run afterAll hooks"]
并发模型由 config.maxConcurrency 以及每个 suite 上的 concurrent/sequential 修饰符共同控制。标记了 .concurrent 的测试会在同一 suite 内并行执行,并受并发限制器约束。partitionSuiteChildren() 工具函数将 suite 的任务拆分为顺序组和并发组,再分别按对应方式执行。
重试逻辑在测试级别处理。当测试失败且配置了重试次数时,整个测试函数(包括其 beforeEach/afterEach hook)会重新执行。重试配置支持简单的次数形式(retry: 3)和包含延迟与条件匹配的对象形式:
function getRetryCount(retry) {
if (typeof retry === 'number') return retry
return retry.count ?? 0
}
提示:
sequence.hooks配置项控制 hook 的执行顺序。默认值'parallel'会在每个阶段内并发执行 hook。如果你的 hook 之间存在顺序依赖,可以改用'stack'——此时afterAll/afterEach会按注册顺序的逆序执行,与 Jest 的行为保持一致。
Hook 系统
Hook 函数定义在 packages/runner/src/hooks.ts 中。每个 hook 都会在当前 suite 的 SuiteHooks 对象上注册一个监听器:
sequenceDiagram
participant File as Test File
participant Suite as Current Suite
participant Runner as Run Loop
File->>Suite: beforeAll(fn, timeout)
File->>Suite: beforeEach(fn)
File->>Suite: afterEach(fn)
File->>Suite: afterAll(fn)
File->>Suite: aroundAll(fn)
File->>Suite: aroundEach(fn)
Note over Runner: Execution phase
Runner->>Runner: aroundAll wraps entire suite
Runner->>Runner: beforeAll hooks (in order)
loop Each test
Runner->>Runner: aroundEach wraps test
Runner->>Runner: beforeEach hooks (parent → child order)
Runner->>Runner: Test function
Runner->>Runner: afterEach hooks (child → parent, reversed)
end
Runner->>Runner: afterAll hooks (reversed order)
每个 hook 都会被 context.ts 中的 withTimeout() 包裹,后者通过定时器提供超时保护。一旦 hook 超时,就会抛出一个错误——错误的调用栈指向的是 hook 的注册位置,而非执行位置。这正是为什么要在注册时就捕获 new Error('STACK_TRACE_ERROR')。
aroundAll 和 aroundEach 是一对强大的 hook。它们分别包裹 suite 或测试的整个执行过程,并接收一个必须被调用的 runSuite/runTest 函数:
aroundAll(async (runSuite) => {
await tracer.trace('test-suite', runSuite)
})
aroundEach(async (runTest) => {
await database.transaction(() => runTest())
})
多个 aroundAll/aroundEach hook 会相互嵌套——最先注册的处于最外层。
测试 Fixture:Playwright 风格的依赖注入
packages/runner/src/fixture.ts 中的 fixture 系统实现了 Playwright 风格的依赖注入。fixture 通过 test.extend() 声明,并通过解构注入到测试中:
const myTest = test.extend({
db: async ({}, use) => {
const connection = await connect()
await use(connection) // test runs here
await connection.close() // teardown
},
page: async ({ db }, use) => {
const page = await createPage(db)
await use(page)
},
})
TestFixtures 类负责管理 fixture 的注册、作用域和生命周期:
flowchart TD
Extend["test.extend({ db, page })"] --> Parse["parseUserFixtures()"]
Parse --> Analyze["Analyze function parameters<br>to detect dependencies"]
Analyze --> Register["Register in FixtureRegistrations map"]
Run["Test execution"] --> Resolve["Resolve fixture dependencies"]
Resolve --> Topo["Topological sort<br>(detect cycles)"]
Topo --> Init["Lazy initialization:<br>call fixture fn, await use()"]
Init --> Test["Run test with injected values"]
Test --> Cleanup["Cleanup in reverse order"]
fixture 依赖关系通过解析函数参数名来检测(使用 filterOutComments() 和正则表达式)。系统会构建依赖图、检测循环依赖,并按拓扑顺序初始化 fixture。作用域决定了生命周期——'test' 作用域为每个测试创建新的 fixture 实例,'file' 作用域在同一文件内共享,'worker' 作用域则在 worker 的整个生命周期内持久存在。
系统内置了以下 fixture,始终可用:task、signal、onTestFailed、onTestFinished、skip 和 annotate。
packages/runner/src/fixture.ts#L28-L38
测试上下文、超时与取消
每个测试函数都会接收一个 TestContext 对象,其定义位于 packages/runner/src/context.ts。上下文提供以下能力:
task— 当前测试任务对象及其元数据signal— 超时或取消时触发的AbortSignalexpect— 作用域化的断言函数skip()— 动态跳过当前测试onTestFailed()— 注册失败处理器onTestFinished()— 注册清理处理器
超时管理通过 withTimeout() 实现,它为任意异步函数包裹一个 setTimeout 守卫。实现细节颇为精妙——它还会在微任务解析后用 performance.now() 与超时时间比对,从而捕获微任务中长时间同步计算导致 setTimeout 被推迟触发的情形:
function resolve(result) {
clearTimeout(timer)
if (now() - startTime >= timeout) {
rejectTimeoutError()
return
}
resolve_(result)
}
取消操作通过 AbortController 传播。当测试运行被取消(用户按下 Ctrl+C)时,线程池向 worker 发送 cancel 消息,worker 随即在其状态上设置取消标志。测试 runner 会在每次测试之间检查该标志,并中止当前测试的上下文信号。
VitestRunner 接口与 TestRunner 实现
@vitest/runner 定义了供各框架实现的 VitestRunner 接口。Vitest 自身的 TestRunner 位于 packages/vitest/src/runtime/runners/test.ts#L36-L53,在此基础上扩展了 Vitest 特有的功能:
classDiagram
class VitestRunner {
<<interface>>
+config: VitestRunnerConfig
+pool: string
+importFile(filepath, source): unknown
+onCollectStart?(file): void
+onBeforeRunFiles?(): void
+onAfterRunFiles?(): void
+onBeforeRunSuite?(suite): void
+onAfterRunSuite?(suite): void
+onBeforeRunTask?(test): void
+onAfterRunTask?(test): void
+cancel?(reason): void
}
class TestRunner {
-snapshotClient: SnapshotClient
-workerState: WorkerGlobalState
-moduleRunner: ModuleRunner
+pool: string
+viteEnvironment: string
+importFile(filepath, source)
+onCollectStart(file)
+onBeforeRunTask(test)
+onAfterRunTask(test)
+cancel(reason)
}
VitestRunner <|-- TestRunner
TestRunner.importFile() 将调用委托给 Vite 的 ModuleRunner.import(),后者会通过 RPC fetch() 回调 Node 进程完成模块转换。onBeforeRunTask hook 负责初始化作用域化的 expect 状态和快照追踪;onAfterRunTask 则验证断言计数并解析快照。
测试环境的配置位于 packages/vitest/src/integrations/env/index.ts:
export const environments = {
node,
jsdom,
'happy-dom': happy,
'edge-runtime': edge,
}
每个环境都提供 setup() 和 teardown() 函数来配置全局作用域。jsdom 环境会创建一个 JSDOM 实例并映射其全局变量;happy-dom 对 Happy DOM 做同样的处理;edge-runtime 提供兼容 Web API 的运行环境;而 node 环境则是一个空操作——直接使用 Node 的原生全局变量。
提示: 你可以通过实现
Environment接口并将environment配置项指向你的模块路径来创建自定义环境。这对于在 Cloudflare Workers 或 Deno 等特定运行时中进行测试非常有用。
下一篇
至此,我们已经完整了解了 @vitest/runner 内部测试的定义、收集与执行机制。最后一篇将探讨测试结果如何通过 reporter 系统呈现给用户、coverage provider 如何集成、WebSocket API 如何驱动 Vitest UI,以及 @vitest/expect、@vitest/spy、@vitest/snapshot 等子包如何共同构成完整的测试工具链。我们还会介绍让 Vitest 具备高度可扩展性的各个扩展点。