Data Factories and Dynamic Test Data

8 min read

The TypeScript course covered factories at the type-system level: a function that produces a typed object with sensible defaults, accepting overrides for the fields the test cares about. This lesson takes the same idea into a production Cypress framework — typed factories for users, products, and orders, dynamic uniqueness so parallel tests don't collide, API-seeded setup that's deterministic by construction, and the cleanup strategies that keep a shared staging database from filling with test debris.

Why factories beat fixed fixtures for setup

Fixtures are great for stub data — cy.intercept(url, { fixture: "products.json" }) works because every test wants the same 12 products. But fixtures are bad for seed data — every test that creates a real user via cy.request("POST", "/api/users", fixtureUser) runs into "email already exists" the second time it runs.

A factory solves the collision: produces a typed user with a unique-per-call email, accepts overrides for the fields the test specifically wants to set, leaves everything else at sensible defaults.

// cypress/utils/factories.ts
import type { User, Product } from "../support/types";
 
let counter = 0;
 
export function createUser(overrides: Partial<User> = {}): User {
  counter++;
  return {
    id: counter,
    name: `Test User ${counter}`,
    email: `testuser-${counter}-${Date.now()}@test.com`,
    role: "standard",
    ...overrides,
  };
}
 
export function createProduct(overrides: Partial<Product> = {}): Product {
  return {
    id: counter++,
    name: `Test Product ${counter}`,
    price: 9.99,
    category: "electronics",
    inStock: true,
    ...overrides,
  };
}

Three properties make this work:

  • Partial<User> overrides — the test names only the fields it cares about; the rest get defaults.
  • Spread order matters...overrides last so the test always wins over the default.
  • Date.now() + counter — guarantees uniqueness even across parallel CI workers.

The factory is the layer where "test data" becomes "test context."

Dynamic uniqueness

Two tests on two parallel CI workers both call createUser({ role: "admin" }) at the same millisecond. Without uniqueness, both produce testuser-1@test.com — the second cy.request("POST", "/api/users", ...) collides on the unique email constraint.

The combination of counter + Date.now() typically gives enough uniqueness:

email: `testuser-${counter}-${Date.now()}@test.com`,

Counter changes within the spec; Date.now() changes across specs. Two workers that do hit the same millisecond differentiate by counter; two specs that hit the same counter differentiate by timestamp.

For paranoid uniqueness or large parallel suites, swap in a UUID:

import { v4 as uuid } from "uuid";
 
email: `testuser-${uuid().slice(0, 8)}@test.com`,

@faker-js/faker is the other go-to — it generates plausible names, addresses, phone numbers, and credit cards that look like real data:

import { faker } from "@faker-js/faker";
 
export function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.number.int({ min: 100_000, max: 999_999 }),
    name: faker.person.fullName(),
    email: faker.internet.email().toLowerCase(),
    role: "standard",
    ...overrides,
  };
}

Faker-generated data is also useful when you want test data to look like the real product. A bug report screenshot showing "Test User 1" looks artificial; one showing "Marcus Hahn" looks like a real customer.

Using factories in specs

The factory replaces both inline objects and JSON fixtures in seed-style setup:

import { createUser, createProduct } from "../utils/factories";
import { getApiUrl } from "../utils/api";
 
describe("User profile", () => {
  it("displays the user's name on the profile page", () => {
    const user = createUser({ name: "Alice Smith", role: "admin" });
 
    cy.request("POST", getApiUrl("/test/users"), user);
    cy.sessionLogin(user.email, "TestPass1!");
 
    cy.visit("/profile");
    cy.get("[data-testid='user-name']").should("contain", "Alice Smith");
  });
});

Three lines of setup; one line of assertion. The factory is what makes the setup readable — a casual reader sees "create an admin user named Alice Smith" without parsing a 30-line fixture.

API-seeded setup with typed factories

The combination of factories and the cy.request patterns from chapter 4 is what most production Cypress suites converge on for setup:

beforeEach(() => {
  const user = createUser({ role: "admin" });
  cy.request<{ data: User }>("POST", getApiUrl("/test/users"), user)
    .its("body.data")
    .as("testUser");
  cy.sessionLogin(user.email, "TestPass1!");
});
 
it("displays the new admin in the user list", function () {
  cy.visit("/admin/users");
  cy.get("[data-testid='user-row']").should("contain", this.testUser.email);
});

The user is created via API (fast), aliased as this.testUser (typed via the generic on cy.request), and a session login attaches the auth cookie. The test body is now purely about the assertion — every other line was setup or framework boilerplate.

Factories for relationships

Real domains have entities that compose: an order has a user and a list of products. Factory composition keeps the spec clean:

export function createOrder(overrides: Partial<Order> = {}): Order {
  return {
    id: ++counter,
    userId: 1,
    items: [],
    total: 0,
    status: "pending",
    ...overrides,
  };
}
 
export function createOrderWithProducts(
  productCount = 2,
): { order: Order; products: Product[] } {
  const products = Array.from({ length: productCount }, () => createProduct());
  const order = createOrder({
    items: products.map((p) => ({ productId: p.id, quantity: 1 })),
    total: products.reduce((sum, p) => sum + p.price, 0),
  });
  return { order, products };
}
it("displays an order summary with three items", () => {
  const { order, products } = createOrderWithProducts(3);
  cy.request("POST", getApiUrl("/test/orders"), order);
 
  cy.visit(`/orders/${order.id}`);
  cy.get("[data-testid='order-item']").should("have.length", 3);
  products.forEach((p) => {
    cy.get("[data-testid='order-summary']").should("contain", p.name);
  });
});

The factory handles the relationship; the spec describes the assertion. Anything tricky (computing the order total, ensuring product IDs match) lives in the factory once.

Cleanup strategies

Three patterns, in increasing operational cost:

  • Database reset between runs. A CI-only cy.task that calls a "reset test database" endpoint before the suite. Best for isolated CI environments where you control the backend. Doesn't scale to shared staging environments.
  • Per-test cleanup in afterEach. Each test deletes the data it created. Works on shared environments, but slows the suite by ~10% and breaks if a test fails before reaching cleanup.
  • No cleanup, rely on uniqueness. Tests create unique-per-run rows; the database fills up over time but never collides. Good for short-lived environments; bad for long-lived staging without periodic pruning.

A pragmatic middle ground:

afterEach(function () {
  if (this.testUser?.id) {
    cy.request("DELETE", getApiUrl(`/test/users/${this.testUser.id}`), {
      failOnStatusCode: false,
    });
  }
});

failOnStatusCode: false so a 404 (data already gone) doesn't fail the test. Pair this with a nightly database-cleanup cron on the staging environment to catch anything that slipped through.

The factory-driven test flow

Step 1 of 5

Build test data

createUser({ role: 'admin' }) and createProduct({ price: 49.99 }) — typed objects with unique-per-call IDs.

A complete e-commerce factory file

The full slice as it would appear in a production project:

// cypress/utils/factories.ts
import { faker } from "@faker-js/faker";
import type { User, Product, Order, CartItem } from "../support/types";
 
let counter = 0;
 
export function createUser(overrides: Partial<User> = {}): User {
  counter++;
  return {
    id: counter + Date.now(),
    name: faker.person.fullName(),
    email: faker.internet.email().toLowerCase(),
    role: "standard",
    ...overrides,
  };
}
 
export function createProduct(overrides: Partial<Product> = {}): Product {
  counter++;
  return {
    id: counter + Date.now(),
    name: faker.commerce.productName(),
    price: parseFloat(faker.commerce.price()),
    category: faker.commerce.department().toLowerCase(),
    inStock: true,
    ...overrides,
  };
}
 
export function createCartItem(productId: number, quantity = 1): CartItem {
  return { productId, quantity };
}
 
export function createOrder(overrides: Partial<Order> = {}): Order {
  counter++;
  return {
    id: counter + Date.now(),
    userId: 1,
    items: [],
    total: 0,
    status: "pending",
    ...overrides,
  };
}
 
export function createOrderWithProducts(
  count = 2,
): { order: Order; products: Product[] } {
  const products = Array.from({ length: count }, () => createProduct());
  const order = createOrder({
    items: products.map((p) => createCartItem(p.id)),
    total: products.reduce((s, p) => s + p.price, 0),
  });
  return { order, products };
}

Five factories, one composition helper, every value typed. Any spec in any chapter of this course can now seed an admin user with three products and a draft order in three lines.

⚠️ Common mistakes

  • Building a factory that takes thirty arguments. Factories should accept one argument: a Partial<T> of overrides. Tests pass only what they care about; defaults handle the rest. A function with thirty positional parameters is a sign the abstraction is wrong.
  • Generating non-unique data. A factory that returns the same email every call works once and fails the second time. Date.now(), faker.internet.email(), or UUID — pick one and use it consistently.
  • Putting Cypress chains inside factory functions. Factories should be pure — given the same overrides, they return the same shape. Calling cy.request inside a factory mixes synchronous data construction with async test execution and produces confusing race conditions. Build the data with the factory; use cy.request outside it to send.

🎯 Practice task

Build a complete e-commerce factory layer. 30-40 minutes.

  1. Install faker: npm install --save-dev @faker-js/faker. Add import { faker } from "@faker-js/faker" to cypress/utils/factories.ts.
  2. Define typed factories for User, Product, CartItem, and Order matching the interfaces in cypress/support/types.ts (chapter 9, lesson 2).
  3. Add a composition factory createOrderWithProducts(count: number) that returns both the order and the underlying product list.
  4. Refactor one of your existing checkout specs to use the factories for setup. The spec should look something like: const { order, products } = createOrderWithProducts(3); cy.request("POST", ...); — three lines of setup, then assertions.
  5. Uniqueness drill — run the same spec ten times in a row. Confirm no "email already exists" or "product ID conflict" errors appear.
  6. Add cleanup — wire an afterEach that deletes created rows via API. Use failOnStatusCode: false to ignore 404s. Confirm the database doesn't accumulate test rows after ten runs.
  7. Stretch: make a typed cy.seedFromFactory<T> custom command that combines factory + API seed in one call: cy.seedFromFactory(createUser, { role: "admin" }) should hit the right endpoint and yield the typed result. The command keeps the factory + API pattern down to a single line in every spec.

The last lesson of chapter 9 (and of the framework portion of the course) covers maintenance — the habits that keep a 200-spec, six-month-old test suite trustworthy instead of letting it drift into the half-ignored test graveyard every long-running project produces.

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