Test Data & Factories

core20 min

In one line

A factory produces a valid object with sensible defaults and lets each test override only the fields it cares about — which makes the intent of the test the only thing visible in the diff.

What it is

The pattern that does not scale is the shared fixture: one mockUser imported everywhere. Adding a required field means editing every test file; a test that needs an admin either mutates the shared object — leaking into other tests — or copies it, and now there are two. Neither works past a small suite.

A factory inverts it. userFactory.build() returns a complete, valid user; userFactory.build({ role: 'admin' }) returns one that differs in exactly the way the test cares about. When the schema gains a field, one default changes and every test keeps compiling. And the override list documents the test: a reader sees immediately that role is the variable under test and nothing else is.

Two rules make the defaults right. Sensible, valid, and boring — a factory that produces edge cases by default makes every test implicitly an edge-case test. And deterministic where it matters: random names are fine, but random ids and dates make failures irreproducible, so use incrementing sequences and a fixed clock. Faker with a seeded generator gives realistic data without irreproducibility.

Relationships need care. orderFactory.build() should produce a valid order with a user; whether it builds a nested object or takes an id depends on the shape the code expects. Keep it shallow — deep graphs generated by default make tests slow and obscure.

Derive from the schema where possible. If a Zod schema defines the API contract, generating factory defaults from it — or at minimum validating factory output against it — keeps fixtures from drifting into shapes the server would never produce. A test suite full of impossible objects gives confidence in the wrong thing.

Share factories with MSW handlers, so a mocked endpoint returns the same shapes the tests build. One definition of what a user looks like across the whole suite.

For end-to-end tests, the same idea applies through the API: create the required data via a setup request with a unique identifier per run rather than depending on a seeded account that other tests also touch.

Why it matters

Fixture maintenance is a large hidden cost in a mature suite, and shared mutable fixtures are a classic source of order-dependent failures that appear only in CI.

Readability is the other half: a test whose setup is twenty lines of object literal hides its own point.

Key points

  • Factories give valid defaults plus per-test overrides, so a schema change touches one file.
  • The override list is documentation — it shows exactly what the test varies.
  • Defaults should be boring and valid; edge cases belong in explicit overrides.
  • Use sequences for ids and a fixed clock for dates so failures are reproducible.
  • Keep relationships shallow; deep default graphs make tests slow and opaque.
  • Generate or validate factory output against the real schema so fixtures cannot drift.
  • Share factories with MSW handlers, and create end-to-end data via the API with unique identifiers.