Read OSS

@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.tssuitetest 在这里被创建:

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.tasksdescribe 回调内部的每次 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')

aroundAllaroundEach 是一对强大的 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,始终可用:tasksignalonTestFailedonTestFinishedskipannotate

packages/runner/src/fixture.ts#L28-L38

测试上下文、超时与取消

每个测试函数都会接收一个 TestContext 对象,其定义位于 packages/runner/src/context.ts。上下文提供以下能力:

  • task — 当前测试任务对象及其元数据
  • signal — 超时或取消时触发的 AbortSignal
  • expect — 作用域化的断言函数
  • 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 具备高度可扩展性的各个扩展点。