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 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.


← Back to Faker & Fixture Seeding