pnpm Workspaces Testing: Running Tests Across Packages and Shared Utilities

pnpm Workspaces Testing: Running Tests Across Packages and Shared Utilities

pnpm workspaces provide a lightweight monorepo solution built into the pnpm package manager. Unlike Nx or Turborepo, pnpm workspaces have no build graph awareness or caching — they give you the foundation to run commands across packages, and you layer tooling on top. Understanding how to run tests across packages, share test utilities, and structure your workspace is essential before adding more complex tooling.

Workspace Structure

A pnpm workspace is declared by pnpm-workspace.yaml at the repo root:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
  - 'libs/*'

A typical layout:

my-monorepo/
├── pnpm-workspace.yaml
├── package.json
├── apps/
│   ├── web/
│   │   ├── package.json
│   │   └── src/
│   └── api/
│       ├── package.json
│       └── src/
└── packages/
    ├── ui/
    │   ├── package.json
    │   └── src/
    └── utils/
        ├── package.json
        └── src/

Each package has its own package.json with its own test script. pnpm workspaces wire up the symlinks in node_modules so packages can import each other.

Running Tests Across Packages

--recursive Flag

Run the test script in every workspace package:

# Run tests in all packages
pnpm --recursive run <span class="hljs-built_in">test

<span class="hljs-comment"># Shorthand
pnpm -r run <span class="hljs-built_in">test

<span class="hljs-comment"># In parallel (default is sequential)
pnpm -r --parallel run <span class="hljs-built_in">test

<span class="hljs-comment"># Filter to specific packages
pnpm --filter @myorg/ui run <span class="hljs-built_in">test
pnpm --filter <span class="hljs-string">"./packages/**" run <span class="hljs-built_in">test

The --filter flag is pnpm's primary scoping mechanism. It accepts glob patterns, package names, and change-based selectors.

Change-Based Filtering

Run tests only for packages that changed compared to a branch:

# Test packages changed relative to main
pnpm --filter <span class="hljs-string">"...[main]" run <span class="hljs-built_in">test

<span class="hljs-comment"># Test changed packages and everything that depends on them
pnpm --filter <span class="hljs-string">"...{./packages/utils}..." run <span class="hljs-built_in">test

The [main] syntax compares against main branch. The ... prefix/suffix means "and dependents" / "and dependencies":

  • [main] — exactly the changed packages
  • ...[main] — changed packages plus packages that depend on them (tests upstream consumers)
  • [main]... — changed packages plus their dependencies

Stream Output vs Default

By default, pnpm serializes output per package. Use --stream to interleave output in real time:

pnpm -r --parallel --stream run test

This is useful for watching long test runs across many packages.

Shared Test Utilities

When multiple packages need the same test helpers, fixtures, or mocks, centralise them in a packages/test-utils package.

Create the Shared Package

// packages/test-utils/package.json
{
  "name": "@myorg/test-utils",
  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts",
    "./mocks": "./src/mocks/index.ts",
    "./fixtures": "./src/fixtures/index.ts"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}
// packages/test-utils/src/index.ts
export * from './factories';
export * from './matchers';
export * from './setup';
// packages/test-utils/src/factories.ts
export function createUser(overrides?: Partial<User>): User {
  return {
    id: 'user-1',
    email: 'test@example.com',
    name: 'Test User',
    role: 'member',
    ...overrides,
  };
}

export function createProject(overrides?: Partial<Project>): Project {
  return {
    id: 'proj-1',
    name: 'Test Project',
    ownerId: 'user-1',
    createdAt: new Date('2025-01-01'),
    ...overrides,
  };
}

Add Custom Matchers

// packages/test-utils/src/matchers.ts
import { expect } from 'vitest';

expect.extend({
  toBeValidEmail(received: string) {
    const pass = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(received);
    return {
      pass,
      message: () => pass
        ? `Expected "${received}" not to be a valid email`
        : `Expected "${received}" to be a valid email`,
    };
  },
  toMatchApiResponse(received: unknown, expected: Record<string, unknown>) {
    const pass = Object.entries(expected).every(
      ([key, value]) => (received as Record<string, unknown>)[key] === value
    );
    return {
      pass,
      message: () => `Expected response to match ${JSON.stringify(expected)}`,
    };
  },
});

Consume in Other Packages

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "devDependencies": {
    "@myorg/test-utils": "workspace:*"
  }
}
// packages/ui/src/Button.test.tsx
import { render, screen } from '@testing-library/react';
import { createUser } from '@myorg/test-utils';
import { Button } from './Button';

test('renders user greeting', () => {
  const user = createUser({ name: 'Alice' });
  render(<Button label={`Hello ${user.name}`} />);
  expect(screen.getByText('Hello Alice')).toBeInTheDocument();
});

The workspace:* version range resolves to the local package during development and the published version when releasing.

Shared Jest/Vitest Configuration

Centralise test runner config in a dedicated package to avoid duplication:

// packages/config/jest.base.js
/** @type {import('jest').Config} */
module.exports = {
  testEnvironment: 'node',
  transform: {
    '^.+\\.(t|j)sx?$': ['@swc/jest'],
  },
  moduleNameMapper: {
    '^@myorg/(.*)$': '<rootDir>/../../packages/$1/src',
  },
  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
    },
  },
};
// packages/ui/jest.config.js
const base = require('@myorg/config/jest.base');

/** @type {import('jest').Config} */
module.exports = {
  ...base,
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['@testing-library/jest-dom'],
};

For Vitest:

// packages/config/vitest.base.ts
import { defineConfig } from 'vitest/config';

export function createVitestConfig(overrides = {}) {
  return defineConfig({
    test: {
      globals: true,
      environment: 'node',
      coverage: {
        provider: 'v8',
        reporter: ['text', 'json', 'html'],
        thresholds: { lines: 70, functions: 70, branches: 70 },
      },
    },
    ...overrides,
  });
}

Shared Mock Infrastructure

Put common mocks in test-utils to avoid duplicating them across packages:

// packages/test-utils/src/mocks/http.ts
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

export const handlers = [
  http.get('/api/user/:id', ({ params }) => {
    return HttpResponse.json({ id: params.id, name: 'Test User' });
  }),
  http.post('/api/auth/login', () => {
    return HttpResponse.json({ token: 'test-token' });
  }),
];

export const server = setupServer(...handlers);
// packages/api/src/client.test.ts
import { server } from '@myorg/test-utils/mocks';
import { beforeAll, afterAll, afterEach } from 'vitest';
import { fetchUser } from './client';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('fetches user by id', async () => {
  const user = await fetchUser('123');
  expect(user.name).toBe('Test User');
});

Root-Level Test Scripts

Add convenience scripts to the root package.json:

// package.json (root)
{
  "name": "my-monorepo",
  "private": true,
  "scripts": {
    "test": "pnpm -r run test",
    "test:watch": "pnpm -r --parallel run test:watch",
    "test:coverage": "pnpm -r run test:coverage",
    "test:changed": "pnpm --filter '...[main]' run test"
  },
  "devDependencies": {
    "pnpm": "^9.0.0"
  }
}

CI Configuration

name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Required for change detection

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install --frozen-lockfile

      - name: Run tests (changed only on PR)
        if: github.event_name == 'pull_request'
        run: pnpm --filter "...[origin/${{ github.base_ref }}]" run test

      - name: Run all tests (on main)
        if: github.event_name == 'push'
        run: pnpm -r run test

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          directory: ./

Note the fetch-depth: 0 — without full git history, pnpm's change-based filtering can't determine what changed.

Adding Turborepo for Caching

pnpm workspaces work well with Turborepo as a caching and orchestration layer:

# Add Turborepo to an existing pnpm workspace
pnpm add -D turbo -w
// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "test": {
      "dependsOn": ["^build"],
      "inputs": ["src/**/*.ts", "src/**/*.tsx", "**/*.test.*"],
      "outputs": ["coverage/**"]
    }
  }
}
# Now tests are cached
pnpm turbo <span class="hljs-built_in">test

pnpm handles package installation and workspace linking; Turborepo adds caching and the execution graph.

Key Takeaways

  • pnpm -r run test runs tests in all packages; --parallel runs them concurrently
  • --filter "...[main]" runs tests only for packages changed vs the base branch
  • Shared test utilities in packages/test-utils eliminate duplication across packages
  • Centralise Jest/Vitest config in a packages/config package to keep each package's config small
  • pnpm workspaces have no caching; add Turborepo for caching and smarter orchestration
  • Always use fetch-depth: 0 in CI when using change-based filtering

Read more