Sharing a Vitest Config Across a Turborepo

Copy-pasting vitest.config.ts into a dozen workspace packages guarantees drift: one package forgets a coverage threshold, another targets the wrong environment, and a third silently runs without your setup file. This guide is for platform engineers and tech leads maintaining a Turborepo (Turborepo 2, pnpm workspaces) who want a single source of truth for Vitest configuration and setup that every package extends, plus a cached test task so CI only re-runs the packages that actually changed. The result is one base config, thin per-package overrides, and incremental test execution.

Root Cause Analysis

Duplicated test configuration fails for the same reason any copy-pasted infrastructure fails: there is no enforcement that the copies stay identical. When a new lint rule, coverage gate, or alias convention lands, someone has to edit N files, and the diff that misses one package passes review unnoticed. The cost compounds in CI. Without a shared base and a cached task graph, every push runs the entire test suite across every package, so a one-line change in a leaf utility re-executes thousands of unrelated tests. Turborepo solves the execution half by hashing inputs and caching outputs, but it can only skip a package’s test task if that task’s inputs are declared precisely — and a sprawling, per-package config makes those inputs hard to pin down. The fix is two-sided: centralize the configuration into an installable package so drift becomes impossible, then declare the test task’s inputs and outputs so Turborepo can cache it safely.

There is a second, less obvious failure mode worth naming, because it is the one that erodes trust in caching: a too-broad input set. If the test task hashes the entire package directory, then editing a README, a Storybook story, or a generated lockfile fragment invalidates the cache and re-runs tests that could not possibly have changed behavior. Teams that hit this often conclude “Turborepo caching doesn’t work for us” and disable it, when the real problem is an imprecise inputs array. The opposite error — too-narrow inputs — is more dangerous: if you forget to list vitest.setup.ts, a change to global test setup will not bust the cache, and Turborepo will happily restore a stale “pass” for tests that should now fail. Correct caching therefore depends on the input declaration being neither broader nor narrower than the set of files Vitest actually reads. Centralizing the config into a workspace package makes this tractable, because the dependency graph edge from a consumer to @repo/config-vitest lets Turborepo invalidate every consumer automatically when the shared config changes — something a loose relative-path import into a sibling folder cannot express, since Turborepo tracks dependencies, not arbitrary file references.

The choice of where configuration lives is itself architectural. A config that is a published workspace package participates in the task graph: it has a name, a version, and explicit dependents. A config that is a bare file at ../../config/vitest.base.ts is invisible to the graph — Turborepo sees only the consumer’s own files and cannot know that editing the shared file should ripple outward. This is why the package boundary is not bureaucratic overhead but the mechanism that makes incremental, correct caching possible across the whole repository, which is the same incremental-testing principle explored in balancing speed and coverage in monorepo testing.

Reproducible Setup

Assume a standard pnpm-based Turborepo:

my-repo/
├─ turbo.json
├─ pnpm-workspace.yaml
├─ packages/
│  ├─ config-vitest/        # the shared base config (new)
│  ├─ ui/
│  └─ utils/
└─ apps/
   └─ web/

Declare the workspace globs:

# pnpm-workspace.yaml
packages:
  - "apps/*"
  - "packages/*"

Each consumer needs Vitest installed; the shared package owns the configuration logic but lists Vitest as a peer dependency so versions stay aligned across the repo.

The relationships between the base package, its consumers, and the cached task look like this:

Shared Vitest config dependency graph in a Turborepo One base config package feeds the ui, utils, and web packages; Turborepo hashes each package's test task independently and restores unchanged packages from cache. @repo/config-vitest base + factory packages/utils packages/ui apps/web test (cache miss) test (cached) test (cached)

Implementation

Step 1 — Create the shared base config package. It exports a factory so callers can merge overrides rather than fork the file.

// packages/config-vitest/package.json
{
  "name": "@repo/config-vitest",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": { ".": "./base.ts" },
  "peerDependencies": { "vitest": "^2.0.0" }
}
// packages/config-vitest/base.ts
import { defineConfig, mergeConfig, type UserConfig } from 'vitest/config';

export const baseConfig = defineConfig({
  test: {
    globals: false,
    environment: 'node',
    include: ['src/**/*.test.{ts,tsx}'],
    exclude: ['node_modules', 'dist'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary'],
      thresholds: { lines: 85, branches: 80, functions: 85, statements: 85 },
    },
  },
});

// Factory: callers pass only what differs from the base
export function createVitestConfig(overrides: UserConfig = {}) {
  return mergeConfig(baseConfig, defineConfig(overrides));
}

Step 2 — Consume the base in a package, applying overrides. A pure-logic package needs almost nothing; a UI package switches to jsdom and adds a setup file.

// packages/utils/vitest.config.ts
import { baseConfig } from '@repo/config-vitest';
export default baseConfig;
// packages/ui/vitest.config.ts
import { createVitestConfig } from '@repo/config-vitest';
import react from '@vitejs/plugin-react';

export default createVitestConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
  },
});

Add the dependency in each consumer:

// packages/ui/package.json (excerpt)
{
  "devDependencies": {
    "@repo/config-vitest": "workspace:*",
    "@vitejs/plugin-react": "^4.3.0",
    "vitest": "^2.0.0"
  },
  "scripts": { "test": "vitest run" }
}

A common refinement is to expose more than one preset from the shared package — a node base for libraries and a dom base for component packages — so consumers pick an intent rather than re-specifying environment and setupFiles each time. The factory makes this trivial because each preset is just a pre-merged config:

// packages/config-vitest/base.ts (extended)
import { defineConfig, mergeConfig, type UserConfig } from 'vitest/config';

export const baseConfig = defineConfig({
  test: {
    globals: false,
    environment: 'node',
    include: ['src/**/*.test.{ts,tsx}'],
    exclude: ['node_modules', 'dist'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json-summary'],
      thresholds: { lines: 85, branches: 80, functions: 85, statements: 85 },
    },
  },
});

export function createVitestConfig(overrides: UserConfig = {}) {
  return mergeConfig(baseConfig, defineConfig(overrides));
}

// A DOM preset that component packages can extend with a single import.
export function createDomConfig(overrides: UserConfig = {}) {
  return createVitestConfig(
    mergeConfig(
      defineConfig({
        test: {
          environment: 'jsdom',
          setupFiles: ['@repo/config-vitest/setup'],
        },
      }),
      defineConfig(overrides),
    ),
  );
}

Ship the shared setup file from the same package so every DOM consumer registers identical matchers and cleanup, and there is no per-package vitest.setup.ts to drift:

// packages/config-vitest/setup.ts
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => cleanup());

Then a component package’s config collapses to a single line of intent:

// packages/ui/vitest.config.ts
import { createDomConfig } from '@repo/config-vitest';
import react from '@vitejs/plugin-react';

export default createDomConfig({ plugins: [react()] });

Remember to add the new export paths to the package’s exports map ("./setup": "./setup.ts") so the specifier resolves. Because Vitest transforms config files with esbuild, you can ship these as .ts and skip a build step for the config package entirely — one of the few places in a Turborepo where leaving a package unbuilt is the right call.

Step 3 — Optionally collapse packages into Vitest projects. If you prefer one command at the root over per-package runs, Vitest’s projects field (formerly workspace) discovers each config and runs them under one process while still honoring per-package overrides.

// vitest.config.ts (repo root)
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    projects: ['packages/*', 'apps/*'],
  },
});

Use this for local “run everything” ergonomics; in CI, prefer the per-package task so Turborepo can cache and skip unchanged packages.

Step 4 — Define and cache the test task in Turborepo. Declare inputs so the hash only changes when test-relevant files change, and declare outputs so coverage is restored from cache on a hit.

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "test": {
      "dependsOn": ["^build"],
      "inputs": [
        "src/**",
        "vitest.config.ts",
        "vitest.setup.ts",
        "package.json"
      ],
      "outputs": ["coverage/**"]
    }
  }
}

Run it from the root; Turborepo executes each package’s test script in dependency order and caches the result:

turbo run test

A change confined to packages/utils re-runs only that package’s tests on the next invocation — every other package is restored from cache.

The exact inputs set is the most consequential choice in this file, so it is worth being deliberate about each entry:

Input glob Why it is included What breaks if you omit it
src/** The source and test files Vitest reads Code changes restore a stale pass
vitest.config.ts The per-package config and its overrides Switching environment or thresholds is ignored
vitest.setup.ts Global matchers and lifecycle hooks A setup change leaves stale results cached
package.json The test script and dependency ranges A script edit is not detected
$TURBO_DEFAULT$ Implicit default inputs (when you extend rather than replace) You lose Turborepo’s sensible defaults

When you specify inputs explicitly, Turborepo replaces its defaults rather than adding to them. To keep the defaults and add your own, prepend the $TURBO_DEFAULT$ token: "inputs": ["$TURBO_DEFAULT$", "vitest.setup.ts"]. This is the safest form because it preserves Turborepo’s built-in awareness of files like tsconfig.json while still pinning the test-specific extras.

For CI, pair the local cache with a remote cache so a teammate’s or a previous job’s results are reused across machines. Turborepo restores any task whose hash matches a remote artifact, which turns a green build on main into instant cache hits for every downstream branch that did not touch those packages:

# CI: only test what changed against the base branch, using the remote cache
turbo run test --filter='...[origin/main]'

The ...[origin/main] filter scopes execution to packages changed since main plus everything that depends on them, while the remote cache handles the rest. Together they keep monorepo CI proportional to the size of the change, not the size of the repo — the core economic argument behind a deliberate test pyramid strategy.

Verification

Run the task twice. The first run is a full execution; the second, with no source changes, should be near-instant and report cache hits.

turbo run test          # cold: executes every package
turbo run test          # warm: FULL TURBO — all tasks from cache

Expected warm-run summary:

 Tasks:    4 successful, 4 total
Cached:    4 cached, 4 total
  Time:    180ms >>> FULL TURBO

Now touch one file and confirm scoping:

echo "// touch" >> packages/utils/src/index.ts
turbo run test

Only @repo/config-vitest’s downstream consumer — here packages/utils — should miss the cache; the UI and web packages stay cached. A passing mergeConfig is confirmed by running vitest run inside packages/ui and seeing both the inherited 85% line threshold and the jsdom environment in the output. This per-package incrementality is the practical payoff discussed in balancing speed and coverage in monorepo testing.

To verify the inheritance itself rather than just the cache behavior, resolve the merged config programmatically and assert on it. This catches a silent shallow-merge regression long before it reaches CI:

// packages/config-vitest/base.test.ts
import { describe, it, expect } from 'vitest';
import { createDomConfig } from './base';

describe('shared config', () => {
  it('keeps base coverage thresholds when overriding the environment', () => {
    const merged = createDomConfig({ test: { environment: 'jsdom' } });
    expect(merged.test?.environment).toBe('jsdom');
    expect(merged.test?.coverage).toMatchObject({
      thresholds: { lines: 85 },
    });
  });

  it('concatenates setupFiles rather than replacing them', () => {
    const merged = createDomConfig({ test: { setupFiles: ['./extra.ts'] } });
    expect(merged.test?.setupFiles).toContain('./extra.ts');
    expect(merged.test?.setupFiles).toContain('@repo/config-vitest/setup');
  });
});

The second assertion is the one that earns its keep: mergeConfig concatenates array fields like setupFiles and plugins, so a consumer adding its own setup file should extend, not replace, the shared one. If that test ever fails, someone has reintroduced a manual spread merge somewhere in the chain. Finally, confirm the cache restores coverage artifacts by deleting a package’s local coverage/ directory and re-running turbo run test --filter=ui: on a cache hit Turborepo recreates coverage/ from the cached outputs, proving the outputs declaration is correct and that downstream coverage-merge steps will find their inputs.

Troubleshooting

Cache never hits even with no changes. An input is varying between runs — usually an absolute path baked into the config or a generated file inside src/**. Narrow inputs to the files Vitest actually reads, and move generated artifacts out of the input globs. Run turbo run test --dry=json to inspect the computed hash inputs.

Overrides silently ignored. Spreading objects manually ({ ...base.test, ...overrides }) does a shallow merge and drops nested keys like coverage.thresholds. Always compose with Vitest’s mergeConfig, which deep-merges and concatenates arrays such as plugins and setupFiles correctly.

projects picks up the wrong configs. The root projects glob matches any directory containing a Vitest config, including build output. Exclude dist and node_modules from the glob, or point projects at explicit config paths, so it does not try to run compiled copies of your tests.

Cannot find module '@repo/config-vitest' at config load time. The consumer’s vitest.config.ts resolves before the workspace symlink exists, usually right after a fresh clone where install has not run, or when the shared package’s exports map omits the path you imported. Run the install step first, and verify exports declares every entry point you import (".", "./setup"); a missing export key surfaces as a module-not-found even though the file exists on disk.

Coverage thresholds pass locally but the merged numbers look wrong in CI. Each package reports its own coverage in isolation, so a repo-wide gate needs the per-package json-summary reports merged. Keep reporter: ['text', 'json-summary'] in the base, declare coverage/** in outputs, and run a separate root task that reads each package’s summary — do not expect turbo run test alone to produce a single aggregated number.

Path aliases resolve in one package but not another. Aliases defined in the base config are resolved relative to wherever Vitest runs, so a base-level '@/' alias pointing at ./src/ works only if every consumer has the same layout. Prefer defining aliases in each consumer’s override (where import.meta.url resolves to that package), or pass them as a factory argument so the base does not bake in a single package’s directory shape.

FAQ

Should I use Vitest projects or per-package configs with Turborepo?

Use both, for different jobs. Per-package configs plus a per-package test script let Turborepo hash and cache each package independently, which is what makes CI incremental. The root projects field is a developer-experience convenience for running the whole repo with one command locally. They are not mutually exclusive: keep the per-package configs as the source of truth and have projects reference them.

Why publish the config as a workspace package instead of a shared file path?

A workspace:* package gives you versioning, an explicit dependency edge, and a clean import specifier (@repo/config-vitest) instead of brittle ../../config/vitest.base.ts relative paths that break when a package moves. The dependency edge also lets Turborepo understand that changing the shared config should invalidate every consumer’s test cache, which a loose file reference cannot express.

How do I keep Vitest versions aligned across packages?

List vitest as a peerDependency in the shared config package and pin a single version range there, then let pnpm’s workspace hoisting and a root-level catalog (or a pnpm.overrides entry) enforce one resolved version. A version skew across packages is the most common cause of confusing transform errors, so making it a peer dependency turns a silent mismatch into an install-time warning.

Does this work the same with npm or Yarn workspaces?

The Vitest factory and Turborepo task definition are package-manager agnostic; only the dependency protocol differs. npm and Yarn use "@repo/config-vitest": "*" (npm) or "workspace:*" (Yarn Berry) instead of pnpm’s workspace:*, and hoisting behavior varies. The configuration sharing and caching strategy itself is identical across all three.