Shared Test Utilities in Monorepos: Mocks, Fixtures, and Test Helpers

Shared Test Utilities in Monorepos: Mocks, Fixtures, and Test Helpers

As a monorepo grows, test code starts to duplicate. Every package writes its own createUser() factory, defines its own API mock handlers, and copies the same Jest setup file. A change to the User type means updating factories in five packages. A new API endpoint means updating mocks scattered across the codebase.

Shared test utilities solve this. A dedicated packages/test-utils package becomes the single source of truth for test infrastructure, and every package imports from it.

The Case for Shared Test Utilities

Without shared utilities:

  • Factories diverge — different packages create test users with different defaults, leading to inconsistent test data
  • API mocks get out of sync — one package updates a mock, another still uses the stale version
  • Setup code duplicates — every package copies the same beforeAll(setup) / afterAll(teardown) boilerplate
  • Custom matchers get reinvented — each package writes its own toMatchApiResponse

With shared utilities:

  • One factory change updates all tests
  • Mock handlers are authoritative and reused
  • Setup happens once, composed everywhere
  • Custom matchers are consistent across the codebase

Package Structure

packages/
  test-utils/
    package.json
    src/
      index.ts              # Public exports
      factories/
        index.ts
        user.factory.ts
        project.factory.ts
        payment.factory.ts
      fixtures/
        index.ts
        users.fixture.ts
        api-responses.fixture.ts
      mocks/
        index.ts
        server.ts           # MSW server
        handlers/
          auth.handlers.ts
          user.handlers.ts
          payment.handlers.ts
      matchers/
        index.ts
        api.matchers.ts
        date.matchers.ts
      setup/
        index.ts
        vitest.setup.ts
        jest.setup.ts

Factory Functions

Factories create test objects with sensible defaults, accepting overrides for specific scenarios:

// packages/test-utils/src/factories/user.factory.ts
import type { User, UserRole } from '@myorg/types';

let idCounter = 0;

export function createUser(overrides?: Partial<User>): User {
  idCounter++;
  return {
    id: `user-${idCounter}`,
    email: `user-${idCounter}@example.com`,
    name: `Test User ${idCounter}`,
    role: 'member' as UserRole,
    createdAt: new Date('2025-01-01T00:00:00Z'),
    updatedAt: new Date('2025-01-01T00:00:00Z'),
    emailVerified: true,
    avatarUrl: null,
    ...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 }, (_, i) =>
    createUser(overrides?.[i])
  );
}

// Reset counter between tests to keep IDs deterministic
export function resetUserFactory(): void {
  idCounter = 0;
}

Usage:

import { createUser, createAdminUser } from '@myorg/test-utils';

test('admin can delete any user', () => {
  const admin = createAdminUser();
  const target = createUser({ id: 'user-99' });
  expect(canDeleteUser(admin, target)).toBe(true);
});

test('member cannot delete other users', () => {
  const member = createUser();
  const target = createUser();
  expect(canDeleteUser(member, target)).toBe(false);
});

Fixture Files

Fixtures are static test data for scenarios that need deterministic, reusable values:

// packages/test-utils/src/fixtures/users.fixture.ts
import type { User } from '@myorg/types';

export const fixtures = {
  basicUser: {
    id: 'fixture-user-1',
    email: 'alice@example.com',
    name: 'Alice Fixture',
    role: 'member',
    createdAt: new Date('2025-01-15T10:00:00Z'),
    updatedAt: new Date('2025-01-15T10:00:00Z'),
    emailVerified: true,
    avatarUrl: 'https://example.com/avatar.jpg',
  } satisfies User,

  adminUser: {
    id: 'fixture-admin-1',
    email: 'admin@example.com',
    name: 'Admin Fixture',
    role: 'admin',
    createdAt: new Date('2024-06-01T08:00:00Z'),
    updatedAt: new Date('2025-01-01T00:00:00Z'),
    emailVerified: true,
    avatarUrl: null,
  } satisfies User,

  unverifiedUser: {
    id: 'fixture-user-2',
    email: 'unverified@example.com',
    name: 'Unverified User',
    role: 'member',
    createdAt: new Date('2025-02-01T12:00:00Z'),
    updatedAt: new Date('2025-02-01T12:00:00Z'),
    emailVerified: false,
    avatarUrl: null,
  } satisfies User,
} as const;

Fixtures vs factories: fixtures are for stable, named scenarios. Factories are for generating arbitrary quantities of data with known shapes.

Mock HTTP Handlers with MSW

Mock Service Worker (MSW) provides network-level mocking that works in Node.js tests and the browser. Define handlers once, reuse everywhere:

// packages/test-utils/src/mocks/handlers/user.handlers.ts
import { http, HttpResponse } from 'msw';
import { fixtures } from '../../fixtures/users.fixture';
import { createUser } from '../../factories/user.factory';

export const userHandlers = [
  http.get('/api/users/:id', ({ params }) => {
    if (params.id === fixtures.basicUser.id) {
      return HttpResponse.json(fixtures.basicUser);
    }
    if (params.id === 'not-found') {
      return HttpResponse.json(
        { error: 'User not found' },
        { status: 404 }
      );
    }
    return HttpResponse.json(createUser({ id: params.id as string }));
  }),

  http.get('/api/users', ({ request }) => {
    const url = new URL(request.url);
    const limit = Number(url.searchParams.get('limit') ?? 10);
    return HttpResponse.json({
      data: Array.from({ length: limit }, () => createUser()),
      total: 100,
    });
  }),

  http.patch('/api/users/:id', async ({ params, request }) => {
    const body = await request.json() as Partial<User>;
    return HttpResponse.json({
      ...fixtures.basicUser,
      id: params.id as string,
      ...body,
      updatedAt: new Date().toISOString(),
    });
  }),
];
// packages/test-utils/src/mocks/server.ts
import { setupServer } from 'msw/node';
import { userHandlers } from './handlers/user.handlers';
import { authHandlers } from './handlers/auth.handlers';
import { paymentHandlers } from './handlers/payment.handlers';

export const server = setupServer(
  ...authHandlers,
  ...userHandlers,
  ...paymentHandlers,
);

// Helper to add temporary handlers for specific tests
export function withHandlers(handlers: Parameters<typeof server.use>) {
  return {
    use: () => server.use(...handlers),
    restore: () => server.resetHandlers(),
  };
}
// packages/test-utils/src/setup/vitest.setup.ts
import { server } from '../mocks/server';
import { beforeAll, afterAll, afterEach } from 'vitest';

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

Using onUnhandledRequest: 'error' causes tests to fail if they make requests that don't have a handler — this catches regressions when endpoints change.

Custom Matchers

Extend your test runner with domain-specific assertions:

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

interface ApiError {
  error: string;
  status: number;
  message?: string;
}

expect.extend({
  toBeApiError(received: unknown, expectedStatus: number, expectedError?: string) {
    const isApiError = (v: unknown): v is ApiError =>
      typeof v === 'object' && v !== null && 'error' in v && 'status' in v;

    if (!isApiError(received)) {
      return {
        pass: false,
        message: () => `Expected an API error object, got: ${JSON.stringify(received)}`,
      };
    }

    const statusMatches = received.status === expectedStatus;
    const errorMatches = expectedError ? received.error === expectedError : true;
    const pass = statusMatches && errorMatches;

    return {
      pass,
      message: () => pass
        ? `Expected not to be API error ${expectedStatus} ${expectedError}`
        : `Expected API error ${expectedStatus} ${expectedError ?? ''}, got ${received.status} ${received.error}`,
    };
  },

  toHavePagination(received: unknown, expected: { page: number; total: number }) {
    const hasPagination = (v: unknown): v is { page: number; total: number; data: unknown[] } =>
      typeof v === 'object' && v !== null && 'page' in v && 'total' in v;

    if (!hasPagination(received)) {
      return { pass: false, message: () => `Expected paginated response` };
    }

    const pass = received.page === expected.page && received.total === expected.total;
    return {
      pass,
      message: () => `Expected page=${expected.page} total=${expected.total}, got page=${received.page} total=${received.total}`,
    };
  },
});

// Type augmentation for TypeScript
declare module 'vitest' {
  interface Assertion {
    toBeApiError(status: number, error?: string): void;
    toHavePagination(expected: { page: number; total: number }): void;
  }
}

Test Setup and Teardown Helpers

Centralise database setup, server startup, and environment configuration:

// packages/test-utils/src/setup/database.setup.ts
import { beforeEach, afterEach } from 'vitest';

export function withCleanDatabase() {
  beforeEach(async () => {
    await global.__TEST_DB__.migrate.rollback();
    await global.__TEST_DB__.migrate.latest();
    await global.__TEST_DB__.seed.run();
  });

  afterEach(async () => {
    await global.__TEST_DB__.migrate.rollback();
  });
}

export function withTransaction() {
  let trx: Knex.Transaction;

  beforeEach(async () => {
    trx = await global.__TEST_DB__.transaction();
    // Inject into DI container or module scope
    setTestTransaction(trx);
  });

  afterEach(async () => {
    await trx.rollback();
  });

  return { getTrx: () => trx };
}

Exporting from the Package

Keep exports organised and tree-shakeable:

// packages/test-utils/src/index.ts

// Core utilities - always available
export * from './factories';
export * from './fixtures';
export * from './matchers';

// Setup helpers - import in vitest/jest config files
export * from './setup';
// packages/test-utils/src/mocks/index.ts
// Separate export to avoid pulling MSW into non-HTTP tests
export { server, withHandlers } from './server';
export * from './handlers/user.handlers';
export * from './handlers/auth.handlers';

Consumers can import precisely what they need:

import { createUser } from '@myorg/test-utils';
import { server } from '@myorg/test-utils/mocks';

Versioning and Updates

When the shared type User changes, updating @myorg/test-utils propagates to all consumers. To avoid accidentally breaking tests across the entire monorepo:

  1. Update the factory/fixture to match the new type
  2. Run pnpm -r run test or turbo test to surface breakages
  3. Fix any tests that relied on the old shape
  4. Commit everything together

This is preferable to spreading changes across individual packages — the shared utilities act as a forcing function for consistency.

Key Takeaways

  • A packages/test-utils package eliminates duplicated factories, mocks, and setup code across your monorepo
  • Factory functions generate test data with sensible defaults and override support; fixtures store stable named scenarios
  • MSW handlers defined once in test-utils ensure all packages test against the same API contracts
  • Custom matchers make assertions readable and domain-specific without duplicating logic
  • Use onUnhandledRequest: 'error' in MSW to catch regressions from API changes
  • Export mocks separately to avoid importing MSW into tests that don't need HTTP mocking

Read more