Wiring Vitest Workspace Projects in a pnpm Monorepo
A monorepo with one Vitest configuration at the root is a configuration that nobody can change safely: the Node-only packages carry a jsdom environment they do not need, the component packages inherit setup files that break them, and any adjustment for one package risks the other nineteen. Vitest’s workspace mode solves this by making each package a project with its own environment, setup and coverage rules, run together by a single command. This guide walks through wiring that up in a pnpm workspace from scratch — the root file, per-package configurations, aliases that resolve to source, shared setup that is shared deliberately rather than globally, and reporter output you can actually read when twenty projects run at once. It sits under monorepo and workspace testing and assumes Vitest 1.x or 2.x with pnpm 9.
Root Cause Analysis
The single-configuration approach fails for a structural reason: test environment is a per-package property, and so is almost everything downstream of it. A package of pure date arithmetic wants a Node environment, no setup files and a high coverage bar. A component package wants jsdom, a Testing Library cleanup hook, a matcher extension and a lower branch threshold. Expressing both in one file means conditionals keyed on file paths, which grow until nobody can predict which setup applies to a given test.
The second failure is resolution. In a workspace, @acme/ui might mean the source directory, the built output, or a version from the registry, and the answer changes what your tests actually verify. A single root configuration usually picks one answer for everything, which is wrong for at least some packages — and wrong silently, because tests still pass, they just test a different artifact than you believed.
The third is reporting. Running everything as one flat project produces failure output that names files but not packages, so a failure in src/index.test.ts could be any of six packages. Naming projects is a two-word change that turns an unreadable log into an attributable one, and it costs nothing.
Reproducible Setup
A minimal workspace with three shapes of package: pure logic, a component library, and an application that consumes both.
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'
pnpm add -Dw vitest @vitest/coverage-v8
pnpm --filter @acme/ui add -D jsdom @testing-library/react @testing-library/jest-dom @vitejs/plugin-react
.
├── packages/
│ ├── core/ pure TypeScript, node environment
│ ├── ui/ React components, jsdom
│ └── test-utils/ shared render helper
└── apps/
└── web/ consumes core and ui
Implementation
Step 1 — Create the root workspace file. It lists projects; it does not configure them. Globs are resolved relative to the repository root, and a package without a Vitest configuration is simply not a project.
// vitest.workspace.ts
export default [
'packages/*/vitest.config.ts',
'apps/*/vitest.config.ts',
];
Step 2 — Give the pure package a Node project. No jsdom, no setup files, no DOM matchers — and consequently the fastest tests in the repository.
// packages/core/vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
name: 'core',
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: { provider: 'v8', thresholds: { lines: 90, branches: 85 } },
},
});
Step 3 — Give the component package a jsdom project with its own setup. The setup file lives in the package that needs it, so no other project loads it and nobody has to reason about whether it applies.
// packages/ui/vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
name: 'ui',
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.test.tsx'],
coverage: { provider: 'v8', thresholds: { lines: 85, branches: 75 } },
},
});
// packages/ui/src/test/setup.ts
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => cleanup());
Step 4 — Resolve workspace imports to source. Without an alias, apps/web imports the built dist of its dependencies, so a change in packages/ui is invisible until someone rebuilds.
// apps/web/vitest.config.ts
import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url';
const pkg = (name: string, sub = 'src/index.ts') =>
fileURLToPath(new URL(`../../packages/${name}/${sub}`, import.meta.url));
export default defineConfig({
resolve: {
alias: {
'@acme/core': pkg('core'),
'@acme/ui': pkg('ui'),
'@acme/test-utils': pkg('test-utils'),
},
},
test: { name: 'web', environment: 'jsdom', setupFiles: ['./src/test/setup.ts'] },
});
Two details make aliases behave. They must be absolute paths, because a relative alias is resolved against the file doing the importing rather than the configuration, which produces resolution failures that look like missing packages. And the alias list has to stay in step with the workspace as packages are added — a helper that reads pnpm-workspace.yaml and generates the map is worth writing once a repository passes a dozen packages, since a forgotten entry silently falls back to build output.
Step 5 — Wire the scripts so both whole-repo and single-package runs work. Developers want one package; CI wants everything.
// package.json (root)
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest run --project ui",
"coverage": "vitest run --coverage"
}
}
pnpm test --project core --project ui # a subset, by project name
pnpm --filter @acme/ui test # or from the package itself
Verification
Confirm Vitest discovered every project and that the names appear in the output. A package missing from this list has no configuration file, or the glob does not reach it.
pnpm vitest run --reporter=basic
# ✓ |core| src/date.test.ts (12 tests) 24ms
# ✓ |ui| src/Button.test.tsx (8 tests) 210ms
# ✓ |web| src/routes/home.test.tsx (5 tests) 340ms
# Test Files 3 passed (3)
Then verify environments really are per project, because a misplaced setting silently gives everyone jsdom. A one-line assertion in each package is a cheap permanent check.
// packages/core/src/env.test.ts
import { test, expect } from 'vitest';
test('core runs in a node environment', () => {
expect(typeof document).toBe('undefined');
});
Finally, verify the alias resolves to source rather than to build output, since this is the setting most likely to be silently wrong after a refactor.
// apps/web/src/resolve.test.ts
import { test, expect } from 'vitest';
test('workspace imports resolve to source', async () => {
const mod = await import('@acme/ui');
expect(import.meta.resolve('@acme/ui')).toContain('/packages/ui/src/');
expect(mod).toHaveProperty('Button');
});
One habit is worth adopting alongside these checks: run the whole workspace once with --reporter=basic after any configuration change, and read the project labels rather than the pass count. Projects disappear quietly — a renamed directory, a glob that no longer matches, a configuration file moved during a refactor — and a run that reports “12 passed” from eleven projects instead of twelve looks exactly like a healthy run.
Troubleshooting
Symptom: “No test files found” for a package that clearly has tests. Diagnosis: the project’s include pattern is relative to the package directory, not the repository root, so a pattern copied from a root configuration points nowhere. Fix: use patterns relative to the package, and confirm with pnpm vitest list --project <name>.
Symptom: jsdom globals appear in a Node project. Diagnosis: a setup file from another package is being loaded, usually because a path in setupFiles climbed out of the package. Fix: keep setup files inside their own package and never reference one across a package boundary — if two packages need the same setup, it belongs in the shared test utilities package.
Symptom: coverage thresholds are ignored. Diagnosis: coverage is being collected at the root across all projects, where per-project thresholds do not apply. Fix: run coverage per project, or keep root coverage for reporting only and enforce thresholds in the per-package run, as described in enforcing coverage thresholds in a monorepo.
FAQ
Do I need a Vite configuration in every package?
Only a Vitest configuration, and only where the package has tests. A package with no tests simply has no project, which is fine and keeps the workspace honest about where coverage actually exists. Packages that also build with Vite can share one file, since defineConfig from vitest/config accepts both sets of options.
How do project names interact with filtering?
The name field is what --project matches, and it is independent of the package name, so you can group several packages under one label if you genuinely want to run them together. In practice keeping the project name identical to the last segment of the package name is the least surprising choice, and it makes CI logs directly greppable.
Should the root have a Vitest configuration at all?
Prefer not to. A root configuration alongside a workspace file creates two places where a setting could come from, and the precedence surprises people. Keep the root file limited to listing projects, and if something genuinely must be global — a reporter, a coverage provider — pass it on the command line where it is visible.
Does this work with npm or Yarn workspaces?
Yes; nothing in the workspace file is pnpm-specific. What differs is node module layout: pnpm’s strict linking will fail loudly on an undeclared dependency, while hoisted layouts may resolve it accidentally. That strictness is an advantage here, because it surfaces the implicit dependencies that otherwise make affected-only runs unreliable.
Related
- Back to Monorepo & Workspace Testing
- Testing shared packages without publishing — source versus built resolution in depth.
- Versioning test utilities as an internal package — where shared setup belongs.
- Sharing a Vitest config across a Turborepo — factoring the common parts out.