Skip to content

Vitest: Config, Parallelism, Workspaces

Practical Vitest setup trade-offs for monorepos, config composition, parallelism, and workspaces without turning CI into a flag dump.

5 min read
Vitest
Monorepo
Testing
Vite
Parallelism
Workspaces

The testing trophy and unit-vs-integration criteria argued for an integration-heavy mix. That only works if Vitest stays fast. A clever Testing Library suite that takes eight minutes locally loses to a worse suite people actually run.

Practical setup for a pnpm monorepo, not an encyclopedia of every Vitest flag.

Start from Vite, do not fork reality

Vitest rides Vite’s resolve, aliases, and transforms. I keep one source of truth for path aliases (@/src/) in the Vite config the app already uses, then let Vitest inherit:

ts
// apps/web/vitest.config.ts, illustrative 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', setupFiles: ['./src/test/setup.ts'], include: ['src/**/*.{test,spec}.{ts,tsx}'], css: false, // assert behavior, not stylesheets, unless CSS is the bug }, });

Decision: if production resolve and test resolve diverge, you will debug "works in Vitest, breaks in Vite" forever. Share aliases; fork only when a test-only shim is unavoidable (e.g. stubbing import.meta.env keys you already documented in the env-vars post).

Parallelism: pool choice is a trade-off

Vitest can run files across worker threads or forks. Defaults are usually fine until they are not.

KnobWhen I touch itRisk if wrong
File parallelismAlways on for independent filesRare shared global state flakes
pool: 'forks'Native addons, stubborn module cacheHigher memory
pool: 'threads'Pure JS, memory-sensitive CIShared memory surprises
fileParallelism: falseDebugging order-dependent flakesSlow feedback
maxWorkersCI OOM or noisy neighbor runnersUnder-utilized CPUs

I do not copy a blog’s maxWorkers: 4 into every project. I watch CI wall time and memory first (same measurement habit as the ESLint performance post). If GitHub-hosted runners OOM on a monorepo, I lower workers for that job, not for every laptop.

ts
// CI-only overlay, illustrative export default defineConfig({ test: { maxWorkers: process.env.CI ? 2 : undefined, // Prefer isolating flaky integration files over global serial mode }, });

Workspaces: one command, honest package boundaries

In a pnpm workspace I want pnpm test at the root to mean "every package’s suite," with each package owning its config:

text
apps/web/vitest.config.ts packages/ui/vitest.config.ts packages/billing/vitest.config.ts
ts
// vitest.workspace.ts, illustrative import { defineWorkspace } from 'vitest/config'; export default defineWorkspace([ 'apps/*/vitest.config.ts', 'packages/*/vitest.config.ts', ]);

Why not one mega-config? UI package tests should not pull the web app’s Next/TanStack plugins. Billing unit tests should not pay for jsdom if they are pure functions, use environment: 'node' there.

ts
// packages/billing/vitest.config.ts, illustrative import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'], }, });

That is trophy economics in config form: pay for jsdom only where DOM assertions justify the cost.

Setup files: shared without becoming a junk drawer

ts
// apps/web/src/test/setup.ts, illustrative import '@testing-library/jest-dom/vitest'; import { afterEach } from 'vitest'; import { cleanup } from '@testing-library/react'; afterEach(() => { cleanup(); });

I keep MSW server start/stop in setup when most tests need network stubs. I do not dump global router history resets, fake timers, and analytics mocks into one file, those belong next to the tests that need them. Global setup that is "convenient" becomes the reason two files flake when run together.

Filtering the monorepo loop

Root vitest run is for CI. Locally I prefer package or project filters so the integration band stays in working memory:

bash
# Illustrative developer loops pnpm --filter @acme/ui test pnpm exec vitest run --project web src/orders/order-status.test.tsx

Name projects in each config (test.name) when you use workspace mode so --project is stable. Unnamed projects force people to remember folder paths.

Coverage reporters are optional spotlights, not vanity gates, same refusal as the testing trophy post. If you enable coverage, scope include to the package’s src and exclude fixtures; do not fail CI because a story file sits at 72%.

Failure modes

Different alias maps per package "for now." Six months later, imports resolve in tests and fail in build. Treat resolve as a workspace contract.

Forcing threads everywhere because it is faster on a laptop. Native dependencies and some CSS pipelines want forks. Measure per package.

Workspace root that runs everything as jsdom. Pure packages get slower and noisier for no gain.

Snapshot folders committed as a substitute for assertions. Same chrome tax as the testing trophy post, prefer role/text queries.

Silent MSW fallthroughs. If app setup uses MSW, prefer onUnhandledRequest: 'error' so tests cannot pass by accident on unmocked routes.

Continuity

Unit vs integration layer choice only sticks if the runner is boring and fast. Branded / nominal types for IDs and tokens return to the TypeScript depth arc, then linting picks back up with Biome. The TypeScript 6.0 bridge is a later post, not this runner post.

Takeaway

Vitest config should preserve production resolve, pay for jsdom only where DOM assertions justify the cost, and tune workers from CI evidence, not from a flag museum.