Type-Safe Test Data Factories

9 min read

You need fifty users for a load test. You need an admin, a tester, and a viewer for a permissions matrix. You need an order with three line items where the customer's billing address is in Germany and the discount applies. Hand-writing every test object is tedious and the moment one field is wrong, the test fails in misleading ways. Test data factories are the antidote: small functions that produce typed defaults you can override with one or two relevant fields. Combine them with Partial<T> and you have the cleanest test data layer most teams will ever need.

The pattern in one function

interface User {
  id: number;
  name: string;
  email: string;
  role: "admin" | "tester" | "viewer";
  isActive: boolean;
  createdAt: string;
}
 
function createUser(overrides: Partial<User> = {}): User {
  const id = Math.floor(Math.random() * 10_000);
  return {
    id,
    name:      `Test User ${id}`,
    email:     `user${id}@test.com`,
    role:      "tester",
    isActive:  true,
    createdAt: new Date().toISOString(),
    ...overrides,   // spread overrides LAST so they win
  };
}

Three details that make this work:

  1. Partial<User> allows callers to omit any field. Required fields become optional in overrides; the defaults fill them in.
  2. The defaults come first, the spread comes last. If you reverse the order, overrides get clobbered by defaults — the most common mistake new factory authors make.
  3. The return type is User — not Partial<User>. Every caller gets a fully formed value, even if they only specified one field.

Using the factory

const defaultUser = createUser();
// → { id: 4291, name: "Test User 4291", email: "user4291@test.com", role: "tester", isActive: true, ... }
 
const admin = createUser({ role: "admin" });
// → same defaults, but role: "admin"
 
const inactive = createUser({ isActive: false, name: "Deleted User" });
// → defaults + isActive false + custom name
 
const broken = createUser({ banana: 42 });
// ❌ Object literal may only specify known properties, and 'banana' does not exist in type 'Partial<User>'.
 
const wrongRole = createUser({ role: "owner" });
// ❌ Type '"owner"' is not assignable to type '"admin" | "tester" | "viewer"'.

Every override is checked against the User interface. Typos in field names are rejected. Wrong values for literal-union fields are rejected. The test code that calls the factory has the same protection as the interface itself.

A generic factory builder

The pattern is so common that it pays to lift it once and reuse it for every entity:

function createFactory<T>(defaults: T): (overrides?: Partial<T>) => T {
  return (overrides = {}) => ({ ...defaults, ...overrides });
}
 
interface Product {
  id: number;
  sku: string;
  name: string;
  price: number;
  category: "electronics" | "books" | "clothing";
  inStock: boolean;
}
 
const createProduct = createFactory<Product>({
  id:       0,
  sku:      "TEST-PRODUCT",
  name:     "Test Product",
  price:    9.99,
  category: "electronics",
  inStock:  true,
});
 
const cheapBook = createProduct({ category: "books", price: 4.99 });
const oosWidget = createProduct({ inStock: false });

createFactory is a higher-order function — you give it the defaults once and it returns a function that knows how to merge overrides. The <T> parameter carries the type through to the returned function. One generic, every factory you'll ever need.

Building many at once

Some tests need a list — fifty users, a hundred orders, a paginated dataset. A small createMany helper handles it:

function createMany<T>(
  factory: (overrides?: Partial<T>) => T,
  count: number,
  customise?: (i: number) => Partial<T>,
): T[] {
  return Array.from({ length: count }, (_, i) =>
    factory(customise?.(i) ?? ({} as Partial<T>)),
  );
}
 
const users = createMany(createUser, 50);
// 50 users, all with random ids and default fields
 
const numberedUsers = createMany(createUser, 5, (i) => ({
  id:    i + 1,
  name:  `User ${i + 1}`,
  email: `user${i + 1}@test.com`,
}));
// 5 users with sequential ids and predictable emails

customise is optional — pass it when you need to vary fields per index. The compiler still checks every override against Partial<T>, so you can't sneak a typo into the per-index customisation.

Composing factories — orders, line items, customers

Real test data has relationships. An order needs a customer id and a list of product ids. A line item needs a product id and a quantity. The cleanest pattern: factories that take typed references and produce typed values.

interface LineItem { productId: number; quantity: number; priceAtPurchase: number }
interface Order   { id: number; customerId: number; items: LineItem[]; total: number; status: "pending" | "paid" | "shipped" }
 
const createLineItem = createFactory<LineItem>({
  productId:       0,
  quantity:        1,
  priceAtPurchase: 9.99,
});
 
function createOrder(overrides: Partial<Order> = {}): Order {
  const id = Math.floor(Math.random() * 10_000);
  return {
    id,
    customerId: 1,
    items:      [createLineItem()],
    total:      9.99,
    status:     "pending",
    ...overrides,
  };
}
 
const cart = createOrder({
  customerId: alice.id,
  items: [
    createLineItem({ productId: book.id,  quantity: 1, priceAtPurchase: book.price }),
    createLineItem({ productId: shirt.id, quantity: 2, priceAtPurchase: shirt.price }),
  ],
});

Each factory produces a fully formed value with sensible defaults. Each layer composes into the next. Wrong types — passing a string customerId or an items value that isn't an array — are caught at compile time.

How a factory call flows

Step 1 of 4

Defaults

createUser() — the factory starts with the canonical default object: id, name, email, role, etc.

Why factories pay off in TypeScript

Three concrete wins:

  • Autocomplete on overrides. Callers see exactly which fields are available — no guessing whether the field is email or emailAddress.
  • Refactor safety. Add a field to User and every factory's return type updates. Forgot to provide a default for the new field? Compile error in the factory definition, not at midnight in CI.
  • Closed value sets. role: "admin" | "tester" | "viewer" rejects "owner". Hand-written test data has no such protection — typos creep in and tests pass against garbage data.

A QA-shaped suite of factories

// factories/index.ts
import { type User, type Product, type Order } from "../types";
 
export const createFactory = <T>(defaults: T) =>
  (overrides: Partial<T> = {}): T => ({ ...defaults, ...overrides });
 
export const createUser    = createFactory<User>({   /* ... */ } as User);
export const createProduct = createFactory<Product>({ /* ... */ } as Product);
export const createOrder   = createFactory<Order>({   /* ... */ } as Order);
 
// in a test
import { createUser, createOrder } from "../factories";
 
const alice = createUser({ role: "admin" });
const order = createOrder({ customerId: alice.id, total: 49.99 });

A handful of one-line factories at the top of the file, every test reads as a description of intent — "an admin user, an order for forty-nine ninety-nine, paid by Alice." No noise, no duplication, no compile errors when the underlying interfaces evolve.

⚠️ Common mistakes

  • Spreading defaults after overrides. { ...overrides, ...defaults } is the bug-magnet — overrides get clobbered and the override "doesn't take effect." Always: defaults first, spread overrides at the end.
  • Returning Partial<T> instead of T. Easy slip when you copy-paste the parameter type into the return type. Callers then have to narrow every field before using it. The factory's whole point is to produce a complete value — annotate the return as T.
  • Sharing one factory's output across tests by reference. const sharedUser = createUser(); at module level looks like an optimisation but creates flakiness — one test mutates a field, the next test sees the mutation. Call the factory inside each test (or per beforeEach) so every test gets a fresh value.

🎯 Practice task

Build a complete factory layer for a small test suite. 30-45 minutes.

  1. In your ts-for-qa/src folder, create factories.ts.
  2. Define interfaces for User, Product, and Order (Order should reference User.id and Product.id).
  3. Write createUser, createProduct, and createOrder factories with sensible defaults. Use the createFactory<T> generic helper from the lesson.
  4. Use createMany<T> to generate 10 users and 20 products. Run with npx ts-node src/factories.ts and console.log a few entries.
  5. Compose: create an order with three line items pointing at real products from your createMany output. Confirm the typing flows end to end.
  6. Trigger every override check. After each, read the error and revert:
    • createUser({ banana: 42 })
    • createUser({ role: "owner" })
    • createOrder({ customerId: "user-1" }) (wrong type)
    • Forget to provide a default for a newly added required field on the User interface — confirm the factory definition errors.
  7. Stretch: add a seed parameter to createUser that produces deterministic ids and emails (createUser({ seed: 5 }) always produces user 5). Useful when you need stable test data for snapshot or screenshot tests.

That's chapter 7 — and the substantive content of the course. You can now write production-quality TypeScript test code: typed Cypress and Playwright projects, page objects, fixtures, and the factory layer that produces every test value. The capstone next chapter ties it all together into a complete framework.

// tip to track lessons you complete and pick up where you left off across devices.