Monorepo & Workspace Testing
A monorepo changes what a test run means. In a single package, “run the tests” is unambiguous; across twenty packages it is a question about which packages, in what order, against which versions of each other. Get the answer wrong and you land in one of two familiar failure states: a repository where every change runs every test and the pipeline takes half an hour, or one where packages are tested in isolation against stale published versions and integration breaks are only found after release. This topic covers the middle path — per-package test projects that run independently, a dependency graph that decides what actually needs re-running, and shared test utilities that are versioned like code rather than copied. It sits under test pyramid strategy and assumes a pnpm or npm workspace with Vitest as the runner.
Architectural Scope & Boundaries
Monorepo testing is an orchestration concern layered on top of ordinary testing, not a different kind of testing. Each package still has unit tests, component tests and whatever integration tests it needs; what the monorepo adds is three questions the single-package case never asks. Which packages does this change affect? How does a package under test resolve its workspace dependencies — from source, from a build output, or from a published version? And where does shared test infrastructure live so that twenty packages do not each maintain their own copy of a render helper?
The answers interact. Resolving workspace dependencies from source gives the fastest feedback and the truest integration signal, because a change in a shared package is immediately visible to its consumers’ tests. Resolving from build output is slower but catches packaging mistakes — a missing export map entry, a type declaration that does not ship — that source resolution hides entirely. Most repositories want source resolution for the inner loop and at least one job that tests against built artifacts before release.
What this topic does not cover is the deployment side of a monorepo, or the question of whether to have one at all. It also does not replace per-package discipline: a workspace with twenty packages whose tests are slow, order-dependent and under-asserted is twenty problems, not one, and no amount of caching makes it faster in a way that means anything. The techniques here multiply the value of good per-package tests; they do not substitute for them.
One further boundary is worth stating because it causes real confusion. Running “only affected tests” is a cache and scheduling optimisation, not a correctness guarantee. It is correct exactly to the degree that your dependency graph is complete — and an implicit dependency, such as a package that reads a sibling’s build output through a relative path, is invisible to the graph and will be skipped when it should have run.
There is also a cultural boundary that determines whether any of this holds. In a monorepo, one team’s test infrastructure decision becomes everyone’s constraint: a root-level setup file that registers global mocks, a shared Vitest plugin, a lint rule about test naming. These are genuinely useful when small and genuinely corrosive when they grow, because a package that needs to opt out of a global has no clean way to do so. The rule that keeps this manageable is that the root owns orchestration and the packages own behaviour — the root file says where projects are and how they are cached, while each package decides its environment, its setup and its thresholds.
Prerequisites
Step-by-Step Implementation
Step 1 — Give every package its own test configuration, and the root a workspace file. The root file is a list, not a configuration: it tells Vitest where the projects are, and each project owns its environment, setup files and coverage settings.
// vitest.workspace.ts
export default [
'packages/*/vitest.config.ts',
'apps/*/vitest.config.ts',
];
// 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'],
globals: true,
},
});
Naming each project matters more than it looks: the name appears in reporter output, in coverage reports and in failure messages, so a failing test in a twenty-package repository says which package it came from without anyone having to read a path.
One structural decision hides inside that configuration. Each project gets its own environment, which means a Node-only package does not pay for jsdom and a component package is not forced into a Node environment because a sibling needed one. In a single-configuration repository these fight each other, and the usual resolution — jsdom everywhere — makes every pure-logic test slower for no benefit. Project mode is what makes per-package environments free.
Step 2 — Resolve workspace dependencies to source for the inner loop. Without this, a test in apps/web imports the built output of packages/ui, so every change to the shared package requires a build before its consumers see it.
// packages/ui/package.json — a publish-safe conditional export
{
"name": "@acme/ui",
"exports": {
".": {
"development": "./src/index.ts",
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
}
// apps/web/vitest.config.ts — pick the development condition in tests
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { name: 'web', environment: 'jsdom' },
resolve: { conditions: ['development'] },
});
The development condition is the important part. It applies only where something asks for it — the test configuration above, and typically the dev server — so the published package still resolves to dist for consumers outside the repository. This is what lets source resolution be an internal convenience rather than a packaging decision you have to defend later.
Step 3 — Put shared test utilities in a real package. A render helper, a set of factories and a mock server belong in a workspace package that other packages depend on, with its own tests. Copying them into each package is how twenty subtly different versions come to exist.
// packages/test-utils/src/render.tsx
import { render as rtlRender } from '@testing-library/react';
import type { ReactElement } from 'react';
import { ThemeProvider } from '@acme/ui';
export function render(ui: ReactElement, { theme = 'light' } = {}) {
return rtlRender(ui, {
wrapper: ({ children }) => <ThemeProvider theme={theme}>{children}</ThemeProvider>,
});
}
export * from '@testing-library/react';
export { userEvent } from '@testing-library/user-event';
Re-exporting the underlying library from the shared package, as the last two lines do, is a small decision with a large effect: consumers import everything from @acme/test-utils rather than from a mixture of that and the original library, so the day you need to change the wrapper, add a provider, or swap an implementation, there is exactly one import path to update across the workspace.
Step 4 — Let the graph decide what runs. A task runner reads the workspace dependency graph and skips packages whose inputs have not changed, which is what turns a thirty-minute pipeline into a two-minute one on a typical change.
// turbo.json
{
"tasks": {
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**", "vitest.config.ts", "package.json"],
"outputs": ["coverage/**"]
}
}
}
turbo run test --filter='...[origin/main]' # changed packages and their dependents
Step 5 — Keep coverage per package and merge it deliberately. A single repository-wide coverage number in a monorepo is close to meaningless, because it averages a heavily tested domain package with a thin adapter. Collect per package, enforce per package, and merge only for reporting.
// packages/ui/vitest.config.ts
export default defineConfig({
test: {
name: 'ui',
coverage: {
provider: 'v8',
reporter: ['text', 'json'],
reportsDirectory: './coverage',
thresholds: { lines: 85, functions: 85, branches: 75 },
},
},
});
The dependsOn: ["^build"] entry is worth understanding rather than copying. It says a package’s tests require its dependencies to have been built first, which is correct when tests resolve to build output and unnecessary — and slow — when they resolve to source. Repositories that adopt source resolution and leave this entry in place pay for a full build before every test run and often conclude that the task runner is slow, when it is doing exactly what they asked.
Configuration Reference Table
| Option | Type | Default | Effect |
|---|---|---|---|
vitest.workspace.ts |
file | none | Enables project mode; each entry gets its own environment and setup. |
test.name |
string | directory | Labels a project in reports — essential once there are more than three. |
resolve.conditions |
string[] | env default | Chooses source or built entry points for workspace dependencies. |
test.pool |
enum | forks |
threads is faster for pure logic; forks is safer for native modules. |
turbo.tasks.test.inputs |
string[] | all files | Defines what invalidates a cached result; too broad means no cache hits. |
--filter='...[ref]' |
CLI | none | Selects changed packages plus their dependents. |
test.coverage.thresholds |
object | none | Per-project thresholds; far more meaningful than a repository-wide number. |
test.maxWorkers |
number | cores | Needs dividing by the number of packages running concurrently. |
Two defaults in that table are worth changing early rather than late. test.pool defaults to forks, which is the safe choice, but a workspace where most packages are pure TypeScript logic will run noticeably faster on threads, and the packages that genuinely need process isolation can override it individually. And maxWorkers left at its default means each package assumes it owns the machine, which is precisely wrong when four packages start at once.
Verification & Assertions
Verify three properties, because each fails in a different and quiet way. First, that a package’s tests genuinely pass in isolation — a package that only passes as part of the full run has an undeclared dependency.
pnpm --filter @acme/ui test # must pass entirely on its own
Second, that the affected-only selection includes everything it should. Change a shared package, print the selected set, and read it: if a known consumer is missing, the graph is incomplete and the optimisation is silently skipping real work.
turbo run test --filter='...[origin/main]' --dry=json | jq -r '.tasks[].package'
# @acme/ui
# @acme/web
# @acme/admin ← all three expected; a missing one means an implicit dependency
Third, that source resolution has not hidden a packaging error. A build-resolved run before release is the only thing that catches an export map that omits a subpath, and it is worth one job in the release pipeline.
pnpm -r build && pnpm -r --filter './packages/*' test:built
A fourth check is worth adding once the repository is large enough that nobody holds the whole graph in their head: assert that every package has tests at all. A package added six months ago with a test script that exits zero because no files matched is invisible in every report, passes every gate, and is discovered only when it breaks. A short script that lists packages whose test run collected zero files costs nothing and finds these immediately.
Edge Cases & Failure Modes
Implicit dependencies invisible to the graph. A package that reads a sibling’s generated file through a relative path, or imports it through a TypeScript path alias not declared in package.json, is not in the graph. Affected-only runs will skip it, and the failure appears later on an unrelated commit. Diagnose by running the full suite nightly and comparing results; fix by declaring the dependency properly, even if only as a dev dependency.
Cache hits that should have been misses. If inputs omits a file that genuinely affects the result — a root-level setup file, a shared TypeScript configuration — a cached pass will be replayed for code that changed. Diagnose by editing that file and confirming the task re-runs; fix by adding it to inputs or to a global dependency list.
Worker over-subscription. Each package’s Vitest run defaults to using all cores, and the task runner may start several packages at once, so a four-core runner can find itself hosting twenty workers. Diagnose by watching wall clock rise as parallelism rises; fix by capping maxWorkers per project and concurrency in the task runner together.
A shared test utility package that nothing tests. Because it is only used by tests, it is easy to leave it uncovered — and a bug in a shared render helper produces confusing failures in every consumer at once. Treat it as production code, with its own tests, as described in versioning test utilities as an internal package.
Version skew between the root and a package. A workspace that installs Vitest at the root and again in two packages can end up running three versions, with subtly different behaviour around mocking or module resolution. Diagnose with the lockfile rather than by reading package.json files; fix by declaring the runner once at the root and letting packages depend on it through the workspace protocol.
Tests that pass only because a sibling ran first. Running packages in parallel usually surfaces this immediately, but a package that seeds a shared database during its tests can leave state that another package’s tests then depend on. Diagnose by running each package alone in a loop; fix at the source, because the ordering is not something the task runner will preserve.
Performance & CI Impact
The headline number in a monorepo is not how long the tests take but how often they have to run. A repository of twenty packages where a typical change touches one leaf package should be running one package’s tests on most commits, which is seconds. The pipeline’s job is to make that the common case and to make the rare full run — after a shared dependency changes, or nightly — genuinely complete.
Caching compounds this. A remote cache shared between CI and developers means the second person to check out a branch pays nothing for packages the first person already tested, which in a busy repository is most of them. The cost is cache correctness, which is why the input declarations from Step 4 deserve more attention than they usually get.
Parallelism is where monorepos most often get slower by trying to be faster. Two levels exist — packages running concurrently, and workers inside each package — and multiplying them past the core count produces contention that shows up as timing flakiness rather than as honest slowness. Cap both explicitly, and measure wall clock rather than assuming, following the same discipline as sharding Vitest across GitHub Actions runners.
Finally, be honest about what the numbers mean when reporting them. “The test suite takes ninety seconds” in a monorepo usually means “ninety seconds for the packages that were not cached”, and that figure moves for reasons unrelated to test quality — a shared dependency bump invalidates everything and the same commit that took ten seconds yesterday takes eight minutes today. Report both the cached and the cold figures, and watch the cold one, since it is the number that tells you whether the suite is actually growing out of hand.
In-Depth Guides
- Wiring Vitest workspace projects in a pnpm monorepo — the root workspace file and per-package configurations in full.
- Testing shared packages without publishing — source resolution, export conditions, and a build-resolved release check.
- Caching test results with Turborepo remote cache — declaring inputs correctly and sharing hits across CI and laptops.
- Versioning test utilities as an internal package — one render helper, tested, for the whole workspace.
Related
- Back to Modern JavaScript Test Strategy & Pyramid Design
- Running only tests affected by a change — the selection mechanics in a single repository.
- Enforcing coverage thresholds in a monorepo — per-package thresholds in practice.
- Sharing a Vitest config across a Turborepo — the configuration side of the same problem.
Wiring Vitest Workspace Projects in a pnpm Monorepo
Set up Vitest projects across pnpm packages: per-project environments, shared setup without a global blob, source aliases and readable output.
Testing Shared Packages Without Publishing
Verify a workspace package as consumers use it: source resolution for speed, a built-artifact job for packaging errors, and export map checks.
Caching Test Results With Turborepo Remote Cache
Share test results between CI and laptops safely: declare inputs so cache hits are correct, handle env vars, and prove a cached pass is not stale.
Versioning Test Utilities as an Internal Package
Stop copying render helpers between packages: a workspace test-utils package with its own tests, a stable API and a clear migration path.