Sharing Test Fixtures Across a Vitest Workspace

In a monorepo with several packages, the same test setup gets rebuilt in every corner — one package hand-rolls a seeded user, another copy-pastes it, a third subtly diverges — until “the test user” means three different things and no one can trace which. A Vitest workspace lets multiple projects share one runner configuration, and Vitest’s test.extend fixtures let you share setup the same way: define a fixture once, expose it as a typed test object, and consume it across projects with automatic teardown. This guide is for engineers running a Vitest 3.x workspace in TypeScript who want reusable fixtures that stay type-safe and discoverable without creating hidden coupling between packages. We build a shared fixture module, compose fixtures on top of each other, scope them correctly, and wire them into a workspace so every project imports the same source of truth. It extends the factory patterns from factory functions vs. fixtures in Vitest to the whole repository.

Root Cause Analysis

Duplicated test setup is not a cosmetic problem; it is a correctness problem. When each package builds its own “seeded database” or “authenticated user”, the definitions drift, and a change to the domain that should update one place instead requires finding and editing every copy — which never happens completely, so some tests quietly assert against stale shapes. The root cause is that setup logic has no single owner. A shared fixture gives it one: the fixture module becomes the canonical definition of “a seeded user in a transaction”, and every project consumes that definition rather than re-deriving it.

The failure to avoid while consolidating is hidden coupling. A shared fixture that reaches into global mutable state, or that one project mutates in a way another depends on, reintroduces the order-dependence that fixtures are supposed to eliminate — now spread across package boundaries where it is even harder to see. Vitest’s test.extend is designed to prevent this: each fixture is created lazily per test that requests it and torn down after, so there is no shared instance to mutate. The discipline is to keep fixtures constructive (they build fresh state via factories) rather than referential (they hand out a shared object), which is the same builders-versus-shared-reference distinction that governs the whole test pyramid strategy. Done right, a shared fixture is a shared recipe, not a shared object.

One fixture module consumed by many workspace projects A single shared fixtures module is the canonical source that the api, web, and worker projects each import, replacing duplicated per-project setup. shared/fixtures.ts canonical test.extend api project imports test web project imports test worker project imports test one recipe, fresh instance per test
Every project imports one canonical fixture module instead of duplicating setup.

Reproducible Setup

Create a workspace with a shared package and two consuming projects. Vitest reads the workspace definition to run all projects under one config.

npm install -D vitest typescript
// vitest.workspace.ts — one runner, several projects
export default [
  './packages/api/vitest.config.ts',
  './packages/web/vitest.config.ts',
];
// packages/shared/src/types.ts
export interface User {
  id: string;
  email: string;
  role: 'member' | 'admin';
  createdAt: Date;
}

Implementation

Step 1 — Define the canonical fixtures with test.extend in a shared module. Each fixture builds fresh state from a factory and yields it via use; anything after use is teardown. Type the fixtures so consumers get full inference.

// packages/shared/src/fixtures.ts
import { test as base } from 'vitest';
import type { User } from './types';

let seq = 0;
function makeUser(overrides: Partial<User> = {}): User {
  seq += 1;
  return {
    id: `user-${seq}`,
    email: `user-${seq}@example.test`,
    role: 'member',
    createdAt: new Date('2026-01-01T00:00:00Z'),
    ...overrides,
  };
}

export interface Fixtures {
  user: User;
  admin: User;
}

export const test = base.extend<Fixtures>({
  user: async ({}, use) => {
    await use(makeUser());
  },
  admin: async ({}, use) => {
    await use(makeUser({ role: 'admin' }));
  },
});

Step 2 — Compose fixtures so one can depend on another. A fixture receives already-resolved fixtures in its first argument, so a higher-level fixture can build on a lower-level one without the consumer wiring them together.

// packages/shared/src/fixtures.ts (composed)
import type { User } from './types';

export interface Fixtures {
  user: User;
  admin: User;
  session: { token: string; user: User };
}

export const test = base.extend<Fixtures>({
  user: async ({}, use) => { await use(makeUser()); },
  admin: async ({}, use) => { await use(makeUser({ role: 'admin' })); },
  session: async ({ user }, use) => {
    // depends on the resolved user fixture
    await use({ token: `tok-${user.id}`, user });
  },
});
Fixture dependency graph resolved by Vitest The session fixture depends on the user fixture, so requesting session in a test transparently resolves user first without the consumer wiring them. Fixtures resolve their own dependencies user built from factory session depends on user test body requests session Vitest resolves user before session automatically
Composed fixtures form a dependency graph Vitest resolves for you.

Step 3 — Add a fixture with real teardown. A fixture that opens a resource — a database transaction, a temp directory — must release it. Code after use runs once the test completes, pass or fail, giving deterministic cleanup that pairs with resetting state between tests without slowing CI.

// packages/shared/src/db-fixture.ts
import { test as base } from 'vitest';
import { pool } from './db';

export const test = base.extend<{ tx: typeof pool }>({
  tx: async ({}, use) => {
    await pool.query('BEGIN');
    await use(pool);           // test runs here
    await pool.query('ROLLBACK'); // teardown, always runs
  },
});

Step 4 — Consume the shared fixtures from any project. A consuming project imports test from the shared package instead of from vitest, and the injected, typed fixtures are available with no local setup.

// packages/api/src/user.test.ts
import { expect } from 'vitest';
import { test } from '@repo/shared/fixtures';

test('an admin can be distinguished from a member', ({ admin, user, session }) => {
  expect(admin.role).toBe('admin');
  expect(user.role).toBe('member');
  expect(session.user.id).toBe(user.id);
});

Step 5 — Scope expensive fixtures per worker. A fixture that provisions something costly — a database schema, a server — should not rebuild per test. Give it { scope: 'worker' } so it is created once per worker and shared across that worker’s tests, while cheap data fixtures stay per-test.

// packages/shared/src/server-fixture.ts
import { test as base } from 'vitest';
import { startServer } from './server';

export const test = base.extend<{ baseUrl: string }>({
  baseUrl: [
    async ({}, use) => {
      const server = await startServer();
      await use(server.url);
      await server.close();
    },
    { scope: 'worker' }, // one server per worker, not per test
  ],
});
Fixture lifecycle around the use boundary Code before use is setup, the test body runs at the use call, and code after use is teardown that runs whether the test passes or fails. setup build state via factory await use(value) test body runs here teardown always runs, pass or fail per-test scope by default; worker scope for costly resources
The use boundary splits a fixture into setup, the test body, and guaranteed teardown.

Verification

Prove the shared fixtures are isolated (no cross-test leakage) and typed (a wrong shape fails compilation). Isolation is checked by mutating a fixture in one test and confirming a fresh instance in the next; typing is checked by the build itself.

// packages/api/src/isolation.test.ts
import { expect } from 'vitest';
import { test } from '@repo/shared/fixtures';

test('mutating a fixture does not leak', ({ user }) => {
  user.role = 'admin';
  expect(user.role).toBe('admin'); // local mutation only
});

test('the next test gets a fresh member', ({ user }) => {
  expect(user.role).toBe('member'); // not polluted by the previous test
});

Run npx vitest run across the whole workspace and confirm every project’s tests pass with the shared fixtures. Then run with --sequence.shuffle; because each fixture yields a fresh instance, order cannot matter, which is the proof that the shared module is a recipe and not a shared object. Confirm typing by changing a fixture’s declared type and observing that tsc --noEmit fails at the consumer — the shared contract is enforced at compile time, not discovered at runtime.

Troubleshooting

Symptom: injected fixtures are undefined in a consuming project. Diagnosis: the test file imported test from vitest rather than from the shared fixtures module, so the extended fixtures were never registered for it. Fix: import test from @repo/shared/fixtures everywhere you rely on the shared fixtures, and never mix that import with a bare vitest import in the same file.

Symptom: an expensive fixture rebuilds for every test and the suite crawls. Diagnosis: a costly resource is declared with default per-test scope, so it is provisioned and torn down hundreds of times. Fix: declare it with { scope: 'worker' } so it is created once per worker and reused; keep only cheap data fixtures at per-test scope where fresh instances matter.

Symptom: a fixture’s teardown does not run after a failing test. Diagnosis: the cleanup was placed after an await use() that threw because setup itself failed, or the cleanup was written outside the fixture. Fix: put all release logic after use inside the fixture body — Vitest runs it whether the test passes or throws — and ensure setup errors are surfaced rather than swallowed so the fixture fails loudly instead of leaking a resource.

FAQ

How is a workspace fixture different from a global setup file?

A setupFiles module runs side-effecting code before tests and cannot hand a typed value to the test body; a test.extend fixture provides a named, typed value that the test requests by destructuring, with per-test lifecycle and teardown. Use setup files for ambient concerns like seeding faker or registering matchers, and use fixtures for anything a test consumes as a value. In a workspace, fixtures shine because the typed value crosses project boundaries with full inference.

Can different projects extend the same base fixtures differently?

Yes. A project can import the shared test and call .extend again to add project-specific fixtures on top, composing the shared contract with local needs. The shared fixtures remain the common base, and each project layers what only it requires. This keeps the canonical definitions in one place while still allowing per-project specialisation, without any project mutating another’s setup.

Do worker-scoped fixtures break isolation?

Not if you use them for read-only or self-resetting resources. A worker-scoped server or schema is shared across a worker’s tests, so it must not accumulate per-test state that another test would see. Pair a worker-scoped resource with a per-test reset — a transaction rollback or handler reset — so the expensive thing is shared but each test still starts clean. That combination gives you both throughput and isolation.

Where should the shared fixtures module live?

In a dedicated shared package that consuming projects depend on explicitly, so the dependency is visible in each project’s manifest rather than reaching across the repo by relative path. Export the extended test and its Fixtures type from a stable entry point. Making the dependency explicit is what keeps the fixtures discoverable and prevents the copy-paste drift that motivated consolidating them in the first place.