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.
| Knob | When I touch it | Risk if wrong |
|---|---|---|
| File parallelism | Always on for independent files | Rare shared global state flakes |
pool: 'forks' | Native addons, stubborn module cache | Higher memory |
pool: 'threads' | Pure JS, memory-sensitive CI | Shared memory surprises |
fileParallelism: false | Debugging order-dependent flakes | Slow feedback |
maxWorkers | CI OOM or noisy neighbor runners | Under-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:
textapps/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.