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:
| Package | npm name | Purpose |
|---|---|---|
vitest | vitest | The 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/runner | The actual test execution engine: describe, it, beforeAll, hooks, retries, concurrent tests. Framework-agnostic - has no Vite dependency. |
expect | @vitest/expect | Jest-compatible expect() matchers implemented as a Chai plugin. Asymmetric matchers, toEqual, toMatchSnapshot, etc. |
spy | @vitest/spy | Lightweight Jest-compatible spy/mock functions (vi.fn(), vi.spyOn()). Standalone - no Vitest dependency. |
snapshot | @vitest/snapshot | Snapshot testing: SnapshotClient (per-worker), SnapshotManager (main thread summary), inline snapshot support. |
mocker | @vitest/mocker | Module 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-v8 | Coverage 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-istanbul | Coverage via Istanbul instrumentation (Babel-based code transform). |
browser | @vitest/browser | Browser-mode test runner. Runs tests in a real browser instead of Node. |
browser-playwright | @vitest/browser-playwright | Playwright adapter for browser mode. |
browser-webdriverio | @vitest/browser-webdriverio | WebDriverIO adapter for browser mode. |
browser-preview | @vitest/browser-preview | Preview/iframe harness for browser test execution. |
ui | @vitest/ui | Web dashboard (--ui flag). A Vite-powered SPA that connects via WebSocket. |
ws-client | @vitest/ws-client | WebSocket client for communicating with a running Vitest server (used by UI and IDE extensions). |
utils | @vitest/utils | Shared utilities: diff, serialization, source-map parsing, error formatting. |
pretty-format | @vitest/pretty-format | Fork of Jest’s pretty-format with ESM support. Used for snapshot serialization. |
web-worker | @vitest/web-worker | Simulates 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:
- Creates a
Vitestinstance - Calls
VitestPlugin()which returns an array of Vite plugins - Calls Vite’s
createServer()with those plugins injected - The Vite server’s
configureServerhook callsvitest._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, setsprocess.env.NODE_ENV, handles CSS modules strategy). ItsconfigureServerhook bootstraps the test infrastructure.MetaEnvReplacerPlugin- Replacesimport.meta.envwithprocess.envso values are reassignable at runtime.CSSEnablerPlugin- Controls CSS processing based oncssconfig (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) -hoistMocksPluginrewritesvi.mock()calls to the top of files, andautomockPluginhandles__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:
- Vite’s chokidar watcher detects file changes
VitestWatcher.onFileChange()fires- Vitest walks the Vite module graph to find affected test files
vitest.scheduleRerun()debounces (100ms) and reruns only affected specs- The pool is reused between runs (workers may be recycled if
isolate: false) - 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: 1and defaultgroupOrderrun one at a time - Parallel groups: Files are dispatched to
maxWorkersconcurrent workers - Typecheck group: Type-only tests run in a separate group via the TypeScript compiler
- Group ordering:
sequence.groupOrdercontrols which groups run first (lower = earlier). Next group waits for the previous to finish. - Non-isolated optimization: When
isolate: falseandmaxWorkers: 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
| Pool | Mechanism | Isolation | Use Case |
|---|---|---|---|
threads (default) | worker_threads.Worker | Module-level (new ModuleRunner per file) | Best default. Shared memory possible. |
forks | child_process.fork() | Process-level | When you need full process isolation. Uses serialization: 'advanced' (structured clone). |
vmThreads | Worker thread + node:vm | VM context per file | Strongest isolation. Each file runs in its own VM context. Needs --experimental-vm-modules. |
vmForks | Fork + node:vm | Process + VM context | Maximum isolation. |
typescript | TypeScript compiler API | N/A | Type checking only, no runtime execution. |
browser | Real browser via Playwright/WebDriverIO | Browser tab | For 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:
- The worker’s
VitestModuleRunner(extends Vite’sModuleRunner) requests the module - The request goes via
birpcRPC to the main thread (createMethodsRPCinrpc.ts) - Main thread calls
project._fetcher(), which calls Vite’sfetchModule() - Vite runs the file through its transform pipeline (TypeScript, JSX, plugins, etc.)
- The transformed code is sent back to the worker
- The worker’s
VitestModuleEvaluatorexecutes 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 Viteresolve(id, importer, env)- Resolve a module IDonCollected(files)- Report collected test structureonTaskUpdate(packs, events)- Report test resultsonUserConsoleLog(log)- Forward console outputsnapshotSaved(snapshot)- Report snapshot state
Key Subsystems
Snapshot System (@vitest/snapshot)
Two layers:
SnapshotClientruns in each worker. It manages per-file snapshot state, compares values against stored snapshots, and handles inline snapshots. Each test file gets aSnapshotStateinstance that reads/writes.snapfiles.SnapshotManagerruns on the main thread. It aggregatesSnapshotResultfrom all workers into aSnapshotSummary(added/updated/removed counts). TheresolveSnapshotPathconfig controls where.snapfiles 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:
- Vitest starts a Vite dev server that serves test files to the browser
- A browser is launched via Playwright or WebDriverIO
- Test files are loaded as ES modules in the browser
@vitest/runnerexecutes in the browser context- 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/checkmarksverbose- Full test namesdot- Minimal dot outputjson- Machine-readable JSONjunit- JUnit XML (for CI)github-actions- GitHub Actions annotationshtml- HTML report (via@vitest/ui)blob- Serialized binary format for--merge-reportsacross shardshanging-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
threadspool - test files run in Nodeworker_threads - Transform: Your TypeScript source passes through Vite’s esbuild transform. Any
tsconfigpath 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
coverageconfig (thresholds, include/exclude) is handled byV8CoverageProvider.resolveOptions(). - Mocking: If you use
vi.mock(), thehoistMocksPluginrewrites 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 callsloadEnvironment('jsdom')during setup. This creates a jsdom window/document before your tests run. ThesetupBaseEnvironment()function inworkers/base.tshandles this. - React testing:
@testing-library/reactrenders components into jsdom’s DOM. Vitest’s jsdom environment provides thewindow,document, and other browser globals that React needs. - Web Workers: If you test code that uses Web Workers,
@vitest/web-workersimulates them in the jsdom environment. - Coverage: Same V8 pipeline as ai-skills-sync.
- CSS: The
CSSEnablerPlugincontrols whether CSS imports are processed or stubbed. By default, CSS imports return empty objects (faster). Thecss.modules.classNameStrategysetting 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
| Subsystem | ai-skills-sync | mysukari.com | starlight-action |
|---|---|---|---|
| Vite transform pipeline | Yes | Yes | Yes |
threads worker pool | Yes (default) | Yes (default) | Yes (default) |
| jsdom environment | No (Node) | Yes | Likely Node |
@vitest/coverage-v8 | Yes | Yes | No |
@vitest/spy (vi.fn) | If mocking | If mocking | If mocking |
@vitest/mocker (vi.mock) | If mocking modules | If mocking modules | If mocking modules |
@vitest/snapshot | If using snapshots | If using snapshots | If using snapshots |
@testing-library/react | No | Yes | No |
| Reporter | default | default | default |
When debugging test issues in these projects, the most relevant source locations are:
- Mock hoisting problems:
packages/mocker/(thehoistMocksPlugintransform) - 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.tsandpackages/vitest/src/node/pools/rpc.ts