pnpm Workspaces Testing: Shared Test Utilities & Cross-Package Testing

pnpm Workspaces Testing: Shared Test Utilities & Cross-Package Testing

pnpm workspaces give you a fast, disk-efficient monorepo foundation, but the testing story requires deliberate setup. Out of the box, you can run pnpm --filter <package> test to execute tests in a specific package, but that's only the beginning. The real value comes from shared test utilities — factory functions, database seeders, mock servers — that live in their own workspace package and get consumed consistently across your entire repo.

This guide covers how to structure those shared utilities, how to configure Vitest for workspace-wide test runs, how to handle TypeScript path aliases across packages, and the subtleties of cross-package dependency testing.

The Core Problem: Duplicated Test Infrastructure

Without shared test utilities, every package ends up with its own version of:

  • User factory functions (createUser({ email: '...' }))
  • Database seeders
  • Mock API server setup
  • Custom assertion helpers
  • Test data constants

These diverge over time. The User shape in packages/auth's factory doesn't match the one in packages/api. You change the schema and hunt down 8 different factory implementations. You add a new required field and half the tests break in different ways.

The solution is a @yourorg/test-fixtures package — a workspace-private package that owns all shared test infrastructure and is imported by every other package's tests.

Setting Up pnpm Workspaces

Your pnpm-workspace.yaml defines which directories are packages:

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

And a minimal root package.json:

{
  "name": "my-monorepo",
  "private": true,
  "engines": {
    "pnpm": ">=8.0.0",
    "node": ">=20.0.0"
  },
  "scripts": {
    "test": "pnpm -r run test",
    "test:unit": "pnpm -r run test:unit",
    "test:watch": "pnpm --filter '...' run test:watch"
  }
}

Creating the Shared Test Fixtures Package

Create the package at packages/test-fixtures/:

packages/test-fixtures/
├── package.json
├── tsconfig.json
├── src/
│   ├── index.ts
│   ├── factories/
│   │   ├── user.factory.ts
│   │   ├── order.factory.ts
│   │   └── product.factory.ts
│   ├── db/
│   │   ├── seed.ts
│   │   └── reset.ts
│   ├── mocks/
│   │   ├── api-server.ts
│   │   └── handlers/
│   │       ├── auth.handlers.ts
│   │       └── orders.handlers.ts
│   └── helpers/
│       ├── assertions.ts
│       └── wait-for.ts

The package.json for the fixtures package:

{
  "name": "@myorg/test-fixtures",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "default": "./src/index.ts"
    },
    "./factories": {
      "types": "./src/factories/index.ts",
      "default": "./src/factories/index.ts"
    },
    "./mocks": {
      "types": "./src/mocks/index.ts",
      "default": "./src/mocks/index.ts"
    }
  },
  "dependencies": {
    "@faker-js/faker": "^8.0.0",
    "msw": "^2.0.0"
  },
  "peerDependencies": {
    "vitest": "^1.0.0"
  }
}

Marking it private: true ensures it never gets published to npm. The exports map with subpath exports lets consumers import specific sections without pulling in everything.

Factory Functions

A well-designed factory function supports partial overrides:

// packages/test-fixtures/src/factories/user.factory.ts
import { faker } from '@faker-js/faker';
import type { User } from '@myorg/types';

export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    role: 'member',
    createdAt: new Date(),
    emailVerified: true,
    ...overrides,
  };
}

export function createAdminUser(overrides: Partial<User> = {}): User {
  return createUser({ role: 'admin', ...overrides });
}

export function createUserList(
  count: number,
  overrides: Partial<User> = {}
): User[] {
  return Array.from({ length: count }, () => createUser(overrides));
}

The pattern of accepting Partial<T> overrides is essential. Tests should be able to say exactly what matters:

// In a test
const adminUser = createUser({ role: 'admin', emailVerified: false });

Everything else gets a realistic but irrelevant value from faker.

Database Seeding Utilities

For packages that test against a real database:

// packages/test-fixtures/src/db/seed.ts
import type { PrismaClient } from '@prisma/client';
import { createUser } from '../factories/user.factory';
import { createProduct } from '../factories/product.factory';

export async function seedTestDatabase(prisma: PrismaClient) {
  await prisma.$transaction(async (tx) => {
    const admin = await tx.user.create({
      data: createUser({ role: 'admin' }),
    });

    const products = await Promise.all(
      Array.from({ length: 10 }, () =>
        tx.product.create({ data: createProduct() })
      )
    );

    return { admin, products };
  });
}

export async function resetTestDatabase(prisma: PrismaClient) {
  // Delete in reverse dependency order
  await prisma.$transaction([
    prisma.orderItem.deleteMany(),
    prisma.order.deleteMany(),
    prisma.product.deleteMany(),
    prisma.user.deleteMany(),
  ]);
}

Mock Server Factories

Using MSW for HTTP mocking:

// packages/test-fixtures/src/mocks/api-server.ts
import { setupServer } from 'msw/node';
import { authHandlers } from './handlers/auth.handlers';
import { ordersHandlers } from './handlers/orders.handlers';

export const defaultHandlers = [...authHandlers, ...ordersHandlers];

export function createMockServer(additionalHandlers = []) {
  return setupServer(...defaultHandlers, ...additionalHandlers);
}

// Usage in tests:
// const server = createMockServer();
// beforeAll(() => server.listen());
// afterEach(() => server.resetHandlers());
// afterAll(() => server.close());

Using the Fixtures Package Across Workspace Packages

Add the fixtures package as a dependency using the workspace:* protocol:

{
  "name": "@myorg/checkout",
  "devDependencies": {
    "@myorg/test-fixtures": "workspace:*"
  }
}

The workspace:* protocol tells pnpm to resolve this from the local workspace rather than npm. Running pnpm install creates a symlink in node_modules/@myorg/test-fixtures pointing to your local packages/test-fixtures/ directory.

Now in your tests:

// packages/checkout/src/checkout.service.test.ts
import { createUser, createProduct } from '@myorg/test-fixtures/factories';
import { createMockServer } from '@myorg/test-fixtures/mocks';
import { CheckoutService } from './checkout.service';

const server = createMockServer();

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('CheckoutService', () => {
  it('creates an order for a verified user', async () => {
    const user = createUser({ emailVerified: true });
    const product = createProduct({ stock: 5 });

    const service = new CheckoutService();
    const order = await service.createOrder({ user, items: [{ product, quantity: 1 }] });

    expect(order.status).toBe('pending');
    expect(order.userId).toBe(user.id);
  });
});

Configuring Vitest for Workspaces

Vitest has first-class workspace support via a vitest.workspace.ts file at the root:

// vitest.workspace.ts
import { defineWorkspace } from 'vitest/config';

export default defineWorkspace([
  // Include all packages with a vitest.config.ts
  'packages/*/vitest.config.ts',
  'apps/*/vitest.config.ts',
  // Or inline configurations
  {
    test: {
      name: 'unit',
      include: ['packages/*/src/**/*.test.ts'],
      exclude: ['**/node_modules/**', '**/e2e/**'],
    },
  },
]);

Running the workspace suite:

# Run all packages
vitest

# Run in watch mode
vitest --watch

# Run with UI
vitest --ui

# Run specific workspace projects
vitest --project=checkout --project=auth

Per-Package Vitest Config

Each package gets its own vitest.config.ts for package-specific settings:

// packages/checkout/vitest.config.ts
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';

export default defineConfig({
  test: {
    name: 'checkout',
    environment: 'node',
    globals: true,
    setupFiles: ['./src/test-setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      include: ['src/**/*.ts'],
      exclude: ['src/**/*.test.ts', 'src/index.ts'],
    },
  },
  resolve: {
    alias: {
      '@myorg/checkout': resolve(__dirname, './src'),
    },
  },
});

TypeScript Path Aliases Across Packages

TypeScript path aliases need coordination between tsconfig.json files to work correctly across package boundaries.

Root tsconfig.base.json

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@myorg/types": ["packages/types/src/index.ts"],
      "@myorg/ui": ["packages/ui/src/index.ts"],
      "@myorg/test-fixtures": ["packages/test-fixtures/src/index.ts"],
      "@myorg/test-fixtures/*": ["packages/test-fixtures/src/*"]
    },
    "strict": true,
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "target": "ES2022"
  }
}

Per-Package tsconfig.json

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src/**/*.ts"],
  "exclude": ["node_modules", "dist"]
}

The extends chain means every package automatically gets the path aliases defined at the root. When @myorg/test-fixtures is imported in any package's test, TypeScript resolves it to the local source directory without needing a build step.

Handling Peer Dependencies in Tests

The fixtures package declares vitest as a peer dependency, not a direct dependency:

{
  "peerDependencies": {
    "vitest": "^1.0.0"
  },
  "peerDependenciesMeta": {
    "vitest": {
      "optional": true
    }
  }
}

This prevents version conflicts when different packages use different vitest minor versions. The fixtures package works with whatever vitest version the consumer has installed.

Running Tests with pnpm --filter

pnpm's --filter flag is the primary mechanism for scoping test runs:

# Test a specific package
pnpm --filter @myorg/checkout test

# Test a package and all packages that depend on it
pnpm --filter @myorg/checkout... test

# Test all packages that depend on @myorg/ui (useful after changing shared UI)
pnpm --filter ...*@myorg/ui test

# Test packages matching a pattern
pnpm --filter '@myorg/*' test

# Test packages changed since main (requires git)
pnpm --filter '...[origin/main]' test

The ... syntax is the transitive expansion operator — it includes the matched package plus all dependents (or dependencies, depending on direction).

Combining with Scripts

Add targeted scripts to the root package.json for common scenarios:

{
  "scripts": {
    "test": "pnpm -r run test",
    "test:changed": "pnpm --filter '...[origin/main]' run test",
    "test:affected-by-fixtures": "pnpm --filter '...*@myorg/test-fixtures' run test",
    "test:apps": "pnpm --filter './apps/*' run test",
    "test:packages": "pnpm --filter './packages/*' run test"
  }
}

Testing Cross-Package Dependencies

The trickiest testing scenario in a monorepo is when you need to verify that a change in one package doesn't break consumers.

Strategy 1: Test the Consumer

When you change @myorg/ui, run tests in packages that consume it:

pnpm --filter '...*@myorg/ui' run test

This tests all consumers. It's correct but potentially slow if many packages depend on the changed one.

Strategy 2: Integration Tests in the Consumer

Add integration tests directly in consumer packages that test the cross-package contract:

// packages/checkout/src/ui-integration.test.ts
import { render, screen } from '@testing-library/react';
import { CheckoutForm } from '@myorg/ui';

// This test breaks if @myorg/ui changes the CheckoutForm interface
it('renders checkout form with required props', () => {
  render(<CheckoutForm onSubmit={jest.fn()} items={[]} />);
  expect(screen.getByRole('button', { name: /complete order/i })).toBeInTheDocument();
});

These tests live in the consumer and explicitly test the integration boundary. When @myorg/ui changes the CheckoutForm API, this test breaks immediately.

Strategy 3: Shared Contract Tests

For API-level contracts between packages:

// packages/test-fixtures/src/contracts/user-service.contract.ts
import type { UserService } from '@myorg/types';

export function runUserServiceContract(getService: () => UserService) {
  describe('UserService contract', () => {
    it('returns null for unknown user IDs', async () => {
      const service = getService();
      const result = await service.findById('nonexistent-id');
      expect(result).toBeNull();
    });

    it('creates a user with the provided email', async () => {
      const service = getService();
      const user = await service.create({ email: 'test@example.com', name: 'Test' });
      expect(user.email).toBe('test@example.com');
      expect(user.id).toBeDefined();
    });
  });
}

Any package implementing UserService imports and runs this contract test:

// packages/auth/src/auth-user.service.test.ts
import { runUserServiceContract } from '@myorg/test-fixtures/contracts';
import { AuthUserService } from './auth-user.service';

runUserServiceContract(() => new AuthUserService(/* deps */));

Keeping Fixtures in Sync with Schema Changes

The biggest maintenance burden of shared fixtures is keeping them current as schemas evolve. A few practices help:

Use TypeScript types from the source of truth. Factories should import types from @myorg/types — if the type changes and the factory is out of date, TypeScript compilation fails immediately.

Validate factory output against runtime schemas. In a Zod-heavy codebase, run factory output through your Zod schema in tests:

import { UserSchema } from '@myorg/types';

export function createUser(overrides: Partial<User> = {}): User {
  const user = {
    id: faker.string.uuid(),
    // ... rest of fields
    ...overrides,
  };
  // Throws if factory output doesn't match schema
  return UserSchema.parse(user);
}

Run the fixtures package tests on every change to the types package. In your CI or Turborepo/Nx config, make test-fixtures rebuild whenever types changes.

Monitoring Shared Infrastructure

When your shared @myorg/test-fixtures package is used by 30+ other packages, a breaking change in it can cascade into failures across your entire test suite. Monitoring which tests rely on shared fixtures — and whether those tests are consistently healthy — is worth tracking explicitly.

HelpMeTest can run your test suites on a schedule and alert you when shared utilities start causing widespread failures across packages, making it easier to distinguish "this specific test is flaky" from "something in the shared layer broke."

Summary

Shared test fixtures in pnpm workspaces pay dividends proportional to the number of packages in your monorepo. The investment is:

  1. Create @myorg/test-fixtures as a private: true workspace package
  2. Build factory functions that accept Partial<T> overrides
  3. Co-locate database seeders, mock server setup, and custom assertions in the same package
  4. Add it as a devDependency with workspace:* in consuming packages
  5. Configure TypeScript path aliases in tsconfig.base.json so imports resolve without builds

The payoff is a single place to update when your data model changes, consistent test data across all packages, and no more hunting for the 8 different versions of createUser.

Read more

Start now free