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 secondbuildCommerceGraph()returnsorder_0009instead oforder_0001. Reset all three, as the composer does. -
flatMaporder is load-bearing. Orders are built by iterating users in array order, soorders[0]andorders[1]belong tousers[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. TreattotalCentsandsubtotalCentsas computed-only; if a test needs a specific total, adjust the line items that feed it rather than the aggregate.
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.
Related
- Building a Reusable Fixture Factory — the generic factory these entities are built on
- Faker & Fixture Seeding — parent overview of seeded, reusable fixtures
- Deterministic Seed Management — why the ids stay stable across runs
← Back to Faker & Fixture Seeding