Building a Reusable Fixture Factory
You have twenty test files, and each one hand-rolls a user object inline: { id: '1', name: 'Test', email: '[email protected]', role: 'admin' }. When a required field is added to the User type, every one of those literals is now wrong, and TypeScript only catches the ones that happen to be typed. You need one place that knows how to build a valid User, produces a unique instance each time, and lets a single test override exactly the fields it cares about — without copy-pasting the other six.
Why hand-rolled fixtures rot
Inline fixture literals fail for three predictable reasons. They drift from the type: a new non-optional field means every literal is silently incomplete until something dereferences it. They collide: two tests that both use id: '1' break the moment they share a store or a database. And they obscure intent: a test that cares only about a user’s role still has to spell out a name, email, and timestamp, burying the one field that matters under noise.
A factory fixes all three. The type is declared once, so a new field forces exactly one edit. Ids come from a monotonic sequence, so instances never collide. And overrides let a test say buildAs(['admin']) or build({ role: 'admin' }) and nothing else. This page builds that factory generically — one implementation that serves every entity in the project. It is the reusable core behind faker and fixture seeding, extracted and explained line by line.
Solution
1. Declare the generic factory contract
Start with the interface. Making Factory<T> generic means the same helper types every entity, and the compiler enforces that overrides are a Partial<T> — you can never override a field that does not exist.
// src/mocks/factory.ts
import { faker } from './seeded-faker';
export interface BuildContext {
/** Monotonic index, unique and ordered within this factory. */
index: number;
faker: typeof faker;
}
export type Builder<T> = (ctx: BuildContext) => T;
export type TraitPatch<T> = Partial<T> | ((ctx: BuildContext) => Partial<T>);
export interface Factory<T> {
build(overrides?: Partial<T>): T;
buildList(count: number, overrides?: Partial<T> | ((i: number) => Partial<T>)): T[];
buildAs(traits: string[], overrides?: Partial<T>): T;
define(trait: string, patch: TraitPatch<T>): Factory<T>;
/** Reset the internal sequence counter — useful in beforeEach. */
rewind(): Factory<T>;
}
The faker import points at a seeded singleton so the factory inherits reproducibility for free. That module is the anchor described in deterministic seed management:
// src/mocks/seeded-faker.ts
import { Faker, en } from '@faker-js/faker';
const seed = Number.parseInt(process.env.MOCK_SEED ?? '42', 10);
if (Number.isNaN(seed)) {
throw new Error(`MOCK_SEED must be an integer, got "${process.env.MOCK_SEED}"`);
}
export const faker = new Faker({ locale: [en] });
faker.seed(seed);
export function resetSeed(value = seed): void {
faker.seed(value);
}
2. Implement the factory with a monotonic sequence
The implementation keeps two pieces of state: a sequence counter that increments on every build, and a traits map. The counter is what guarantees unique ids; the map is what powers named variants.
// src/mocks/factory.ts (continued)
export function defineFactory<T>(builder: Builder<T>): Factory<T> {
let sequence = 0;
const traits = new Map<string, TraitPatch<T>>();
const patchFrom = (name: string, ctx: BuildContext): Partial<T> => {
const patch = traits.get(name);
if (patch === undefined) {
throw new Error(`Unknown trait "${name}" (registered: ${[...traits.keys()].join(', ') || 'none'})`);
}
return typeof patch === 'function' ? patch(ctx) : patch;
};
const factory: Factory<T> = {
build(overrides = {}) {
const ctx: BuildContext = { index: sequence++, faker };
return { ...builder(ctx), ...overrides };
},
buildList(count, overrides = {}) {
return Array.from({ length: count }, (_unused, i) => {
const perItem = typeof overrides === 'function' ? overrides(i) : overrides;
return factory.build(perItem);
});
},
buildAs(traitNames, overrides = {}) {
const ctx: BuildContext = { index: sequence++, faker };
let entity = builder(ctx);
for (const name of traitNames) {
entity = { ...entity, ...patchFrom(name, ctx) };
}
return { ...entity, ...overrides };
},
define(trait, patch) {
traits.set(trait, patch);
return factory;
},
rewind() {
sequence = 0;
return factory;
},
};
return factory;
}
Two design choices are worth calling out. buildList accepts either a flat override object or a function of the list index, so you can say buildList(3, (i) => ({ position: i })) to give each item an ordinal. And buildAs shares one index across all traits in a single build, so a trait that reads ctx.index sees the same value the base builder saw — traits patch an entity, they do not advance the sequence twice.
3. Define an entity with traits, overrides, and sequences
Now the payoff: an entity definition that reads like a specification. The base builder describes a valid default; traits describe meaningful deviations; the sequence produces stable, ordered ids.
// src/mocks/factories/product.ts
import { defineFactory } from '../factory';
export interface Product {
sku: string;
name: string;
priceCents: number;
currency: 'USD' | 'EUR' | 'GBP';
inStock: boolean;
tags: string[];
}
export const productFactory = defineFactory<Product>(({ index, faker }) => ({
sku: `SKU-${(index + 1).toString().padStart(5, '0')}`,
name: faker.commerce.productName(),
priceCents: faker.number.int({ min: 500, max: 50_000 }),
currency: 'USD',
inStock: true,
tags: faker.helpers.arrayElements(['new', 'sale', 'popular', 'clearance'], { min: 0, max: 2 }),
}))
.define('outOfStock', { inStock: false })
.define('onSale', ({ faker }) => ({
priceCents: faker.number.int({ min: 100, max: 499 }),
tags: ['sale'],
}))
.define('euro', { currency: 'EUR' });
Every call pattern the factory offers, exercised:
import { productFactory } from './mocks/factories/product';
productFactory.rewind();
const catalog = productFactory.buildList(3); // SKU-00001..00003
const sale = productFactory.buildAs(['onSale']); // priced < 500, tagged 'sale'
const soldOut = productFactory.buildAs(['outOfStock', 'euro']); // combined traits
const pinned = productFactory.build({ name: 'Featured Item' }); // explicit override
const ordinals = productFactory.buildList(2, (i) => ({ name: `Item ${i}` }));
Because traits are just partials, they compose: buildAs(['outOfStock', 'euro']) applies both in order. And because the sequence is monotonic, catalog always holds SKU-00001 through SKU-00003 after a rewind() — the property that lets generating realistic relational mock data build foreign keys that resolve.
4. Use the factory in a handler
The factory drops straight into MSW. For richer routing and conditional responses, layer it into advanced MSW handler patterns:
// src/mocks/handlers/products.ts
import { http, HttpResponse } from 'msw';
import { productFactory } from '../factories/product';
export const productHandlers = [
http.get('/api/v1/products', () => {
productFactory.rewind();
return HttpResponse.json({ data: productFactory.buildList(12) });
}),
];
Verification
Confirm the factory is both reproducible and correctly sequenced with a single assertion:
node -e "
const { productFactory } = require('./dist/mocks/factories/product.js');
const { resetSeed } = require('./dist/mocks/seeded-faker.js');
resetSeed(); productFactory.rewind();
const a = productFactory.buildList(3);
resetSeed(); productFactory.rewind();
const b = productFactory.buildList(3);
const stable = JSON.stringify(a) === JSON.stringify(b);
const ordered = a.map(p => p.sku).join(',') === 'SKU-00001,SKU-00002,SKU-00003';
console.log(stable && ordered ? 'OK' : 'FAIL');
"
# Expected: OK
If it prints FAIL, the sequence was not rewound between runs or a value was generated outside the builder body.
Gotchas and edge cases
-
Deep objects are not deep-merged. Overrides use spread, which is shallow.
build({ address: { city: 'X' } })replaces the entireaddress, dropping other address fields. For nested overrides, expose a dedicated sub-factory (addressFactory) and pass a fully-built object, or add adeepMergehelper if your entities are genuinely nested. -
rewind()resets the sequence but not the PRNG. The sequence counter and the faker seed are independent. To make an entire test reproducible you must call bothresetSeed()andfactory.rewind()inbeforeEach— resetting one without the other gives stable ids but drifting values, or vice versa. -
Trait order matters when traits overlap.
buildAs(['onSale', 'euro'])andbuildAs(['euro', 'onSale'])are identical here because the traits touch disjoint fields, but two traits that both settagswill have the last one win. Keep traits field-disjoint where possible, and document the precedence when they must overlap.
Related
- Generating Realistic Relational Mock Data — compose these factories into referentially-consistent graphs
- Faker & Fixture Seeding — the parent walkthrough covering seeds, wiring, and CI
- Deterministic Seed Management — the seeded faker singleton this factory depends on
← Back to Faker & Fixture Seeding