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:
Partial<User>allows callers to omit any field. Required fields become optional inoverrides; the defaults fill them in.- The defaults come first, the spread comes last. If you reverse the order,
overridesget clobbered by defaults — the most common mistake new factory authors make. - The return type is
User— notPartial<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 emailscustomise 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
emailoremailAddress. - Refactor safety. Add a field to
Userand 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 ofT. 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 asT. - 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 perbeforeEach) so every test gets a fresh value.
🎯 Practice task
Build a complete factory layer for a small test suite. 30-45 minutes.
- In your
ts-for-qa/srcfolder, createfactories.ts. - Define interfaces for
User,Product, andOrder(Order should referenceUser.idandProduct.id). - Write
createUser,createProduct, andcreateOrderfactories with sensible defaults. Use thecreateFactory<T>generic helper from the lesson. - Use
createMany<T>to generate 10 users and 20 products. Run withnpx ts-node src/factories.tsandconsole.loga few entries. - Compose: create an order with three line items pointing at real products from your
createManyoutput. Confirm the typing flows end to end. - 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
Userinterface — confirm the factory definition errors.
- Stretch: add a
seedparameter tocreateUserthat 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.