Shared Test Fixtures in Monorepos: Avoiding Duplication Across Packages
Every monorepo eventually develops the same antipattern: each package has its own createUser helper, its own database seeder, its own mock server setup. They start identical and drift apart. The schema changes, three of the eight copies get updated, and you spend a debugging session figuring out why tests pass in @myorg/auth but fail in @myorg/api when testing the same user creation flow.
The fix is to treat shared test infrastructure the same way you treat shared production code — as a first-class package in your workspace. This guide covers how to structure a @yourorg/test-fixtures package, what to put in it, how to keep it synchronized with your schema, and the decision of whether to publish it or keep it workspace-only.
Why Separate Test Fixtures into Their Own Package
The alternative to a dedicated package is usually one of two things: copy-paste into each package (the antipattern above), or a shared directory that everyone imports with relative paths (../../test-utils/user.factory). The relative path approach is fragile — move a package and all its relative imports break — and it doesn't support TypeScript path alias resolution cleanly.
A proper workspace package gives you:
- A canonical import path (
@myorg/test-fixtures) that works anywhere in the monorepo - Versioned changes visible in git history as commits to a specific package
- Dependency declaration so Turbo/Nx know which packages depend on test infrastructure
- Type checking at the point of use, not just at definition
Package Structure
packages/test-fixtures/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # Main re-exports
│ ├── factories/
│ │ ├── index.ts
│ │ ├── user.factory.ts
│ │ ├── product.factory.ts
│ │ ├── order.factory.ts
│ │ └── address.factory.ts
│ ├── db/
│ │ ├── index.ts
│ │ ├── seed.ts # Database population utilities
│ │ ├── reset.ts # Cleanup between tests
│ │ └── transaction-helper.ts # Test transaction wrapping
│ ├── mocks/
│ │ ├── index.ts
│ │ ├── server.ts # MSW server factory
│ │ └── handlers/
│ │ ├── auth.handlers.ts
│ │ ├── payments.handlers.ts
│ │ └── notifications.handlers.ts
│ ├── helpers/
│ │ ├── index.ts
│ │ ├── assertions.ts # Custom matchers
│ │ ├── async.ts # waitFor, retry utilities
│ │ └── date.ts # Date manipulation for tests
│ └── types/
│ ├── index.ts
│ └── test-context.types.ts # Shared test context interfacesThe package.json
{
"name": "@myorg/test-fixtures",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./factories": {
"types": "./src/factories/index.ts",
"import": "./src/factories/index.ts"
},
"./db": {
"types": "./src/db/index.ts",
"import": "./src/db/index.ts"
},
"./mocks": {
"types": "./src/mocks/index.ts",
"import": "./src/mocks/index.ts"
},
"./helpers": {
"types": "./src/helpers/index.ts",
"import": "./src/helpers/index.ts"
}
},
"dependencies": {
"@faker-js/faker": "^8.4.0",
"msw": "^2.2.0"
},
"peerDependencies": {
"@myorg/types": "workspace:*",
"vitest": ">=1.0.0"
},
"peerDependenciesMeta": {
"vitest": { "optional": true }
},
"devDependencies": {
"typescript": "^5.4.0",
"vitest": "^1.5.0"
}
}Subpath exports (./factories, ./db, ./mocks) let consumers import only what they need:
// Only pulls in factory code, not MSW or database utilities
import { createUser } from '@myorg/test-fixtures/factories';This matters for frontend packages where you don't want database utilities bundled into test output, and for packages where MSW is unnecessary because you're testing pure logic.
Factory Functions
The core requirement of a factory function is that it produces valid test data with sensible defaults, accepts any subset of the type's fields as overrides, and returns a type-safe result.
// src/factories/user.factory.ts
import { faker } from '@faker-js/faker';
import type { User, UserRole } from '@myorg/types';
type UserOverrides = Partial<Omit<User, 'id' | 'createdAt'>>;
export function createUser(overrides: UserOverrides = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email().toLowerCase(),
name: faker.person.fullName(),
role: 'member' as UserRole,
emailVerified: true,
avatarUrl: null,
createdAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-01T00:00:00Z'),
...overrides,
};
}
// Convenience variants for common scenarios
export function createUnverifiedUser(overrides: UserOverrides = {}): User {
return createUser({ emailVerified: false, ...overrides });
}
export function createAdminUser(overrides: UserOverrides = {}): User {
return createUser({ role: 'admin', ...overrides });
}
export function createUserList(
count: number,
overrides: UserOverrides = {}
): User[] {
return Array.from({ length: count }, (_, i) =>
createUser({ email: `user-${i}@example.com`, ...overrides })
);
}A few design decisions worth noting:
Fixed createdAt — Using a fixed date rather than new Date() makes test output deterministic. Tests that sort by creation date or check "created in the last hour" won't flake based on when they run.
Lowercase email — Most systems normalize emails to lowercase. Matching that in the factory prevents subtle comparison bugs in tests.
Omit<User, 'id' | 'createdAt'> — If id is system-generated in production, you might want to always generate it in the factory and not allow overrides. Depends on your domain.
Nested Object Factories
For types with nested objects:
// src/factories/order.factory.ts
import { createUser } from './user.factory';
import { createProduct } from './product.factory';
import type { Order, OrderItem } from '@myorg/types';
export function createOrderItem(overrides: Partial<OrderItem> = {}): OrderItem {
const product = createProduct();
return {
id: faker.string.uuid(),
productId: product.id,
product,
quantity: faker.number.int({ min: 1, max: 10 }),
unitPrice: product.price,
...overrides,
};
}
export function createOrder(overrides: Partial<Order> = {}): Order {
const user = createUser();
const items = [createOrderItem(), createOrderItem()];
return {
id: faker.string.uuid(),
userId: user.id,
user,
items,
status: 'pending',
total: items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),
createdAt: new Date('2024-01-01T00:00:00Z'),
...overrides,
};
}The nested factory composition means you get a fully-realized order object with no setup boilerplate in tests:
const order = createOrder({ status: 'completed' });
// order.user, order.items, order.total are all populatedDatabase Seeding Utilities
Database tests have a lifecycle problem: each test needs a clean starting state, but spinning up a fresh database for each test is too slow. The typical solution is one of:
- Run each test inside a transaction, then roll it back
- Truncate tables before each test
- Use a dedicated test database that gets reset between test suites
The fixtures package should provide utilities for all three:
// src/db/reset.ts
import type { PrismaClient } from '@prisma/client';
// Truncate all tables in correct dependency order
export async function resetDatabase(prisma: PrismaClient): Promise<void> {
// Disable foreign key checks for Postgres
await prisma.$executeRaw`SET session_replication_role = replica`;
await Promise.all([
prisma.orderItem.deleteMany(),
prisma.notification.deleteMany(),
prisma.session.deleteMany(),
]);
await Promise.all([
prisma.order.deleteMany(),
prisma.product.deleteMany(),
]);
await prisma.user.deleteMany();
await prisma.$executeRaw`SET session_replication_role = DEFAULT`;
}// src/db/transaction-helper.ts
import type { PrismaClient } from '@prisma/client';
// Wrap each test in a transaction that gets rolled back
export function withTestTransaction(prisma: PrismaClient) {
let tx: any;
beforeEach(async () => {
tx = await prisma.$begin();
});
afterEach(async () => {
await tx.$rollback();
});
return () => tx as PrismaClient;
}
// Usage in tests:
// const getDb = withTestTransaction(prisma);
// it('creates a user', async () => {
// const db = getDb();
// await db.user.create({ data: createUser() });
// });// src/db/seed.ts
import type { PrismaClient } from '@prisma/client';
import { createUser, createAdminUser } from '../factories/user.factory';
import { createProduct } from '../factories/product.factory';
interface SeedResult {
admin: any;
regularUser: any;
products: any[];
}
export async function seedBaseData(prisma: PrismaClient): Promise<SeedResult> {
const [admin, regularUser] = await Promise.all([
prisma.user.create({ data: createAdminUser() }),
prisma.user.create({ data: createUser() }),
]);
const products = await Promise.all(
Array.from({ length: 5 }, () =>
prisma.product.create({ data: createProduct() })
)
);
return { admin, regularUser, products };
}Mock Server Factories
MSW (Mock Service Worker) is the standard for HTTP mocking in modern JavaScript tests. The fixtures package should own the server setup and the handler collection:
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { authHandlers } from './handlers/auth.handlers';
import { userHandlers } from './handlers/user.handlers';
import { paymentHandlers } from './handlers/payments.handlers';
// Default handlers cover the happy path for all external services
export const defaultHandlers = [
...authHandlers,
...userHandlers,
...paymentHandlers,
];
export function createMockServer(additionalHandlers: any[] = []) {
const server = setupServer(...defaultHandlers, ...additionalHandlers);
return server;
}
// Convenience setup for vitest/jest
export function setupMockServer(additionalHandlers: any[] = []) {
const server = createMockServer(additionalHandlers);
beforeAll(() => server.listen({ onUnhandledRequest: 'warn' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
return server;
}// src/mocks/handlers/payments.handlers.ts
import { http, HttpResponse } from 'msw';
export const paymentHandlers = [
http.post('https://api.stripe.com/v1/payment_intents', () => {
return HttpResponse.json({
id: 'pi_test_12345',
status: 'requires_confirmation',
client_secret: 'pi_test_12345_secret_abc',
});
}),
http.post('https://api.stripe.com/v1/payment_intents/:id/confirm', () => {
return HttpResponse.json({
id: 'pi_test_12345',
status: 'succeeded',
});
}),
];
// Named exports for tests that need to override specific scenarios
export const paymentFailureHandler = http.post(
'https://api.stripe.com/v1/payment_intents',
() => {
return HttpResponse.json(
{ error: { message: 'Your card was declined.' } },
{ status: 402 }
);
}
);Tests that need to override the default behavior use server.use():
import { setupMockServer } from '@myorg/test-fixtures/mocks';
import { paymentFailureHandler } from '@myorg/test-fixtures/mocks';
const server = setupMockServer();
it('shows an error when payment fails', async () => {
server.use(paymentFailureHandler);
// ... test the failure scenario
});TypeScript Types for Test Data
The fixtures package often needs types that don't exist in @myorg/types — types specific to test scenarios:
// src/types/test-context.types.ts
import type { User, Product } from '@myorg/types';
export interface TestUserContext {
admin: User;
regularUser: User;
unverifiedUser: User;
}
export interface TestProductContext {
inStockProduct: Product;
outOfStockProduct: Product;
products: Product[];
}
export interface FullTestContext extends TestUserContext, TestProductContext {
// Combined context for integration tests that need multiple entities
}These types let test setup functions return structured, type-safe context objects:
export async function createTestContext(prisma: PrismaClient): Promise<FullTestContext> {
const [admin, regularUser, unverifiedUser] = await Promise.all([
prisma.user.create({ data: createAdminUser() }),
prisma.user.create({ data: createUser() }),
prisma.user.create({ data: createUnverifiedUser() }),
]);
const [inStockProduct, outOfStockProduct] = await Promise.all([
prisma.product.create({ data: createProduct({ stock: 10 }) }),
prisma.product.create({ data: createProduct({ stock: 0 }) }),
]);
return {
admin,
regularUser,
unverifiedUser,
inStockProduct,
outOfStockProduct,
products: [inStockProduct, outOfStockProduct],
};
}Keeping Fixtures in Sync with Schema Changes
This is the hardest part. Your createUser factory returns a User object. When the User type gains a required field, every factory call that doesn't provide that field breaks — ideally at compile time, but sometimes only at runtime.
Compile-Time Enforcement
The factory's TypeScript types should make it impossible to return an invalid object. If you add a required phoneNumber: string to the User type, TypeScript will error at the factory definition, not at the call site. Fix the factory, and every consumer is automatically fixed.
// This factory will error if User gains required fields not covered here
export function createUser(overrides: Partial<User> = {}): User {
return {
id: faker.string.uuid(),
email: faker.internet.email(),
name: faker.person.fullName(),
// TypeScript error if User has other required fields not listed here
...overrides,
};
}Runtime Schema Validation
For extra safety, run factory output through your runtime schema validator:
import { UserSchema } from '@myorg/types'; // Zod schema
export function createUser(overrides: Partial<User> = {}): User {
const raw = {
id: faker.string.uuid(),
email: faker.internet.email().toLowerCase(),
name: faker.person.fullName(),
role: 'member' as const,
emailVerified: true,
createdAt: new Date('2024-01-01T00:00:00Z'),
...overrides,
};
// Throws at test setup time if factory output doesn't satisfy schema
// Catches cases where Zod schema adds constraints TypeScript types don't capture
return UserSchema.parse(raw);
}This catches the mismatch between TypeScript types (which only check shape) and Zod schemas (which also check constraints like email format, positive numbers, etc.).
Testing the Fixtures Package Itself
The fixtures package should have its own tests:
// src/factories/user.factory.test.ts
import { describe, it, expect } from 'vitest';
import { UserSchema } from '@myorg/types';
import { createUser, createAdminUser, createUserList } from './user.factory';
describe('createUser', () => {
it('produces valid User objects', () => {
const user = createUser();
expect(() => UserSchema.parse(user)).not.toThrow();
});
it('applies overrides correctly', () => {
const user = createUser({ email: 'specific@example.com', role: 'admin' });
expect(user.email).toBe('specific@example.com');
expect(user.role).toBe('admin');
});
it('generates unique IDs by default', () => {
const users = createUserList(100);
const ids = new Set(users.map((u) => u.id));
expect(ids.size).toBe(100);
});
});These tests run as part of the fixtures package's own test suite and serve as a canary — if the schema changes in a way that breaks factory output, these tests fail before anything else does.
Publishing vs. Workspace-Only
The private: true flag in the fixtures package's package.json keeps it workspace-only. This is the right default for most organizations.
The case for publishing arises when:
- You have multiple separate repositories (not a monorepo) that need the same fixtures
- You're building a framework or platform where downstream teams have their own repos
If you do publish, you'll need to add a build step (compile TypeScript to JavaScript), manage versioning, and handle the lag between schema changes and package releases. The workspace:* protocol in a monorepo sidesteps all of this — every consumer always uses the current version of the fixtures package.
For multi-repo scenarios, consider whether a monorepo isn't actually the right solution before investing in published test infrastructure packages.
Integrating with Your Test Runner
For Vitest, a global setup file that configures the fixtures:
// vitest.setup.ts (referenced in vitest.config.ts setupFiles)
import { beforeAll, afterAll, afterEach } from 'vitest';
import { setupMockServer } from '@myorg/test-fixtures/mocks';
// Set up MSW for all tests in this package
setupMockServer();
// Global test environment configuration
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? 'postgresql://localhost/test';And in vitest.config.ts:
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
globals: true,
environment: 'node',
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: {
// Ensure workspace package imports resolve correctly
'@myorg/test-fixtures': resolve(__dirname, '../../packages/test-fixtures/src'),
},
},
});Monitoring Fixture Health Across Packages
A shared fixture package is a force multiplier — for good and bad. When it works, every package gets consistent, high-quality test data. When it breaks, it breaks test suites across your entire repo simultaneously.
Monitoring which packages have tests that depend on shared fixtures — and tracking whether those tests stay green across schema changes — is more valuable than for isolated package tests. HelpMeTest can track test health at this level, helping you identify whether a test failure is isolated or indicates a broken shared dependency.
Summary
A well-structured shared test fixtures package eliminates the most common source of test maintenance burden in monorepos: divergent test data factories. The key pieces:
- Create
@myorg/test-fixturesas aprivate: trueworkspace package with subpath exports - Factory functions accept
Partial<T>overrides and use TypeScript types from@myorg/types - Database seeding utilities handle both truncate-and-seed and transaction-rollback patterns
- Mock server factories own the MSW setup and default handlers, exposing named exports for failure scenarios
- Run Zod validation inside factories to catch schema drift at test setup time, not at assertion time
- Test the fixtures package itself — factory tests are your canary for schema changes
The package stays in sync because TypeScript enforces it. When the User type changes, the factory breaks, you fix the factory, and every consumer is automatically correct. No hunting for diverged copies.