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.tsFactory 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:
- Update the factory/fixture to match the new type
- Run
pnpm -r run testorturbo testto surface breakages - Fix any tests that relied on the old shape
- 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-utilspackage 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-utilsensure 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