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.
The factory’s contract with its callers
A factory is a small API, and like any API its value depends on what it promises. Three promises are worth making explicitly, because callers will assume them whether or not they are true.
The same identifier yields the same record. This is what lets a specification refer to ord_0001 and mean something specific, and what makes a failing test reproducible. It requires the seed to be derived from the identifier rather than from call order, and it is the promise most often broken by accident.
A record is valid without overrides. A factory that produces something the schema rejects unless the caller supplies three fields is not a factory; it is a constructor with optional arguments. Every field should have a sensible default, and overrides should be for the one or two things a given test cares about.
Overrides are shallow-merged predictably. If a caller overrides one field of a nested object, does the rest of that object survive? Both answers are defensible; only one can be true, and callers will assume whichever they first encountered. State it, and be consistent.
Two things a factory should not promise. It should not promise to build related entities — that couples every record to the whole graph and makes a single record impossible to create in isolation. And it should not promise uniqueness across calls unless it genuinely enforces it, because a caller who assumes unique identifiers and gets duplicates writes a test that fails intermittently.
Where these promises hold, callers stop reading the factory’s implementation, which is the real measure of whether an abstraction is working. Where they do not, every spec author ends up reading the source to find out what they will actually get.
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