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