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.

Build the graph in dependency order Entities are generated in the order their references require: customers first, then orders holding a customer reference, then line items holding an order reference, then invoices referencing both. Generating in any other order forces either a second pass or an invented identifier that resolves to nothing. Dependency order is not a style preference — it is what makes every foreign key resolve by construction. Customers no references generated first Orders reference a customer from the existing set Line items reference an order from the existing set Invoices reference both last, and safely Generating an order with an invented customer id produces a fixture set where half the joins silently return nothing.

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`);
Four relationship shapes Four cardinalities with the generation approach and the realism trap for each. One-to-many needs a varied count rather than a constant. Many-to-many needs a junction with duplicates avoided. Optional references need a proportion of nulls. Self-references need a depth cap or generation never terminates. Shape Generate by Realism trap one-to-many a varied child count per parent a constant three children each many-to-many a junction, deduplicated the same pair generated twice optional reference a fixed proportion of nulls always present, so absent is untested self-reference a bounded depth unbounded recursion Row three is the one that hides bugs: a dataset where every optional field is filled never renders the empty branch.

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.


Proving the graph holds together Three assertions worth running over any generated fixture set: every foreign key resolves to an existing record, no orphan records exist on the many side, and the cardinality distribution matches what production looks like. Each catches a distinct class of generation bug. Every reference resolves join each child to its parent catches invented identifiers No orphans every child has a live parent catches ordering mistakes Cardinality is plausible the child-count distribution catches a constant fan-out All three are a few lines over the generated JSON, and they run in the same job that produces it.

Cardinality is where realism actually lives

Once the references resolve, the remaining question is how many of each thing there are — and that number, more than any field value, determines whether the fixtures exercise the code that matters.

A constant fan-out is the default failure. Every customer with exactly three orders, every order with exactly two line items. The resulting dataset renders identically for every record, so the list view is only ever tested at one length, pagination never triggers, and the empty case never appears.

Real distributions are lopsided. Most customers have one or two orders; a few have dozens; one has several hundred. Most orders have a handful of line items; a rare one has enough to break a fixed-height container. Reproducing that shape costs a weighted draw rather than a constant, and it is what makes the awkward records appear without anyone having to write them.

Three cardinalities are worth setting deliberately rather than leaving to a default:

Zero. Every one-to-many relationship should produce some parents with no children at all, because the empty state is a real screen and it is the one most often unstyled.

One. Singular rendering frequently differs from plural — “1 order” against “3 orders”, a single item that skips a list entirely — and a dataset whose minimum is three never reaches it.

Far more than expected. One record near the top of the plausible range catches the layout, pagination and performance assumptions that hold for typical volumes and fail beyond them.

Setting those three explicitly, and letting the generator fill the middle, produces a dataset that looks unremarkable and exercises considerably more of the interface than a uniform one — for the cost of a weights table beside the factory.

← Back to Faker & Fixture Seeding