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.

One root configuration versus per-package projects A single root configuration forces one environment and one setup on every package, while workspace projects give each package its own environment, setup files and coverage thresholds under one run command. One root config jsdom for everyone one setup file, always loaded one coverage number changing it risks every package Workspace projects node here, jsdom there setup only where needed thresholds per package one command still runs them all
Projects keep the single command while removing the single set of compromises.

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'] },
});
Where an import of a workspace package resolves Without an alias the import follows the package's main entry into the built output, so edits require a rebuild; with an alias it lands on the source file and edits are visible immediately. apps/web test import '@acme/ui' no alias packages/ui/dist/index.js with alias packages/ui/src/index.ts needs a build stale until then instant edit and re-run keep one job that runs without the alias, so packaging mistakes are still caught before release
Aliasing to source is what makes cross-package edits testable without a build between every keystroke.

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');
});
Three properties to assert once and keep forever Every package appears as a named project, each project's environment matches what it needs, and workspace imports resolve to source — three cheap assertions that catch silent configuration drift. discovery every package shows as a named project missing = missing config environment document is undefined in node projects catches an inherited default resolution imports land in src/ not in dist/ catches a broken alias
Configuration drift in a workspace is silent by nature; these three assertions make it loud.

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.