· testing / vitest / turborepo

How to configure Vitest in a Turborepo monorepo

Vitest 4.x dropped the workspace option — it is projects now. This guide covers two approaches (per-package caching and Vitest Projects root), shared config, turbo.json wiring, and coverage aggregation.

By

1,207 words · 7 min read

Adding Vitest to a Turborepo monorepo takes about an hour. Most guides get one thing wrong: Vitest 4.0 removed the workspace option. Every tutorial written before October 2025 references it. Use projects instead.

This guide covers the two setups teams actually run: per-package caching for CI and a Vitest Projects root config for local development. The official example repo ships a hybrid of both — we’ll build toward that.

Who this is for

Developers on a pnpm + Turborepo monorepo (or actively setting one up) who want Vitest wired end to end. You should already have a working workspace with at least one testable package. If you’re starting from zero, follow the pnpm + Turborepo setup guide first, then come back here.

The two approaches

Before touching any files, understand what you’re choosing.

Approach A — per-package vitest.config.ts: each package owns its Vitest config and a "test": "vitest run" script. Turborepo caches the test output per package. A change in packages/ui only reruns that package’s tests. Coverage output is isolated per package and needs manual aggregation. This is the better choice for CI.

Approach B — Vitest Projects root: a single vitest.config.ts at the repo root lists every package in a projects array. Vitest merges coverage automatically. Any file change busts the entire cache because Turborepo sees one test task, not many. This is the better choice for local development.

The official with-vitest example ships a hybrid: Approach A scripts in every package’s package.json, but a root config exists for vitest --ui convenience. We’ll do the same.

Step 1: Bootstrap with the official example (optional)

If you’re starting a fresh repo, the quickest path is:

npx create-turbo@latest --example with-vitest my-monorepo
cd my-monorepo
pnpm install

This gives you a working starting point. The rest of this guide explains what it sets up — and how to replicate it in an existing repo.

Step 2: Create a shared Vitest config package

A shared config package means every package’s vitest.config.ts extends one source of truth. Without it, you’ll drift across packages.

Create packages/vitest-config/ with this structure:

packages/vitest-config/
├── package.json
├── tsconfig.json
└── src/
    └── index.ts

packages/vitest-config/package.json:

{
  "name": "@repo/vitest-config",
  "version": "0.0.0",
  "private": true,
  "exports": {
    ".": "./src/index.ts"
  },
  "scripts": {
    "build": "tsc --project tsconfig.json"
  },
  "devDependencies": {
    "typescript": "latest",
    "vitest": "^4.1.7"
  }
}

packages/vitest-config/src/index.ts:

import { defineConfig } from 'vitest/config'

export const defineSharedConfig = () =>
  defineConfig({
    test: {
      environment: 'node',
      globals: true,
      coverage: {
        provider: 'v8',
        reporter: ['text', 'lcov'],
      },
    },
  })

packages/vitest-config/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "declaration": true,
    "outDir": "./dist"
  },
  "include": ["src"]
}

Add it to the workspace root’s pnpm-workspace.yaml if it isn’t there already:

packages:
  - 'apps/*'
  - 'packages/*'

Gotcha: coverage and reporters cannot be configured per-project in Vitest 4.x — they live in the root config only. Put them in the shared config and don’t try to override them per package.

Step 3: Per-package vitest.config.ts

In each package that has tests, add two things.

packages/ui/package.json (add to scripts and devDependencies):

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  },
  "devDependencies": {
    "@repo/vitest-config": "workspace:*",
    "vitest": "^4.1.7"
  }
}

packages/ui/vitest.config.ts:

import { defineSharedConfig } from '@repo/vitest-config'

export default defineSharedConfig()

Critical: the script must be vitest run, not vitest. Running vitest without arguments starts watch mode, which never exits. Turborepo will hang waiting for the task to finish, and your cache hit rate will be zero because a hung task never writes its output.

Step 4: Wire Turborepo

turbo.json at the repo root needs a test task that depends on the shared config being built first.

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["^build", "@repo/vitest-config#build"],
      "outputs": ["coverage/**"],
      "cache": true
    },
    "test:watch": {
      "cache": false,
      "persistent": true
    }
  }
}

The @repo/vitest-config#build dependency ensures the shared config is compiled before any package tries to import it. Skip this and you’ll get import errors on cold installs.

The test:watch task sets "cache": false, "persistent": true. Watch tasks never exit — marking them persistent prevents Turborepo from treating them as failed, and disabling cache prevents Turborepo from trying to record an output that never comes.

Step 5: Run tests from the root

With turbo.json configured, run all tests from the repo root:

pnpm turbo test

First run: all packages execute. Second run with no changes: all packages are cached — output appears instantly.

 Tasks:    6 successful, 6 total
Cached:    6 cached, 6 total
  Time:    312ms >>> FULL TURBO

Run tests for a single package:

pnpm turbo test --filter=@repo/ui

Watch mode for active development:

pnpm turbo test:watch --filter=@repo/ui

Step 6: Root Vitest Projects config (optional — for local convenience)

A root vitest.config.ts lets you run all tests in a single process and get merged coverage without a separate aggregation step. This is what the official example ships for local use.

vitest.config.ts at the repo root:

import { defineConfig } from 'vitest/config'
import { glob } from 'glob'
import path from 'path'

const packages = glob.sync('packages/*/vitest.config.ts').map((config) =>
  path.resolve(config)
)

export default defineConfig({
  test: {
    projects: packages,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
    },
  },
})

Running vitest at the root with this config discovers every package’s config through the projects array. Coverage merges automatically. The tradeoff: one test run covers everything, so Turborepo caches it as one unit. Any file change reruns every test. That’s fine locally. In CI, rely on the per-package turbo test command instead.

Step 7: Coverage aggregation in CI

If you’re on Approach A (per-package caching) and you want a merged coverage report, you need an extra step after turbo test.

Each package writes coverage to its own packages/<name>/coverage/. Merge them with nyc:

pnpm add -Dw nyc

# After turbo test:
nyc merge packages/*/coverage/coverage-final.json coverage/merged.json
nyc report --reporter=text --reporter=lcov --temp-dir coverage

Wire this in CI after the turbo test step. Don’t add it to turbo.json — it needs all package outputs to exist first, and it’s fast enough to run unconditionally.

Remote caching for distributed CI

The cache Turborepo builds locally is invisible to other machines unless you enable remote caching. Vercel Remote Cache is free for all plans — link your repo and the cache automatically syncs across machines:

npx turbo login
npx turbo link

After linking, a cold CI runner that shares no local state with your dev machine will still get cache hits for unchanged packages. For a monorepo with 8–10 packages, cache hits on unchanged paths complete in seconds rather than minutes.

Failure modes summary

SymptomCauseFix
vitest hangs in Turborepowatch mode in test scriptChange to vitest run
Cannot find module '@repo/vitest-config'shared config not builtAdd @repo/vitest-config#build to dependsOn
Coverage config ignored per-packageVitest 4 limitationSet coverage in shared root config
workspace option not recognizedremoved in Vitest 4.0Use projects
CI cache miss every runtest:watch has cache: trueSet cache: false, persistent: true