Vitest Configuration & Setup
A vitest.config.ts file is the single most load-bearing artifact in a modern JavaScript test suite: it decides which environment each test runs in, how modules resolve, how workers are pooled, and which coverage gates block a merge. Get it wrong and you inherit flaky hydration errors, leaked DOM state, and CI runs that pass locally but fail on a runner. This section sits under Component & Integration Testing and treats Vitest configuration as an architectural decision rather than boilerplate — the reference point that every component test, integration suite, and CI shard extends. The goal is a config you can read top to bottom and predict exactly how a given test will execute.
Architectural Scope & Boundaries
Vitest configuration governs the runner contract: the deterministic mapping between a test file and the runtime that executes it. That contract spans four concerns — environment selection (jsdom vs node vs happy-dom), module resolution (aliases, deps.inline, transform pipeline), execution model (pools, isolation, concurrency), and quality gates (coverage thresholds, reporters). Everything in this section operates at the unit and integration tiers. It does not cover real-browser rendering — when you need a genuine layout engine, GPU, or multi-tab orchestration, that work belongs in Playwright component testing, which mounts components in Chromium rather than in a simulated DOM.
The boundary matters because misplacing a test wastes runtime and erodes trust. A pure reducer needs environment: 'node' and runs in single-digit milliseconds; a component asserting on rendered ARIA roles needs jsdom and the React plugin; a test that depends on real getBoundingClientRect geometry needs a browser and should not be forced into jsdom with brittle polyfills. Configuration is where you encode those tiers explicitly. Anything you cannot express in vitest.config.ts — query semantics, render wrappers, accessible selectors — is downstream and belongs to Testing Library best practices.
Because Vitest is Vite under the hood, the config also inherits Vite’s plugin and transform pipeline, which is a feature and a trap in equal measure: your test build and your app build share a resolver, so parity is nearly free, but any Vite plugin that rewrites imports or injects globals also runs in tests and can surprise you. The discipline is to keep the test block minimal and explicit, letting the shared pipeline do the heavy lifting while you own only the handful of options that change how tests execute.
How Vitest resolves and applies its configuration is worth visualizing before you edit a single option.
Reading that flow left to right explains why a single wrong option ripples so far. The config is evaluated once, but its output — the resolved alias map, the environment assignment, the pool shape — is what every worker inherits. An alias that resolves differently in the test graph than in the app build does not fail loudly; it silently loads a second copy of a module, and the symptom surfaces three steps downstream as a mysterious “two Reacts” hydration error inside a worker. Treating the config as an architectural contract rather than a bag of switches means asking, for each option, what does every worker inherit from this? — because that inheritance is the whole game.
The first decision the contract encodes is the environment, and it is worth making mechanical rather than habitual. The wrong environment is the most common cause of both wasted runtime and false confidence, so route each file through a short question before you write its first assertion.
Prerequisites
Step-by-Step Implementation
Step 1 — Establish the base config with explicit environment
Start from a single typed defineConfig. Never rely on the implicit default environment; declare it so a reader knows immediately whether a file touches the DOM. Keep globals: false so vi, describe, and expect are imported explicitly — this prevents namespace pollution and makes test files portable.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'node:path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
globals: false,
include: ['src/**/*.test.{ts,tsx}'],
exclude: ['node_modules', 'dist', '.next', 'coverage'],
},
});
Two choices in this base config carry more weight than they appear to. Declaring environment explicitly, even when it matches the default, turns an invisible assumption into a reviewable line — a reader never has to guess whether a file touches the DOM. And the resolve.alias map must mirror the paths in tsconfig.json exactly; when they drift, TypeScript resolves @/lib/parse to one file while Vite resolves it to another, and you get a test that type-checks against code it never actually runs. Keeping globals: false is the third deliberate choice: importing describe, it, and expect costs a line per file but makes every test self-describing and portable, and it sidesteps the whole class of “works in this file, undefined in that one” errors that ambient globals invite.
Step 2 — Wire a setup file for matchers and cleanup
setupFiles runs once per worker before the suite. Use it to register DOM matchers and enforce teardown. Note that Vitest uses setupFiles, not Jest’s setupFilesAfterEnv.
// vitest.setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
// Unmount rendered trees so DOM state never leaks across tests
afterEach(() => cleanup());
Register it in the config:
// vitest.config.ts (test block)
test: {
environment: 'jsdom',
globals: false,
setupFiles: ['./vitest.setup.ts'],
}
Step 3 — Split environments per file when needed
Most suites mix DOM and pure-logic tests. Rather than forcing everything into jsdom, override the environment per file with a docblock comment so node-only tests stay fast.
// src/lib/parse.test.ts
// @vitest-environment node
import { describe, it, expect } from 'vitest';
import { parse } from '@/lib/parse';
describe('parse', () => {
it('returns a typed record', () => {
expect(parse('a=1')).toEqual({ a: '1' });
});
});
The per-file docblock is the pragmatic middle path between one blanket environment and a fully separate config per tier. It keeps the fast node default for the bulk of a codebase — reducers, selectors, formatters, API-client logic — while letting the handful of files that render components opt into jsdom at the top of the file, where the cost is visible and local. The alternative, Vitest projects (formerly workspaces), is worth reaching for once the two tiers need genuinely different plugins or setup files rather than just a different environment; until then, the docblock keeps the whole suite under one readable config. Whichever you choose, the principle is the same: no file should pay for a capability it does not use, and every file should declare the runtime it expects rather than inheriting one by accident.
Step 4 — Choose a worker pool and isolation model
pool: 'forks' gives true process isolation and is the safest default for suites that mutate globals or rely on module-level state. threads is faster but shares the V8 isolate. Keep isolate: true unless you have measured a need to relax it.
// vitest.config.ts (test block)
test: {
pool: 'forks',
poolOptions: {
forks: {
singleFork: false,
execArgv: ['--max-old-space-size=4096'],
},
},
isolate: true,
}
The choice between forks and threads is a choice about what your tests assume. threads runs each file in a worker thread that shares one V8 isolate, so it is cheaper to spin up but leaks anything global — a monkey-patched Date, a mutated process.env, a module-level singleton that caches state. forks pays for a full child process per worker and in return gives you a clean global scope every time, which is why it is the safer default for integration suites that touch timers, environment variables, or shared caches. The pragmatic rule is to start on forks with isolate: true, measure, and only reach for threads if worker startup is provably your bottleneck and your suite is disciplined about global state. Relaxing isolate is the more dangerous knob: it reuses module state across files in the same worker, which can halve runtime and simultaneously introduce order-dependent failures that only appear when two specific files land on the same worker.
Step 5 — Add coverage with enforced thresholds
Coverage is only useful if it gates the pipeline. Configure the v8 provider with per-metric thresholds; align the numbers with your strategy for coverage thresholds rather than picking a round number.
// vitest.config.ts (test block)
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'json-summary'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/**/*.d.ts'],
thresholds: {
lines: 85,
branches: 80,
functions: 85,
statements: 85,
},
},
}
A threshold that never moves is a threshold nobody trusts. Set the four metrics to the level the suite already clears plus a small margin, commit that as the floor, and ratchet it up as coverage genuinely improves rather than picking an aspirational 90 that turns every pull request red and trains the team to add /* c8 ignore */ comments. Branch coverage is the number that actually correlates with caught defects, because it forces both sides of every conditional to be exercised; lines and statements are easy to satisfy without proving much. Keep the exclude list honest — excluding test files and type declarations is correct, but excluding whole feature directories to hit a number is coverage theatre. Align the specific thresholds with the reasoning in defining coverage thresholds so the numbers reflect a strategy rather than a mood.
Step 6 — Disable the file cache in CI
The on-disk cache speeds local reruns but can mask stale module-resolution artifacts on a fresh runner. Toggle it off when CI is set.
// vitest.config.ts (test block)
test: {
cache: process.env.CI ? false : { dir: 'node_modules/.vite' },
}
Configuration Reference
| Option | Type | Default | Effect |
|---|---|---|---|
test.environment |
'node' | 'jsdom' | 'happy-dom' |
'node' |
Runtime each file executes in; set per-file with a @vitest-environment docblock. |
test.globals |
boolean |
false |
When true, exposes describe/it/expect without imports; keep false for explicit, portable tests. |
test.setupFiles |
string[] |
[] |
Modules run once per worker before tests; the Vitest equivalent of Jest’s setupFilesAfterEnv. |
test.pool |
'forks' | 'threads' | 'vmThreads' |
'forks' |
Worker execution model; forks gives full process isolation, threads is faster but shares state. |
test.isolate |
boolean |
true |
Re-initializes module state per test file; disable only after measuring a real speedup. |
test.deps.inline |
(string | RegExp)[] |
[] |
Forces named packages through Vite’s transform — required for ESM-only or hybrid deps. |
test.coverage.thresholds |
object |
none | Per-metric minimums (lines, branches, etc.) that fail the run when unmet. |
test.sequence.concurrent |
boolean |
false |
Runs tests within a file concurrently; leave false for stateful integration flows. |
test.retry |
number |
0 |
Reruns failed tests; use sparingly, never globally, to avoid masking instability. |
test.cache |
false | { dir } |
{ dir } |
On-disk transform cache; disable in CI for deterministic cold runs. |
Verification & Assertions
Confirm the config is doing what you think before trusting it. Run vitest --run once and read the printed environment and pool line; if it does not say forks or jsdom where you expect, your config did not load. Then assert on a DOM matcher to prove jsdom plus the setup file are wired:
// src/components/Badge.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { Badge } from '@/components/Badge';
describe('Badge', () => {
it('renders its label as accessible text', () => {
render(<Badge>Stable</Badge>);
// toBeInTheDocument comes from the setup file — its presence proves wiring
expect(screen.getByText('Stable')).toBeInTheDocument();
});
});
A passing DOM matcher confirms three things at once: jsdom loaded, the React plugin transformed the JSX, and setupFiles registered jest-dom. For coverage, run vitest run --coverage and confirm the summary table prints and that an intentionally low threshold causes a non-zero exit code.
It is worth verifying the negative as deliberately as the positive. Add one node-environment test that would throw if it accidentally ran under jsdom — for instance, one that asserts typeof window === 'undefined' — so a future change that blanket-sets the environment fails loudly instead of silently slowing the suite. Likewise, temporarily break an alias and confirm the run fails at resolution rather than limping along against a stale copy; a config that cannot detect its own misconfiguration is not a contract, it is a hope. These small adversarial checks are cheap to write once and pay for themselves the first time someone refactors the config six months later without the context you have today.
Edge Cases & Failure Modes
ESM-only dependency throws Cannot use import statement outside a module. Vite pre-bundles dependencies as CommonJS by default; a pure-ESM package breaks. Add it to test.deps.inline (e.g. inline: [/^some-esm-pkg/]) so Vite transforms it in the test graph.
DOM state leaks between tests. Symptoms are tests that pass in isolation but fail in sequence, or duplicate elements found by a query. The cause is a missing cleanup(); ensure the afterEach(cleanup) in your setup file actually runs by confirming setupFiles is registered.
window is not defined in a node-environment file. A test that imports a component but is tagged (or defaulted) to node will fail at module load. Either move it to jsdom or stub the browser API it touches; do not blanket-set everything to jsdom, which slows pure-logic tests.
OOM kills under heavy parallelism. Large component suites with many workers exhaust heap. Cap memory per worker via execArgv: ['--max-old-space-size=4096'] and reduce poolOptions.forks.maxForks rather than disabling isolation.
Two copies of React trigger invalid-hook or hydration errors. When a dependency bundles its own React or an alias resolves the framework twice, hooks throw at render time. Deduplicate with resolve.dedupe: ['react', 'react-dom'] and confirm npm ls react reports a single version; this is the runtime symptom of the alias/tsconfig drift described in Step 1.
Tests pass locally but fail only in CI. The usual culprit is the on-disk cache masking a stale transform, or a test that depends on wall-clock timing that behaves differently on a slower runner. Disable the cache in CI as in Step 6, and replace any real-time dependency with fake timers so the runner’s speed never changes the outcome.
A @vitest-environment docblock is silently ignored. The comment must be the first line of the file, before any import, and spelled exactly. A blank line or a leading import above it means the file falls back to the global default — a common reason a component test mysteriously reports document is not defined.
Performance & CI Impact
The dominant cost in a Vitest run is worker startup multiplied by isolation. forks with isolate: true is the most reliable but the most expensive; profile with vitest --run --reporter=verbose before relaxing either. Sharding is the highest-leverage CI lever: split the suite across runners with --shard=1/3 and aggregate coverage afterward, a pattern explored in depth in balancing speed and coverage in monorepo testing. Persist node_modules/.vite between local runs but discard it in CI for determinism, and never set a global retry — it trades real signal for a green badge and hides the flakiness you should be fixing.
Two economic subtleties decide whether sharding actually pays off. First, coverage must be merged, not gated per shard: each shard sees only the files its slice of tests touches, so a per-shard threshold check would fail against thousands of legitimately-unexercised files. Collect the fragments and evaluate thresholds once on the union. Second, shard count has diminishing returns — past the point where each shard’s fixed startup cost dominates its test work, adding runners buys nothing but a bigger bill. Measure the wall-clock of one shard versus three versus six and pick the knee of the curve. Beyond sharding, the cheapest speed-up is simply keeping the DOM environment off files that do not need it; a suite that has quietly defaulted everything to jsdom is often paying a 3-4x runtime tax on its pure-logic tests for no benefit, which the per-file environment split in Step 3 removes at a stroke.
In This Topic Area
- Configuring Vitest for Next.js App Router — resolve server/client component boundaries, inline Next.js internals, and stub
next/navigationso App Router suites run under jsdom without hydration errors. - Sharing a Vitest config across a Turborepo — publish a base config package, compose it with Vitest projects, apply per-package overrides, and cache the test task in Turborepo.
Related
Configuring Vitest for Next.js App Router
Fix Vitest failures with the Next.js App Router: inline Next internals, stub next/navigation, target jsdom, and eliminate ESM and hydration errors in tests.
Sharing a Vitest Config Across a Turborepo
Publish a base Vitest config package, compose it with Vitest projects, apply per-package overrides, and cache the test task in Turborepo for fast monorepo CI.