Vitest Architecture

How Vitest v4 works internally, based on reading the source code at vitest-dev/vitest.

Package Map

Vitest is a pnpm monorepo. Each package has a focused responsibility:

Packagenpm namePurpose
vitestvitestThe main package. Contains the CLI, Node-side orchestration (src/node/), runtime worker code (src/runtime/), and all integrations. This is what you install.
runner@vitest/runnerThe actual test execution engine: describe, it, beforeAll, hooks, retries, concurrent tests. Framework-agnostic - has no Vite dependency.
expect@vitest/expectJest-compatible expect() matchers implemented as a Chai plugin. Asymmetric matchers, toEqual, toMatchSnapshot, etc.
spy@vitest/spyLightweight Jest-compatible spy/mock functions (vi.fn(), vi.spyOn()). Standalone - no Vitest dependency.
snapshot@vitest/snapshotSnapshot testing: SnapshotClient (per-worker), SnapshotManager (main thread summary), inline snapshot support.
mocker@vitest/mockerModule mocking (vi.mock, vi.unmock). The hoistMocksPlugin Vite plugin that hoists vi.mock() calls to the top of files. Also handles automocking.
coverage-v8@vitest/coverage-v8Coverage via V8’s built-in profiler. Collects raw V8 coverage in workers, converts to Istanbul format on main thread, generates reports.
coverage-istanbul@vitest/coverage-istanbulCoverage via Istanbul instrumentation (Babel-based code transform).
browser@vitest/browserBrowser-mode test runner. Runs tests in a real browser instead of Node.
browser-playwright@vitest/browser-playwrightPlaywright adapter for browser mode.
browser-webdriverio@vitest/browser-webdriverioWebDriverIO adapter for browser mode.
browser-preview@vitest/browser-previewPreview/iframe harness for browser test execution.
ui@vitest/uiWeb dashboard (--ui flag). A Vite-powered SPA that connects via WebSocket.
ws-client@vitest/ws-clientWebSocket client for communicating with a running Vitest server (used by UI and IDE extensions).
utils@vitest/utilsShared utilities: diff, serialization, source-map parsing, error formatting.
pretty-format@vitest/pretty-formatFork of Jest’s pretty-format with ESM support. Used for snapshot serialization.
web-worker@vitest/web-workerSimulates Web Workers and SharedWorkers in Node environments (jsdom/happy-dom).

How Vitest Leverages Vite

This is the core architectural insight: Vitest does not have its own module bundler or transformer. It IS a Vite plugin.

Bootstrap: Vitest creates a Vite dev server

When you run vitest, the createVitest() function in src/node/create.ts:

  1. Creates a Vitest instance
  2. Calls VitestPlugin() which returns an array of Vite plugins
  3. Calls Vite’s createServer() with those plugins injected
  4. The Vite server’s configureServer hook calls vitest._setServer(), which wires up the entire test infrastructure

The Vite server is created in middleware mode (no HTTP serving for tests) with hmr: false and preTransformRequests: false.

What Vitest gets for free from Vite

Transform pipeline. When a test file (or any dependency) needs to be loaded in a worker, the worker’s module runner calls back to the main thread via RPC to fetch the module. The main thread calls fetchModule() from Vite, which runs the file through Vite’s full plugin pipeline: TypeScript/JSX compilation (esbuild or oxc), path alias resolution, CSS modules, JSON imports, etc. This is the same pipeline your app uses in development.

Module resolution. Vite’s resolveId handles tsconfig paths, package.json exports, and the resolve.alias config. Tests resolve imports the same way your dev server does.

Config merging. Vitest reads vite.config.ts (or vitest.config.ts) directly. Your define, resolve.alias, and plugin settings all apply to tests automatically.

File watching. In watch mode, Vitest piggybacks on Vite’s chokidar watcher (server.watcher). When a source file changes, Vitest uses Vite’s module graph to find which test files import it, then reruns only those tests.

Why this makes Vitest fast

Jest has to maintain its own transform cache, resolve modules with its own resolver, and run Babel transforms. Vitest reuses Vite’s already-warm transform cache. If you ran vite dev recently, the transforms are cached. Even without a warm cache, Vite’s esbuild/oxc-based transforms are much faster than Babel.

The Vite plugins Vitest registers

The VitestPlugin() function (src/node/plugins/index.ts) returns these plugins:

  • vitest (enforce: pre) - The main plugin. Modifies Vite config for testing (disables HMR, sets process.env.NODE_ENV, handles CSS modules strategy). Its configureServer hook bootstraps the test infrastructure.
  • MetaEnvReplacerPlugin - Replaces import.meta.env with process.env so values are reassignable at runtime.
  • CSSEnablerPlugin - Controls CSS processing based on css config (can disable CSS to speed up tests).
  • CoverageTransform - Hooks into Vite’s transform to inject coverage instrumentation.
  • VitestCoreResolver - Custom resolution for Vitest’s own internal modules.
  • MocksPlugins (from @vitest/mocker) - hoistMocksPlugin rewrites vi.mock() calls to the top of files, and automockPlugin handles __mocks__ directories.
  • VitestOptimizer - Configures cache directory paths.
  • NormalizeURLPlugin - Normalizes module URLs for consistency.
  • ModuleRunnerTransform - Transforms for the Vite module runner.

Test Execution Flow

CLI (vitest)
  |
  v
createVitest()
  |-- Creates Vite dev server with VitestPlugin
  |-- VitestPlugin.configureServer() calls vitest._setServer()
  |     |-- Resolves config, creates StateManager, SnapshotManager
  |     |-- Creates ServerModuleRunner (Vite's ModuleRunner)
  |     |-- Resolves workspace projects (TestProject instances)
  |     |-- Sets up file watcher (watch mode)
  |
  v
vitest.start(filters)
  |
  |-- 1. SPEC RESOLUTION
  |     VitestSpecifications.getRelevantTestSpecifications()
  |       |-- Each TestProject globs for test files (include/exclude patterns)
  |       |-- Creates TestSpecification per file+project pair
  |       |-- Filters by --changed, --related, source maps
  |
  |-- 2. SEQUENCING
  |     Sequencer.sort(specs)  +  Sequencer.shard(specs)
  |       |-- BaseSequencer sorts by: failed-first, then duration (from cache)
  |       |-- RandomSequencer shuffles with a seed
  |       |-- Shard divides specs across CI shards
  |
  |-- 3. POOL CREATION
  |     createPool(vitest)
  |       |-- Groups specs by: environment, project, pool type, groupOrder
  |       |-- Resolves maxWorkers (CPU count - 1 for run, CPU/2 for watch)
  |       |-- Creates Pool instance with task queue
  |
  |-- 4. WORKER DISPATCH
  |     Pool.run(task) for each group
  |       |-- Creates PoolRunner + PoolWorker (threads/forks/vmThreads/vmForks)
  |       |-- Worker: new Worker(entrypoint) or fork(entrypoint)
  |       |-- Sends 'start' message with config, environment, pool ID
  |       |-- Sends 'run' message with file list
  |
  |-- 5. INSIDE THE WORKER  (see Worker Architecture below)
  |       |-- Sets up environment (jsdom/happy-dom/node)
  |       |-- Creates module runner (Vite ModuleRunner or native)
  |       |-- For each test file:
  |       |     |-- Imports file via module runner (RPC -> Vite transform)
  |       |     |-- @vitest/runner.startTests() executes describe/it/hooks
  |       |     |-- Results sent back via RPC (TaskResultPack events)
  |       |-- Sends 'testfileFinished' response
  |
  |-- 6. RESULT AGGREGATION
  |     TestRun receives events via RPC
  |       |-- StateManager updates test state
  |       |-- Reporters receive onTestModuleCollected, onTestCaseResult, etc.
  |       |-- SnapshotManager aggregates snapshot results
  |       |-- Coverage provider generates report
  |
  v
Results + Exit code

Watch Mode

In watch mode, the flow loops:

  1. Vite’s chokidar watcher detects file changes
  2. VitestWatcher.onFileChange() fires
  3. Vitest walks the Vite module graph to find affected test files
  4. vitest.scheduleRerun() debounces (100ms) and reruns only affected specs
  5. The pool is reused between runs (workers may be recycled if isolate: false)
  6. Config file changes trigger a full restart (server.restart())

Test Scheduling

The groupSpecs() function in pool.ts organizes test files into execution groups:

  • Sequential group: Files with maxWorkers: 1 and default groupOrder run one at a time
  • Parallel groups: Files are dispatched to maxWorkers concurrent workers
  • Typecheck group: Type-only tests run in a separate group via the TypeScript compiler
  • Group ordering: sequence.groupOrder controls which groups run first (lower = earlier). Next group waits for the previous to finish.
  • Non-isolated optimization: When isolate: false and maxWorkers: 1, multiple files can be batched into a single worker to avoid setup overhead

Worker Architecture

Tests run in isolated workers. This is critical for test reliability - each test file gets a clean module state.

Pool Types

PoolMechanismIsolationUse Case
threads (default)worker_threads.WorkerModule-level (new ModuleRunner per file)Best default. Shared memory possible.
forkschild_process.fork()Process-levelWhen you need full process isolation. Uses serialization: 'advanced' (structured clone).
vmThreadsWorker thread + node:vmVM context per fileStrongest isolation. Each file runs in its own VM context. Needs --experimental-vm-modules.
vmForksFork + node:vmProcess + VM contextMaximum isolation.
typescriptTypeScript compiler APIN/AType checking only, no runtime execution.
browserReal browser via Playwright/WebDriverIOBrowser tabFor testing real DOM/browser APIs.

Worker Lifecycle

Main Thread                              Worker Thread/Process
-----------                              ----------------------

Pool.schedule()
  |
  +-- PoolRunner.start()  ------>  Worker entrypoint boots
       sends {type:'start',              |
              config, env,               Calls worker.setup()
              poolId}                    Sets up environment (jsdom, etc.)
                                         Creates module runner
       <------ {type:'started'} -------+
  |
  +-- PoolRunner.request('run')          |
       sends {type:'run',          For each file:
              context: {files,       |-- moduleRunner.import(filepath)
              invalidates,           |     Calls RPC fetch -> Vite transform
              environment}}          |-- @vitest/runner.startTests()
                                     |     Runs describe/it/hooks
                               <---- |-- RPC: onCollected, onTaskUpdate
                                     |-- RPC: onUserConsoleLog
                                     +
                               <---- {type:'testfileFinished',
                                      usedMemory}
  |
  +-- PoolRunner.stop()  ------>  Worker teardown
       sends {type:'stop'}           env.teardown()
       <------ {type:'stopped'} ----+
       Worker terminated

Module Loading in Workers

When a worker needs to import a test file or its dependencies:

  1. The worker’s VitestModuleRunner (extends Vite’s ModuleRunner) requests the module
  2. The request goes via birpc RPC to the main thread (createMethodsRPC in rpc.ts)
  3. Main thread calls project._fetcher(), which calls Vite’s fetchModule()
  4. Vite runs the file through its transform pipeline (TypeScript, JSX, plugins, etc.)
  5. The transformed code is sent back to the worker
  6. The worker’s VitestModuleEvaluator executes the code in the appropriate context

This is why Vite’s transform pipeline is shared: workers don’t run their own transforms. They ask the main-thread Vite server, which caches results.

Non-isolated Mode

When isolate: false, the worker reuses its module cache between test files. The Pool class tracks “shared runners” - workers that can be reused if the next task has the same pool type, project, and environment. This is much faster but means test files can leak state to each other.

Communication: birpc

Workers communicate with the main thread via birpc (bidirectional RPC). The main thread exposes RuntimeRPC methods (fetch modules, resolve IDs, report results). Workers call these methods transparently as if they were local function calls.

Key RPC methods workers call:

  • fetch(url, importer, env) - Get transformed module code from Vite
  • resolve(id, importer, env) - Resolve a module ID
  • onCollected(files) - Report collected test structure
  • onTaskUpdate(packs, events) - Report test results
  • onUserConsoleLog(log) - Forward console output
  • snapshotSaved(snapshot) - Report snapshot state

Key Subsystems

Snapshot System (@vitest/snapshot)

Two layers:

  • SnapshotClient runs in each worker. It manages per-file snapshot state, compares values against stored snapshots, and handles inline snapshots. Each test file gets a SnapshotState instance that reads/writes .snap files.
  • SnapshotManager runs on the main thread. It aggregates SnapshotResult from all workers into a SnapshotSummary (added/updated/removed counts). The resolveSnapshotPath config controls where .snap files live (default: __snapshots__/ next to the test file).

Mocking (@vitest/mocker + @vitest/spy)

vi.fn() and vi.spyOn() are provided by @vitest/spy. This is a standalone package that implements Jest’s mock function interface: call tracking, return value configuration, mockImplementation, mockRestore, etc. It has no Vitest dependency.

vi.mock() and vi.unmock() are handled by @vitest/mocker. The critical piece is the hoistMocksPlugin - a Vite transform plugin that rewrites source code to move vi.mock() calls to the top of the file. This is necessary because vi.mock() must execute before any imports, but ES modules are hoisted. The plugin uses AST analysis (estree-walker) to detect mock calls and relocates them.

At runtime, the VitestMocker class intercepts module resolution. When a module is mocked, the mocker provides the mock factory’s return value instead of the real module. The __mocks__/ directory provides automatic mock implementations.

Why mock ordering issues happen: vi.mock() is hoisted by the Vite plugin at transform time, but the actual mock setup runs at module evaluation time. If you have circular dependencies or conditional mocks, the execution order may not match what you expect. The transform only moves the vi.mock() call - it does not change when the factory function executes.

Coverage

Two providers, same interface (CoverageProvider):

@vitest/coverage-v8 uses V8’s built-in code coverage profiler (node:inspector). Inside each worker, startCoverageInsideWorker() enables the V8 profiler before tests run. After tests finish, stopCoverageInsideWorker() collects raw V8 coverage data and writes it to a temp file. On the main thread, V8CoverageProvider.generateCoverage() reads these files, converts V8 coverage to Istanbul format using ast-v8-to-istanbul, and generates reports (text, lcov, html, etc.) via istanbul-reports.

@vitest/coverage-istanbul takes a different approach: it instruments source code at transform time (via the CoverageTransform Vite plugin) by inserting counter statements. At runtime, the instrumented code tracks which branches/lines/functions execute. Results are collected the same way.

Both providers share the BaseCoverageProvider class and integrate with Vitest’s lifecycle: onTestRunStart, generateCoverage, mergeReports (for sharded CI).

Browser Mode (@vitest/browser)

Instead of running tests in a Node worker, browser mode runs them in a real browser:

  1. Vitest starts a Vite dev server that serves test files to the browser
  2. A browser is launched via Playwright or WebDriverIO
  3. Test files are loaded as ES modules in the browser
  4. @vitest/runner executes in the browser context
  5. Results are sent back to the Node process via WebSocket

This lets you test against real DOM APIs, CSS rendering, and browser-specific behavior. The @vitest/browser-preview package provides the iframe harness that loads tests.

Reporter System

Reporters implement the Reporter interface with lifecycle hooks:

onInit -> onTestRunStart -> onTestModuleQueued -> onTestModuleCollected
  -> onTestCaseReady -> onTestCaseResult -> onTestModuleEnd
  -> onTestRunEnd

The TestRun class (test-run.ts) receives events from workers via RPC and dispatches them to all registered reporters. Built-in reporters include:

  • default - Compact output with dots/checkmarks
  • verbose - Full test names
  • dot - Minimal dot output
  • json - Machine-readable JSON
  • junit - JUnit XML (for CI)
  • github-actions - GitHub Actions annotations
  • html - HTML report (via @vitest/ui)
  • blob - Serialized binary format for --merge-reports across shards
  • hanging-process - Detects process hangs

The BaseReporter class provides shared rendering logic (summary, error formatting, snapshot summary).

Your Projects Connection

ai-skills-sync

Uses Vitest v4 with @vitest/coverage-v8. Your tests run through the standard flow:

  • Pool: Default threads pool - test files run in Node worker_threads
  • Transform: Your TypeScript source passes through Vite’s esbuild transform. Any tsconfig path aliases resolve via Vite’s resolver.
  • Coverage: V8 profiler runs inside each worker thread, raw coverage ships to main thread, gets converted to Istanbul format. Your coverage config (thresholds, include/exclude) is handled by V8CoverageProvider.resolveOptions().
  • Mocking: If you use vi.mock(), the hoistMocksPlugin rewrites your test files during Vite transform.

mysukari.com

Uses Vitest with @testing-library/react, jsdom, and @vitest/coverage-v8.

  • Environment: Your environment: 'jsdom' config means each worker calls loadEnvironment('jsdom') during setup. This creates a jsdom window/document before your tests run. The setupBaseEnvironment() function in workers/base.ts handles this.
  • React testing: @testing-library/react renders components into jsdom’s DOM. Vitest’s jsdom environment provides the window, document, and other browser globals that React needs.
  • Web Workers: If you test code that uses Web Workers, @vitest/web-worker simulates them in the jsdom environment.
  • Coverage: Same V8 pipeline as ai-skills-sync.
  • CSS: The CSSEnablerPlugin controls whether CSS imports are processed or stubbed. By default, CSS imports return empty objects (faster). The css.modules.classNameStrategy setting controls CSS module class name generation during tests.

starlight-action (v3)

Uses Vitest v3 (older architecture but same fundamental design). The package map is largely the same. Key difference: v4 introduced a new Pool class that replaced the older tinypool-based implementation.

Subsystems you interact with

Subsystemai-skills-syncmysukari.comstarlight-action
Vite transform pipelineYesYesYes
threads worker poolYes (default)Yes (default)Yes (default)
jsdom environmentNo (Node)YesLikely Node
@vitest/coverage-v8YesYesNo
@vitest/spy (vi.fn)If mockingIf mockingIf mocking
@vitest/mocker (vi.mock)If mocking modulesIf mocking modulesIf mocking modules
@vitest/snapshotIf using snapshotsIf using snapshotsIf using snapshots
@testing-library/reactNoYesNo
Reporterdefaultdefaultdefault

When debugging test issues in these projects, the most relevant source locations are:

  • Mock hoisting problems: packages/mocker/ (the hoistMocksPlugin transform)
  • Environment setup failures: packages/vitest/src/runtime/workers/base.ts (setupBaseEnvironment)
  • Coverage gaps: packages/coverage-v8/src/provider.ts (V8CoverageProvider)
  • Module resolution issues: packages/vitest/src/node/environments/fetchModule.ts and packages/vitest/src/node/pools/rpc.ts