Read OSS

Reporters, Coverage, and Extending Vitest: The Output and Plugin Layer

Advanced

Prerequisites

  • Articles 1-4: Full understanding of Vitest architecture, boot, execution, and runner
  • Basic familiarity with code coverage concepts
  • Understanding of WebSocket communication (for UI section)

Reporters, Coverage, and Extending Vitest: The Output and Plugin Layer

All the work we've traced through the previous four articles — config resolution, pool management, worker boot, test collection, and execution — ultimately exists to produce output. Whether that's colored terminal output, a JSON file, a JUnit XML report, a coverage summary, or a real-time browser dashboard, the reporter and output systems are where Vitest's internal state becomes visible to developers.

This final article covers the Reporter interface and its lifecycle hooks, the hierarchy of reported task objects, the built-in reporter catalog, the coverage provider system, test sequencing strategies, the WebSocket API powering the Vitest UI, the programmatic Node API, and the sub-package architecture that makes expect, spy, and snapshot independently composable.

The Reporter Interface and Lifecycle Hooks

The Reporter interface in packages/vitest/src/node/types/reporter.ts defines every event a reporter can listen to:

sequenceDiagram
    participant V as Vitest
    participant TR as TestRun
    participant R as Reporter

    V->>R: onInit(vitest)
    
    Note over TR: Test run begins
    TR->>R: onTestRunStart(specifications)
    
    loop For each test module
        TR->>R: onTestModuleQueued(testModule)
        TR->>R: onTestModuleCollected(testModule)
        TR->>R: onTestModuleStart(testModule)
        
        loop For each test
            TR->>R: onTestCaseReady(testCase)
            TR->>R: onTestCaseResult(testCase)
        end
        
        loop For each suite
            TR->>R: onTestSuiteReady(testSuite)
            TR->>R: onTestSuiteResult(testSuite)
        end
        
        TR->>R: onTestModuleEnd(testModule)
    end
    
    TR->>R: onTestRunEnd(testModules, errors, reason)
    
    Note over R: Optional events
    R-->>R: onUserConsoleLog(log)
    R-->>R: onHookStart(hook) / onHookEnd(hook)
    R-->>R: onTestCaseAnnotate(testCase, annotation)
    R-->>R: onCoverage(coverage)

The lifecycle is designed to be granular. A reporter doesn't need to implement every hook — all are optional. The onTestRunStart receives the full list of specifications before any tests execute. onTestModuleQueued fires when a file is sent to a worker but before it's loaded. onTestModuleCollected fires after the file's tests have been discovered. Individual test results arrive via onTestCaseResult.

The TestRunEndReason can be 'passed', 'failed', or 'interrupted' — the last indicating user cancellation or bail-out.

The ReportedTask Hierarchy

Reporters don't work with raw runner tasks — they receive rich wrapper objects. The hierarchy in packages/vitest/src/node/reporters/reported-tasks.ts provides a clean API:

classDiagram
    class ReportedTaskImplementation {
        +task: RunnerTask
        +project: TestProject
        +id: string
        +location: LocationInfo
        +ok(): boolean
        +meta(): TaskMeta
    }

    class TestModule {
        +type: "module"
        +moduleId: string
        +children: TestCollection
        +state(): TestModuleState
        +diagnostic(): ModuleDiagnostic
    }

    class TestSuite {
        +type: "suite"
        +name: string
        +parent: TestSuite | TestModule
        +children: TestCollection
        +state(): TestSuiteState
    }

    class TestCase {
        +type: "test"
        +name: string
        +fullName(): string
        +parent: TestSuite | TestModule
        +result(): TestResult
        +diagnostic(): TestDiagnostic
        +annotations(): TestAnnotation[]
    }

    ReportedTaskImplementation <|-- TestModule
    ReportedTaskImplementation <|-- TestSuite
    ReportedTaskImplementation <|-- TestCase

TestModule represents a test file (what @vitest/runner calls a File). TestSuite wraps describe blocks. TestCase wraps individual tests. Each provides typed accessors for results, diagnostics (duration, retries, heap usage), and navigation (parent, children).

The TestCollection on modules and suites is iterable, supporting for...of loops over children. The state() method returns the computed state: 'pending', 'queued', 'running', 'passed', 'failed', or 'skipped'.

Built-in Reporters

Vitest ships with 12 reporters, registered in packages/vitest/src/node/reporters/index.ts#L49-L62:

export const ReportersMap = {
  'default': DefaultReporter,
  'agent': AgentReporter,
  'blob': BlobReporter,
  'verbose': VerboseReporter,
  'dot': DotReporter,
  'json': JsonReporter,
  'tap': TapReporter,
  'tap-flat': TapFlatReporter,
  'junit': JUnitReporter,
  'tree': TreeReporter,
  'hanging-process': HangingProcessReporter,
  'github-actions': GithubActionsReporter,
}
Reporter Output Use Case
default Colored terminal with progress Interactive development
verbose Every test listed CI with detailed output
dot One dot per test Minimal CI output
tree Tree-structured output Visual hierarchy
json JSON file Machine consumption
junit JUnit XML CI system integration
tap / tap-flat TAP protocol TAP consumers
github-actions GitHub annotations PR integration
blob Binary blob Sharding/merge workflows
agent Structured for AI agents Automated tooling
hanging-process Process diagnostics Debugging stuck tests

Most reporters extend BaseReporter from packages/vitest/src/node/reporters/base.ts#L43-L65, which provides common functionality: TTY detection, silent mode, error formatting, summary rendering, and the banner display.

Tip: You can use multiple reporters simultaneously: --reporter=default --reporter=json --outputFile=results.json. The default reporter writes to stdout while json writes to a file. Custom reporters can be specified as module paths: --reporter=./my-reporter.ts.

Coverage Provider System

Coverage in Vitest is pluggable. The provider system in packages/vitest/src/node/coverage.ts loads provider modules on demand:

flowchart TD
    Config["coverage.provider: 'v8' | 'istanbul' | 'custom'"] --> Resolve["resolveCoverageProviderModule()"]
    Resolve --> V8["@vitest/coverage-v8"]
    Resolve --> Istanbul["@vitest/coverage-istanbul"]
    Resolve --> Custom["Custom module"]
    
    V8 & Istanbul & Custom --> Provider["CoverageProvider interface"]
    Provider --> Init["initialize(ctx)"]
    
    subgraph "Test Execution"
        Init --> StartWorker["startCoverageInsideWorker()"]
        StartWorker --> Tests["Tests run"]
        Tests --> StopWorker["stopCoverageInsideWorker()"]
    end
    
    StopWorker --> Report["generateCoverage()"]
    Report --> Transform["CoverageTransform plugin<br>(source map remapping)"]
    Transform --> Output["Coverage reports<br>(text, html, json, clover)"]

The BaseCoverageProvider class provides shared functionality: threshold checking, file globbing, report generation. The V8 provider uses V8's built-in coverage, which is fast but sometimes less accurate for source-mapped code. Istanbul instruments code at the source level, providing more reliable coverage at the cost of transform overhead.

The CoverageTransform Vite plugin handles source map remapping — ensuring coverage maps point to original source positions, not transformed output. Coverage data is collected per-worker (via the startCoverageInsideWorker/stopCoverageInsideWorker functions called in runBaseTests) and merged on the Node side.

Test Sequencers

Test ordering is controlled by sequencers. The BaseSequencer in packages/vitest/src/node/sequencers/BaseSequencer.ts implements a smart default ordering:

flowchart TD
    Sort["BaseSequencer.sort(files)"] --> GroupOrder["1. sequence.groupOrder"]
    GroupOrder --> ProjectName["2. Project name (alphabetical)"]
    ProjectName --> Isolation["3. Isolated files first"]
    Isolation --> Cache{Has cached results?}
    Cache -- "no" --> Size["Sort by file size (larger first)"]
    Cache -- "yes" --> Failed{Previously failed?}
    Failed -- "yes" --> First["Run first"]
    Failed -- "no" --> Duration["Sort by duration (longer first)"]

The ordering prioritizes failed tests first (fast feedback), then longer tests (optimizes total wall time by starting expensive tests early). The shard() method uses SHA-1 hashing for deterministic distribution across shards.

Custom sequencers can extend BaseSequencer and override sort() — for example, to prioritize tests related to changed files or to implement custom grouping strategies.

WebSocket API and the Vitest UI

The Vitest UI communicates with the test runner through a WebSocket server established in packages/vitest/src/api/setup.ts:

sequenceDiagram
    participant UI as Vitest UI (Browser)
    participant WS as WebSocket Server
    participant V as Vitest Process

    UI->>WS: Connect to /__vitest_api__
    WS->>V: createBirpc(handlers, events)
    
    Note over UI,V: Bidirectional RPC established
    
    UI->>V: getFiles()
    V-->>UI: File list with test results
    
    UI->>V: rerun(files)
    V->>V: Schedule test run
    
    V->>UI: onTaskUpdate(packs, events)
    V->>UI: onFinished(files, errors)
    V->>UI: onUserConsoleLog(log)
    
    UI->>V: getModuleGraph(id)
    V-->>UI: Module dependency graph

The server upgrades HTTP connections on the /__vitest_api__ path to WebSocket. It validates requests against the API config, then establishes a birpc channel. The WebSocketHandlers interface exposes methods like getFiles(), getTransformResult(), getModuleGraph(), and rerun(). Events flow back through the WebSocketEvents interface, which mirrors the Reporter lifecycle.

The @vitest/ws-client package provides the browser-side client, and @vitest/ui is a Vue application that consumes this API to render the dashboard with real-time test results, module graphs, and error displays.

The Programmatic Node API

The public Node API exported from packages/vitest/src/public/node.ts is designed for IDE extensions, custom build tools, and programmatic test execution:

// Core functions
export { startVitest } from '../node/cli/cli-api'
export { createVitest } from '../node/create'
export { VitestPlugin } from '../node/plugins'

// Reporter infrastructure
export { ReportersMap, DefaultReporter, ... } from '../node/reporters'

// Pool workers (for custom pools)
export { ThreadsPoolWorker, ForksPoolWorker, ... } from '../node/pools/workers/...'

// Sequencer
export { BaseSequencer } from '../node/sequencers/BaseSequencer'

// Types
export type { Vitest, Reporter, TestProject, TestSpecification, ... }

The distinction between createVitest and startVitest is important: createVitest creates and initializes the Vitest instance but doesn't run tests. startVitest creates the instance and runs tests. For IDE integrations, you typically want createVitest so you can control when and which tests execute.

The API also re-exports Vite utilities: createViteServer, parseAst, and version information — making it unnecessary to depend on Vite separately when building Vitest tooling.

Tip: For VS Code extension development, use createVitest with the reporters option to inject a custom reporter that maps test results to VS Code's Test API. The TestSpecification class lets you run individual files or even specific tests by line number.

Sub-Package Architecture: expect, spy, snapshot

The assertion, mocking, and snapshot systems are implemented as independent packages that compose into Vitest's test API.

@vitest/expect (packages/expect/src/index.ts) builds on Chai, adding Jest-compatible matchers:

graph TD
    subgraph "@vitest/expect"
        Chai["chai (base)"]
        JCE["JestChaiExpect<br>(toBe, toEqual, toThrow...)"]
        JAM["JestAsymmetricMatchers<br>(any, anything, objectContaining...)"]
        JE["JestExtend<br>(expect.extend())"]
        CM["customMatchers registry"]
        
        Chai --> JCE
        Chai --> JAM
        JCE --> JE
        JE --> CM
    end

    subgraph "@vitest/spy"
        Spy["tinyspy (base)"]
        MockFn["createMockInstance()"]
        MockRestore["Mock lifecycle<br>(clear, reset, restore)"]
        
        Spy --> MockFn
        MockFn --> MockRestore
    end

    subgraph "@vitest/snapshot"
        Client["SnapshotClient"]
        State["SnapshotState"]
        Plugins["Serializer plugins"]
        
        Client --> State
        State --> Plugins
    end

The JestChaiExpect plugin adds all the familiar matchers (toBe, toEqual, toHaveBeenCalled, toMatchSnapshot). JestExtend enables expect.extend() for custom matchers. The GLOBAL_EXPECT constant is used to store the current expect instance in a global scope, enabling the scoped expect that TestRunner configures per-test.

@vitest/spy (packages/spy/src/index.ts) provides mock functions built on tinyspy. The createMockInstance() function returns a Mock object with a full API: mockImplementation(), mockReturnValue(), mockResolvedValue(), etc. A global MOCK_RESTORE set tracks all mocks for bulk restoration with vi.restoreAllMocks().

@vitest/snapshot (packages/snapshot/src/index.ts) handles both file-based and inline snapshots. SnapshotClient manages the assertion flow, SnapshotState tracks snapshot data per file, and custom serializers can be added via addSerializer(). Snapshot environments are pluggable — the default writes .snap files to disk, but custom environments can store snapshots differently (in a database, for example).

These packages are consumed by the vitest integration layer — src/integrations/chai/ wires up expect, src/integrations/spy.ts configures the spy module, and src/integrations/snapshot/ connects snapshot assertions to the runner lifecycle.

Wrapping Up the Series

Over five articles, we've traced Vitest's complete architecture:

  1. Architecture — The 17-package monorepo with its clean separation between Node orchestration and worker runtime
  2. Boot sequence — From binary entry through CLI parsing, config discovery, Vite server creation, and plugin hooks
  3. Pool system — The three-layer Pool/PoolRunner/PoolWorker design with birpc communication
  4. Runner — The framework-agnostic test DSL, collection, execution engine, hook system, and fixtures
  5. Output layer — Reporters, coverage, sequencers, the WebSocket UI API, and composable sub-packages

The design philosophy that emerges is one of layered composition. @vitest/runner knows nothing about Vite. @vitest/expect knows nothing about test execution. The vitest package composes all these pieces, using Vite's dev server as the transformation backbone and birpc as the communication bridge. This architecture makes the system testable, extensible, and — once you understand the layers — surprisingly navigable for a framework of this scope.