Building a Typed Test Data Builder API
The best test data is the data a test states for itself: anOrder({ total: 101 }) tells a reader that the total is what this test is about and that nothing else matters. The worst is a shared fixture object that forty tests depend on in forty different ways, where changing one field to fix one test breaks three others. A builder API is how you get the first without giving up on realistic, valid objects. This guide covers designing builders that produce minimal valid entities, typed overrides that survive refactors, relationship helpers that keep foreign keys consistent, and a check that keeps builders honest against the schema. It sits under test data management.
Root Cause Analysis
Shared fixture objects fail because they serve incompatible purposes. A test about discounting needs a particular total; a test about shipping needs a particular address; a test about validation needs a missing field. One object cannot be all three, so it grows fields nobody understands and acquires values that exist only to satisfy some other test’s assertion. Changing it becomes frightening, which is the surest sign that the abstraction is wrong.
The second failure is at the other extreme: constructing entities inline in every test. This states the setup clearly but produces enormous test bodies, and when the model gains a required field, every test that constructs one breaks at once — which is how teams end up with a shared fixture in the first place.
A builder resolves the tension by separating the two concerns. Defaults live in one place and absorb model changes; overrides live in the test and state its intent. The design work is in making the defaults minimal and valid, and in making overrides expressive enough that tests rarely need to reach past them.
Reproducible Setup
Start from the real types, so the builder cannot drift from the model without the compiler noticing.
// src/domain/types.ts
export type Money = { amountPence: number; currency: 'GBP' | 'USD' | 'EUR' };
export type OrderLine = { sku: string; quantity: number; unitPrice: Money };
export type Order = {
id: string;
customerId: string;
status: 'draft' | 'placed' | 'shipped' | 'cancelled';
lines: OrderLine[];
total: Money;
placedAt: Date | null;
};
npm install -D @faker-js/faker
Implementation
Step 1 — Make the default minimal and valid. Minimal means the fewest fields with meaningful values; valid means it would pass the model’s own validation. Every default that is not required is a value some test will accidentally depend on.
// test/builders/order.ts
import type { Order, OrderLine, Money } from '../../src/domain/types';
let seq = 0;
const nextId = (prefix: string) => `${prefix}_${(++seq).toString().padStart(6, '0')}`;
export const gbp = (amountPence: number): Money => ({ amountPence, currency: 'GBP' });
export function anOrder(overrides: Partial<Order> = {}): Order {
const lines = overrides.lines ?? [anOrderLine()];
return {
id: nextId('ord'),
customerId: nextId('cus'),
status: 'draft',
lines,
total: gbp(lines.reduce((n, l) => n + l.unitPrice.amountPence * l.quantity, 0)),
placedAt: null,
...overrides,
};
}
export function anOrderLine(overrides: Partial<OrderLine> = {}): OrderLine {
return { sku: nextId('sku'), quantity: 1, unitPrice: gbp(1000), ...overrides };
}
Sequential identifiers rather than random ones are a deliberate choice: they make failure output readable and reproducible, which random UUIDs do not.
Step 2 — Derive dependent fields, but let overrides win. The total should follow the lines by default, so a test that changes the lines does not have to restate the total; a test that wants an inconsistent total for a validation case can still say so.
// overrides are spread last, so this test gets exactly what it asked for
const inconsistent = anOrder({ lines: [anOrderLine({ quantity: 2 })], total: gbp(1) });
Step 3 — Add named states for the combinations that recur. These are compositions of the base builder, not new builders, so they inherit every future model change automatically.
// test/builders/order.ts
export const aPlacedOrder = (o: Partial<Order> = {}) =>
anOrder({ status: 'placed', placedAt: new Date('2026-03-01T10:00:00Z'), ...o });
export const aCancelledOrder = (o: Partial<Order> = {}) =>
aPlacedOrder({ status: 'cancelled', ...o });
export const aLargeOrder = (o: Partial<Order> = {}) =>
anOrder({ lines: Array.from({ length: 40 }, () => anOrderLine()), ...o });
Step 4 — Keep relationships consistent with a graph helper. When two entities must agree on a key, express that once rather than in every test.
// test/builders/graph.ts
import { aCustomer } from './customer';
import { anOrder } from './order';
export function aCustomerWithOrders(count = 2, order: Partial<Order> = {}) {
const customer = aCustomer();
const orders = Array.from({ length: count }, () => anOrder({ customerId: customer.id, ...order }));
return { customer, orders };
}
Step 5 — Keep builders pure and synchronous. A builder that writes to a database conflates constructing an object with persisting it, and makes every test that needs a shape pay for a round trip. Keep a separate persist step for the tests that need it.
// pure: returns an object
const order = aPlacedOrder({ total: gbp(9900) });
// separate: persists whatever you hand it
await persist(order);
Step 6 — Guard against drift with a validation test. A builder that produces an entity the real validator rejects is worse than no builder, because every test built on it is testing an impossible state.
import { test, expect } from 'vitest';
import { orderSchema } from '../../src/domain/schema';
import { anOrder, aPlacedOrder, aCancelledOrder, aLargeOrder } from './order';
test.each([
['default', anOrder()],
['placed', aPlacedOrder()],
['cancelled', aCancelledOrder()],
['large', aLargeOrder()],
])('the %s builder produces a valid order', (_name, order) => {
expect(() => orderSchema.parse(order)).not.toThrow();
});
Verification
Verify the builder absorbs a model change rather than propagating it. Add a required field to the type, and confirm the compiler points at one file.
# add `channel: 'web' | 'app'` to Order, then:
npx tsc --noEmit
# test/builders/order.ts:14:3 - error TS2741: Property 'channel' is missing
# (exactly one error — every test keeps compiling once the default is added)
Then verify that tests state their own intent, which is the readability payoff. A quick scan for tests that override many fields highlights places where a named state is missing.
grep -rnoE "anOrder\(\{[^}]{80,}\}" --include="*.test.ts" . | head
# src/features/refunds/refund.test.ts:22: anOrder({ status: 'shipped', placedAt: …, lines: …, total: … })
# ← four overrides suggests aShippedOrder() should exist
Finally, verify determinism. Two runs must produce identical objects, or failures will not reproduce; this is the same requirement as deterministic seeding for test data in Vitest, and it is why the sequence counter is reset between files rather than seeded from the clock.
import { beforeEach } from 'vitest';
import { resetSequence } from './builders/order';
beforeEach(() => resetSequence());
Troubleshooting
Symptom: tests break when an unrelated default changes. Diagnosis: they depend on a default they never stated, which is the shared-fixture problem reappearing inside the builder. Fix: state the dependency in the test as an override. A test that asserts on the total must set the lines or the total explicitly, whatever the default happens to be.
Symptom: the builder file has grown to hundreds of lines of special cases. Diagnosis: named states have been added for every test rather than for recurring combinations. Fix: keep a state only when three or more tests use it; otherwise the overrides belong in the test, where they document its intent.
Symptom: builders produce entities the API rejects. Diagnosis: the defaults drifted from the schema — a new required field, a narrowed enum. Fix: the validation test from Step 6 catches this on the next run; if it is already failing, the builder is telling you about a real inconsistency rather than a test problem.
Symptom: identifiers collide between tests. Diagnosis: the sequence resets per file but tests run in parallel against a shared store. Fix: include the worker index in the prefix, following the namespacing approach in isolating end-to-end tests with per-worker data.
FAQ
Should builders use random data?
Prefer deterministic values with a sequence, and reach for randomness only when the test is genuinely about handling arbitrary input. Random values make failures irreproducible and tempt tests into asserting on things they did not set. Where realistic-looking values help readability, generate them from a seeded source rather than an unseeded one.
Class-based builders with fluent methods, or functions with overrides?
Functions with a Partial<T> override are shorter, type-check for free, and compose without inheritance. Fluent builders read nicely for long constructions but need a method per field and tend to drift from the type. Unless you have a strong reason, the function form is the one that stays cheap.
Where should builders live in a monorepo?
In a shared test-utilities package, so every consumer gets the same defaults and the same validation test. That package deserves its own tests and ownership, as described in versioning test utilities as an internal package.
Do builders replace fixtures entirely?
Nearly. What survives is the case where realistic volume or a real-world payload is the point — a large API response, a production-shaped dataset. Those belong in files, ideally small ones; everything else is better built, which is the comparison drawn in factory functions vs fixtures in Vitest.
Related
- Back to Test Data Management
- Factory functions vs fixtures in Vitest — when each approach fits.
- Deterministic seeding for test data in Vitest — keeping generated values reproducible.
- Keeping large fixture files out of the repo — what to do with the fixtures builders cannot replace.