Test doubles and test data — Examples
Example 1 — A test that pins the call sequence
The test below uses a mock and asserts which repository methods ran. Say what it will do the next time the unit is restructured, then replace the mock with a fake and assert on the result.
test("saves a new order", async () => {
const repo = { insert: vi.fn(), update: vi.fn(), findByKey: vi.fn() };
const service = new OrderService(repo);
await service.place(makeOrder({ key: "abc" }));
expect(repo.findByKey).toHaveBeenCalledWith("abc");
expect(repo.insert).toHaveBeenCalledTimes(1);
expect(repo.update).not.toHaveBeenCalled();
});
Solution
class InMemoryOrders {
rows: Order[] = [];
findByKey(key: string) { return this.rows.find((r) => r.key === key) ?? null; }
insert(order: Order) { this.rows.push(order); return order; }
update(order: Order) { Object.assign(this.rows[0], order); return order; }
}
test("stores a placed order under its key", async () => {
const repo = new InMemoryOrders();
await new OrderService(repo).place(makeOrder({ key: "abc" }));
expect(repo.findByKey("abc")).not.toBeNull();
});
The mock version describes the current implementation: reorder the lookup and the insert and it fails even though the order is stored. A fake with real, tiny behaviour lets the test ask the question a caller would ask — is the order retrievable under its key — so the three call expectations collapse into one outcome assertion. The trap is that a mock is quicker to write than a fake, so call assertions accumulate until the test file itself becomes the reason nobody refactors.
Example 2 — A fixture copied five times
The order literal below is repeated in five tests, with one field changed in each. Say what a schema change costs here, then replace it with a factory that states only what each test cares about.
const order = {
id: 4471, key: "abc", currency: "GBP", total: 2500,
customer: { id: 9, email: "a@b.test", tier: "gold" },
lines: [{ sku: "book", price: 1000, qty: 2 }],
status: "draft",
}; // also copied with totals 900, 0, 5000 and 1200
Solution
const makeOrder = (overrides: Partial<Order> = {}): Order => ({
id: 4471, key: "abc", currency: "GBP", total: 2500,
customer: { id: 9, email: "a@b.test", tier: "gold" },
lines: [{ sku: "book", price: 1000, qty: 2 }],
status: "draft",
...overrides,
});
// each test now states only its own case
const order = makeOrder({ total: 900 });
One definition holds the canonical example, so a new required field is one edit rather than five, and every test reads as a statement of what matters to it. The trap is the shallow spread: overriding customer replaces the whole nested object, so a test changing one nested field must repeat its siblings — keep nested literals out of overrides unless the test is genuinely about all of them.