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.

What one factory call actually does A call names an entity and an identifier. The seed layer derives a per-record seed from that identifier. The primitive layer maps each field name to a generator. The entity layer assembles the record. The result is a valid record that depends only on its own identifier, not on what was built before it. Because the seed comes from the identifier, inserting a record changes one line of the output rather than all of them. build('order', id) one call, one record identifier is the only input Seed layer derives a seed from the id order-independent Primitive layer field name to generator readable values Entity layer assembles and returns valid by construction A factory seeded once per run instead produces a whole-file diff every time a record is added.

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) });
  }),
];
Decisions that are hard to reverse Four API decisions. Whether overrides are positional or named, whether the factory returns one record or a list, whether relationships are built or injected, and whether edge cases live in the random path or as named records. Each is paired with the consequence of the wrong choice. Decision Better choice Cost of the other override style a named partial object positional arguments nobody can read single or list single, with a separate helper a list API that cannot make one record relationships injected references a factory that builds the whole graph edge cases named records, asserted on hidden in the random path, untested All four are cheap to get right on day one and expensive to change once fifty specs depend on the shape.

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 entire address, dropping other address fields. For nested overrides, expose a dedicated sub-factory (addressFactory) and pass a fully-built object, or add a deepMerge helper 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 both resetSeed() and factory.rewind() in beforeEach — resetting one without the other gives stable ids but drifting values, or vice versa.

  • Trait order matters when traits overlap. buildAs(['onSale', 'euro']) and buildAs(['euro', 'onSale']) are identical here because the traits touch disjoint fields, but two traits that both set tags will have the last one win. Keep traits field-disjoint where possible, and document the precedence when they must overlap.


When a factory has outgrown its shape Three signals. Every call site passing the same overrides, a single record requiring several other entities to exist first, and specs reaching past the factory to construct objects by hand. Each indicates a specific structural problem rather than a general need for tidying. The same overrides everywhere that value belongs in the default move it into the factory Building one record needs five the factory builds relationships it should accept inject references instead Specs construct records by hand the factory cannot express what they need add the case rather than bypassing it The third signal is the most serious: once specs bypass the factory, the schema has two sources of truth again.

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.

← Back to Faker & Fixture Seeding