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.
How Vitest resolves and applies its configuration is worth visualizing before you edit a single option.
Prerequisites
vitestand, for coverage,@vitest/coverage-v8).@vitejs/plugin-reactfor any suite that renders JSX components.jsdom(orhappy-dom) installed when targeting a DOM environment — Vitest does not bundle it.@testing-library/reactand@testing-library/jest-domfor component assertions."types": ["vitest/globals"]intsconfig.jsononly if you opt into globals.tsconfig.jsonwhosepathsmatch the aliases you intend to declare in the Vite resolver.
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'],
},
});
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' });
});
});
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,
}
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,
},
},
}
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 Table
| 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.
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.
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.
In-Depth Guides
- 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.