Read OSS

Inside @vitest/runner: Test Collection, Execution, and the Hook System

Advanced

Prerequisites

  • Article 1: Architecture and Project Layout
  • Articles 2-3: Boot process and worker execution context
  • Familiarity with test framework concepts (suites, hooks, fixtures, assertions)

Inside @vitest/runner: Test Collection, Execution, and the Hook System

The @vitest/runner package is where test code actually means something. It defines the describe/test/it API that developers write against, collects those declarations into a hierarchical task tree, executes tests with precise hook ordering, manages fixtures, handles concurrency limits, and supports retries. And it does all of this with zero dependencies on Vitest's Node orchestration layer — it's a standalone, framework-agnostic test engine.

As we saw in Part 3, the runBaseTests function inside workers calls startTests() and collectTests() from this package. Now we'll explore what happens inside those functions.

The Suite/Test DSL: Building the Chainable API

The public API starts at packages/runner/src/suite.ts, where suite and test are created:

export const suite: SuiteAPI = createSuite()
export const test: TestAPI = createTest(...)
export const describe: SuiteAPI = suite
export const it: TestAPI = test

The magic is in createChainable() — a utility that wraps an object with a proxy supporting chained modifiers. When you write test.skip.each([1, 2])('adds %i', ...), each property access returns a new proxy that accumulates state:

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: [...] }"]

The supported modifiers are: .only, .skip, .todo, .each(), .for(), .concurrent, .sequential, and .fails. These can be chained in any order. The createTaskCollector function at the heart of this pattern accumulates the modifier state and creates the appropriate task when the function is finally invoked.

When test('name', fn) is called during file import, it doesn't execute anything immediately. Instead, it registers a task on the current suite collector via the collectTask() function from context.ts. This collector-based pattern is what enables the two-phase approach: first collect all declarations, then execute them.

Test Collection: From File Import to Task Tree

The collectTests() function in packages/runner/src/collect.ts#L23-L26 drives the collection phase:

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"]

The key insight is in the collectorContext from packages/runner/src/context.ts#L21-L24:

export const collectorContext: RuntimeContext = {
  tasks: [],
  currentSuite: null,
}

When the runner calls runner.importFile(filepath, 'collect'), the test file executes synchronously (or asynchronously for top-level await). Every describe() call creates a SuiteCollector and pushes it onto collectorContext.tasks. Every test() call within a describe callback registers on that suite's collector. The runWithSuite() function manages the suite stack:

export async function runWithSuite(suite, fn) {
  const prev = collectorContext.currentSuite
  collectorContext.currentSuite = suite
  await fn()
  collectorContext.currentSuite = prev
}

This stack mechanism is how nested describe blocks create a tree. After import, getDefaultSuite().collect(file) processes the accumulated tasks and builds the final File → Suite → Test hierarchy. Each task gets a deterministic hash based on its position and name.

packages/runner/src/collect.ts#L54-L100

Test Execution: startTests() and the Run Loop

The startTests() function triggers the execution phase. It calls collectTests() first (collection is always the first step), then runs the collected task tree. The run loop in packages/runner/src/run.ts traverses the tree depth-first:

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"]

The concurrency model is controlled by config.maxConcurrency and per-suite concurrent/sequential modifiers. Tests marked .concurrent within a suite run in parallel, bounded by a concurrency limiter. The partitionSuiteChildren() utility splits a suite's tasks into sequential and concurrent groups, then executes each group appropriately.

Retries are handled at the test level. When a test fails and has retries configured, the entire test function (plus its beforeEach/afterEach hooks) re-executes. The retry configuration supports both simple counts (retry: 3) and objects with delay and condition matching:

function getRetryCount(retry) {
  if (typeof retry === 'number') return retry
  return retry.count ?? 0
}

Tip: The sequence.hooks config controls hook execution order. The default 'parallel' runs hooks concurrently within each phase. Use 'stack' if your hooks have ordering dependencies — afterAll/afterEach will then run in reverse registration order, matching Jest's behavior.

The Hook System

The hook functions are defined in packages/runner/src/hooks.ts. Each hook registers a listener on the current suite's SuiteHooks object:

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)

Every hook is wrapped with withTimeout() from context.ts, which creates a timer-based guard. If a hook exceeds its timeout, an error is thrown with a stack trace pointing to the registration site, not the execution site — this is why new Error('STACK_TRACE_ERROR') is captured at registration time.

The aroundAll and aroundEach hooks are a powerful addition. They wrap the suite or test execution, receiving a runSuite/runTest function that must be called:

aroundAll(async (runSuite) => {
  await tracer.trace('test-suite', runSuite)
})

aroundEach(async (runTest) => {
  await database.transaction(() => runTest())
})

Multiple aroundAll/aroundEach hooks nest inside each other — the first registered is the outermost wrapper.

Test Fixtures: Playwright-Style Dependency Injection

The fixture system in packages/runner/src/fixture.ts implements Playwright-style dependency injection. Fixtures are declared with test.extend() and injected via destructuring:

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)
  },
})

The TestFixtures class manages fixture registrations, scoping, and lifecycle:

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 dependencies are detected by parsing function parameter names (using filterOutComments() and regex parsing). The system builds a dependency graph, detects cycles, and initializes fixtures in topological order. Scopes control lifecycle — 'test' scope creates fresh fixtures per test, 'file' scope shares across a file, and 'worker' scope persists for the worker's lifetime.

Built-in fixtures are always available: task, signal, onTestFailed, onTestFinished, skip, and annotate.

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

Test Context, Timeout, and Cancellation

Every test function receives a TestContext object defined via packages/runner/src/context.ts. The context provides:

  • task — The current test task object with metadata
  • signal — An AbortSignal that fires on timeout or cancellation
  • expect — The scoped assertion function
  • skip() — Dynamically skip the test
  • onTestFailed() — Register failure handlers
  • onTestFinished() — Register cleanup handlers

Timeout management uses withTimeout(), which wraps any async function with a setTimeout guard. The implementation is nuanced — it also checks performance.now() against the timeout after microtask resolution, catching cases where a long synchronous computation in a microtask delays setTimeout:

function resolve(result) {
  clearTimeout(timer)
  if (now() - startTime >= timeout) {
    rejectTimeoutError()
    return
  }
  resolve_(result)
}

Cancellation propagates through AbortController. When a test run is cancelled (user presses Ctrl+C), the pool sends a cancel message to workers, which sets a cancellation flag on the worker state. The test runner checks this flag between tests and aborts the context signal for the current test.

The VitestRunner Interface and TestRunner Implementation

@vitest/runner defines the VitestRunner interface that frameworks implement. Vitest's own TestRunner in packages/vitest/src/runtime/runners/test.ts#L36-L53 extends this with Vitest-specific concerns:

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() delegates to Vite's ModuleRunner.import(), which triggers the RPC fetch() call back to the Node process for module transformation. The onBeforeRunTask hook sets up scoped expect state and snapshot tracking. onAfterRunTask validates assertion counts and resolves snapshots.

The test environments are configured in packages/vitest/src/integrations/env/index.ts:

export const environments = {
  node,
  jsdom,
  'happy-dom': happy,
  'edge-runtime': edge,
}

Each environment provides setup() and teardown() functions that configure the global scope. The jsdom environment creates a JSDOM instance and maps its globals; happy-dom does the same with Happy DOM; edge-runtime provides a Web API-compatible environment. The node environment is a no-op — it just uses Node's native globals.

Tip: Custom environments can be created by implementing the Environment interface and pointing the environment config option to your module path. This is useful for testing in specialized runtimes like Cloudflare Workers or Deno.

What's Next

We've now seen how tests are defined, collected, and executed inside @vitest/runner. In the final article, we'll explore how results reach the user through the reporter system, how the coverage provider integrates, how the WebSocket API powers the Vitest UI, and how the sub-packages (@vitest/expect, @vitest/spy, @vitest/snapshot) compose to form the full testing toolkit. We'll also cover the extension points that make Vitest customizable.