Generating Realistic Relational Mock Data

Your orders endpoint returns { id: 'order_1', userId: 'a1b2c3d4' }, your users endpoint returns a completely different set of ids, and the test that clicks an order to open its owner’s profile 404s — not because the code is wrong, but because the mock user referenced by the order never existed. Relational mock data breaks the moment a child entity invents a foreign key that no parent shares. This page builds a graph across users, orders, and line items whose every reference resolves, on every run, because the ids are seeded rather than random.

Why referential integrity is hard with random data

A single-entity factory that calls faker.string.uuid() for its id is fine in isolation. The trouble starts when a second entity needs to point at the first. If the order factory generates its own userId with another faker.string.uuid(), that id is drawn from the same reproducible sequence but it is a fresh value — it will never coincide with any id the user factory produced. The two endpoints are individually deterministic and jointly inconsistent.

The fix is not more randomness but less: children must borrow their parents’ ids instead of minting new ones. Combined with the deterministic ids from deterministic seed management, borrowing produces a graph that is stable end to end. This walkthrough extends the two-level dataset from faker and fixture seeding to a full three-level users → orders → line items graph, adding derived totals so aggregate fields agree with the parts they summarise.

Solution

1. Define the three entities

Each entity gets sequential, human-readable ids from the factory’s build index — the property that makes borrowing possible. These factories use the generic helper from building a reusable fixture factory:

// src/mocks/factories/commerce.ts
import { defineFactory } from '../factory';

export interface User {
  id: string;
  name: string;
  email: string;
}

export interface LineItem {
  id: string;
  orderId: string;
  productName: string;
  quantity: number;
  unitPriceCents: number;
  subtotalCents: number;
}

export interface Order {
  id: string;
  userId: string;
  status: 'pending' | 'paid' | 'shipped';
  lineItems: LineItem[];
  totalCents: number;
  placedAt: string;
}

export const userFactory = defineFactory<User>(({ index, faker }) => ({
  id: `user_${(index + 1).toString().padStart(4, '0')}`,
  name: faker.person.fullName(),
  email: faker.internet.email(),
}));

export const lineItemFactory = defineFactory<LineItem>(({ index, faker }) => {
  const quantity = faker.number.int({ min: 1, max: 4 });
  const unitPriceCents = faker.number.int({ min: 500, max: 12_000 });
  return {
    id: `item_${(index + 1).toString().padStart(4, '0')}`,
    orderId: 'order_0001', // overridden during composition
    productName: faker.commerce.productName(),
    quantity,
    unitPriceCents,
    subtotalCents: quantity * unitPriceCents,
  };
});

export const orderFactory = defineFactory<Order>(({ index, faker }) => ({
  id: `order_${(index + 1).toString().padStart(4, '0')}`,
  userId: 'user_0001', // overridden during composition
  status: faker.helpers.arrayElement(['pending', 'paid', 'shipped']),
  lineItems: [],
  totalCents: 0,
  placedAt: faker.date.recent({ days: 45 }).toISOString(),
}));

Notice that subtotalCents is computed inside the line-item builder from its own quantity and unitPriceCents. Internal consistency starts at the leaf: a line item’s subtotal always equals quantity times unit price, so no downstream sum can contradict it.

2. Compose the graph, borrowing ids downward

The composer builds top-down. Users first; then, for each user, a handful of orders that reference that user’s id; then, for each order, line items that reference that order’s id. Every foreign key is a value that already exists one level up.

// src/mocks/relational.ts
import { resetSeed } from './seeded-faker';
import {
  userFactory,
  orderFactory,
  lineItemFactory,
  type User,
  type Order,
} from './factories/commerce';

export interface CommerceDataset {
  users: User[];
  orders: Order[];
}

export function buildCommerceGraph(
  userCount = 4,
  ordersPerUser = 2,
  itemsPerOrder = 3
): CommerceDataset {
  resetSeed();
  userFactory.rewind();
  orderFactory.rewind();
  lineItemFactory.rewind();

  const users = userFactory.buildList(userCount);

  const orders = users.flatMap((user) =>
    orderFactory.buildList(ordersPerUser, { userId: user.id })
  );

  // Attach line items that reference their order, then roll up the total.
  for (const order of orders) {
    order.lineItems = lineItemFactory.buildList(itemsPerOrder, {
      orderId: order.id,
    });
    order.totalCents = order.lineItems.reduce(
      (sum, item) => sum + item.subtotalCents,
      0
    );
  }

  return { users, orders };
}

The resetSeed() and rewind() calls at the top make the graph independent of anything that ran before it, so calling buildCommerceGraph() from a handler or a script yields the same result every time. The order total is a derived field: it is recomputed from the line items rather than generated, which guarantees the invoice a client renders adds up to the number the API reports.

3. Serve the graph through consistent endpoints

The three endpoints must slice one shared dataset, not build three independent ones — otherwise the “same” order returned by /orders and /orders/:id could differ. Build once, then project:

// src/mocks/handlers/commerce.ts
import { http, HttpResponse } from 'msw';
import { buildCommerceGraph } from '../relational';

const { users, orders } = buildCommerceGraph(4, 2, 3);
const userById = new Map(users.map((u) => [u.id, u]));

export const commerceHandlers = [
  http.get('/api/v1/users/:id', ({ params }) => {
    const user = userById.get(params.id as string);
    return user
      ? HttpResponse.json(user)
      : HttpResponse.json({ error: 'Not found' }, { status: 404 });
  }),

  http.get('/api/v1/users/:id/orders', ({ params }) => {
    const owned = orders.filter((o) => o.userId === params.id);
    return HttpResponse.json({ data: owned, total: owned.length });
  }),

  http.get('/api/v1/orders/:id', ({ params }) => {
    const order = orders.find((o) => o.id === params.id);
    return order
      ? HttpResponse.json(order)
      : HttpResponse.json({ error: 'Not found' }, { status: 404 });
  }),
];

Building the graph once at module load and sharing it across handlers is the read-only counterpart to the mutable stores in stateful scenario sequences — here nothing changes between requests, so a single frozen dataset is exactly right.

4. Emit the same graph as static WireMock files

For a mock server that serves JSON from disk, write the graph out in a generation step so a WireMock standalone instance can serve identical data:

// scripts/generate-commerce-fixtures.mts
import { writeFileSync, mkdirSync } from 'node:fs';
import { buildCommerceGraph } from '../src/mocks/relational.js';

const { users, orders } = buildCommerceGraph(4, 2, 3);
mkdirSync('./mocks/wiremock/__files', { recursive: true });
writeFileSync('./mocks/wiremock/__files/users.json', JSON.stringify({ data: users }, null, 2));
writeFileSync('./mocks/wiremock/__files/orders.json', JSON.stringify({ data: orders }, null, 2));
console.log(`Graph: ${users.length} users, ${orders.length} orders`);

Verification

One script asserts the two invariants that matter — every foreign key resolves, and every order total equals the sum of its items:

node -e "
const { buildCommerceGraph } = require('./dist/mocks/relational.js');
const { users, orders } = buildCommerceGraph(4, 2, 3);
const userIds = new Set(users.map(u => u.id));
const fkOk = orders.every(o => userIds.has(o.userId)
  && o.lineItems.every(li => li.orderId === o.id));
const totalsOk = orders.every(o =>
  o.totalCents === o.lineItems.reduce((s, li) => s + li.subtotalCents, 0));
console.log(fkOk && totalsOk ? 'OK' : 'INCONSISTENT');
"
# Expected: OK

A INCONSISTENT result means either a child invented its own foreign key or a total was generated instead of derived.

Gotchas and edge cases

  • Rewind every factory, not just the seed. Three factories share one seeded PRNG but keep independent sequence counters. If you reset the seed but forget to rewind() the order and line-item factories, ids keep climbing across calls and a second buildCommerceGraph() returns order_0009 instead of order_0001. Reset all three, as the composer does.

  • flatMap order is load-bearing. Orders are built by iterating users in array order, so orders[0] and orders[1] belong to users[0]. If you parallelise generation or reorder the user list between runs, the id-to-owner mapping shifts even though the seed is fixed. Keep generation sequential and single-threaded.

  • Derived fields must never be overridden with a literal. Passing orderFactory.build({ totalCents: 999 }) produces an order whose total contradicts its line items. Treat totalCents and subtotalCents as computed-only; if a test needs a specific total, adjust the line items that feed it rather than the aggregate.


← Back to Faker & Fixture Seeding