Skip to content
advanced Phase 8 · Page Object Patterns

Fixtures and Test Data Layers

Provide per-test setup, factories with overrides, isolation, and reliable cleanup.

1h 15m
0 problems
Topic Progress 0%

Fixtures That Hand Each Test a Ready, Authenticated World

The suite passes solo and fails in CI; passes this month and breaks when a demo account gets deleted by support. Both failures are test-data failures, and both trace to the same root cause: tests reaching for shared, pre-existing state instead of receiving their own. A fixture is the framework-native answer — a piece of setup that creates what the test needs, hands it over, and guarantees cleanup, so the test's only concern is the behavior it verifies.

// Playwright fixture: an authenticated page, fresh per test
export const test = base.extend<{ authedPage: Page; user: TestUser }>({
  user: async ({ request }, use) => {
    const user = await createUserViaApi(request, { plan: 'pro' }); // fast path: API, not UI
    await use(user);                       // test body receives the created user
    await deleteUserViaApi(request, user.id);  // teardown always runs
  },
  authedPage: async ({ browser, user }, use) => {
    const ctx = await browser.newContext();
    const page = await ctx.newPage();
    await loginViaUi(page, user);          // UI login only where UI login IS the subject
    await use(page);
    await ctx.close();
  },
});

test('pro user sees the invoice export button', async ({ authedPage }) => {
  await authedPage.goto('/billing');
  await expect(authedPage.getByRole('button', { name: 'Export invoices' })).toBeVisible();
});

Read the fixture's shape, because it is the same in every framework: create → yield → destroy. Playwright's use() marks the hand-off point; everything after it runs in teardown even when the test throws. pytest expresses it with yield in a fixture function, JUnit 5 with @AfterEach cleanup or extension callbacks, Selenium suites with base-class hooks. The invariant that matters: teardown is guaranteed by the framework, not by discipline — a cleanup that relies on the test author remembering is a cleanup that leaks.

Create data at the cheapest level that is still honest. UI-driven setup is slow (thirty seconds per account) and shares failure modes with the UI; API-driven setup is fast (a POST) but skips whatever the UI's create-flow would have validated. The standard split: use the API to arrange, the UI to act and assert — the test's subject dictates the level, everything else uses the fastest reliable path. A user needed for a billing test gets created by API; a test about the signup flow drives the signup UI. This principle recurs across the roadmap: arrange at the API level, verify at the level where the behavior lives, and your suite's median runtime halves.

Scope and freshness complete the design. Fixtures default to per-test (fresh world each time) because it is the only scope that survives parallelism and retries; per-suite or per-worker scoping is an explicit opt-in for genuinely expensive setup (a seeded catalog), always read-only from the test's perspective. And fixtures should fail loudly when the environment cannot deliver: a missing test flag or an unseedable backend should abort with a named message, not let the test run halfway and fail with a misleading assertion. With per-test worlds in place, the remaining question is what those worlds contain — the objects factories build, and how each test states only what it changes.

Factories with Overrides: Tests State Only Their Differences

A login form has six fields; the suite needs users with a pro plan, with an expired card, with two-factor enabled. Copy-pasting a six-field builder into each test produces thirty near-identical blocks that all drift when field seven is added. The factory-with-defaults pattern fixes this the way good APIs fix everything: sensible defaults, explicit overrides, one function per object type.

// factories/user.ts
let seq = 0;
export async function makeUser(overrides: Partial<UserInput> = {}): Promise<TestUser> {
  const input: UserInput = {
    email: `qa+${Date.now()}-${++seq}@example.test`,  // unique by default
    password: 'Temp!Passw0rd',
    plan: 'free',
    country: 'US',
    ...overrides,                    // the test states ONLY its differences
  };
  return createUserViaApi(input);
}

// tests state differences, not recipes
test('expired card shows a retry banner', async ({ page }) => {
  const user = await makeUser({ plan: 'pro', card: 'expired' });
  // ...
});
test('two-factor user is challenged at login', async ({ page }) => {
  const user = await makeUser({ twoFactor: true });
  // ...
});

The pattern's power is in what disappeared: every test that does not care about country or password never mentions them, and adding field seven means editing the factory, not thirty tests. It also documents the default world — reading makeUser() tells a new engineer what a typical user looks like, which is onboarding documentation that cannot go stale silently, because a stale factory breaks the tests that rely on defaults first.

Three design rules keep factories honest. First, uniqueness is the factory's job, not the caller's — a timestamp-plus-sequence-plus-optional-suffix email means two parallel workers and a re-run against the same staging database never collide. Callers should not be able to forget this; when a test must pin an identity (a webhook fixture needs a known address), that is an explicit override, visible in the call. Second, factories compose: makeOrder({ customer: await makeUser({ plan: 'pro' }) }) builds graphs without any factory knowing another's internals, and nested factories inherit the uniqueness discipline automatically. Third, deterministic randomness: seed the random source (or use a monotonic counter) so a failing run's data can be reconstructed; unseeded Math.random() makes "what exact object did test 47 create?" an unanswerable question during triage.

Personas are named factory presets — the bridge to the fluent API's asNewCustomer from Fluent Page APIs:

export const personas = {
  newCustomer: () => makeUser({ plan: 'free', orders: 0 }),
  proSubscriber: () => makeUser({ plan: 'pro', card: 'valid' }),
  dunningUser: () => makeUser({ plan: 'pro', card: 'expired' }),
};

Personas are worth their abstraction exactly when the combination is meaningful in product language ("dunning user" is a business state no single field expresses); a persona that just renames one field adds a hop without adding meaning. Whichever layer a test uses, the guarantee is the same: every test starts from a documented default world and changes only what its scenario is about — which leaves one hard problem unsolved: what happens to all these objects when the run ends, and what breaks when a hundred tests create them in parallel.

Isolation and Cleanup: Surviving Parallel Runs Without Polluting the World

The suite creates a hundred records per run against shared staging, and six months later staging is a swamp: list pages paginate forever, search returns test garbage, and a nightly job that aggregates orders is drowned in phantom revenue. Parallel runs make it worse — two workers creating customer@example.com at the same instant collide, one fails with a unique-constraint error, and the report blames a "flaky" test. Both failures share a root: data with no namespace and no exit plan. The fixes are paired — isolation for creation, cleanup for exit — and they fail independently, so each needs its own mechanism.

Isolation: make every created object unambiguously yours. The standard techniques, in order of reliability:

1. Unique identifiers per run+worker   qa+<runid>-<worker>-<seq>@example.test
   (runid = CI run number or timestamp; survives parallelism, greppable in logs)
2. Namespaced metadata                  every record tagged run_id — bulk queries,
   audits, and cleanup need no guessing
3. Tenant-per-run                      where the app supports it: one org per run,
   deleted wholesale (strongest isolation, best for multi-tenant apps)
4. Per-run environment                  ephemeral preview env per pipeline — strongest,
   most expensive; reserved for suites where shared staging lies too often

The greppable-run-id property pays twice: during the run, log correlation is trivial; after the run, DELETE FROM orders WHERE run_id = ? is all cleanup needs. Avoid the opposite approach — a shared testuser@example.com mutated by every test — which is exactly the collision that produces "works alone, fails in parallel", the order-dependence class from Debugging Failing Tests, now prevented at the data layer.

Cleanup: choose a strategy and be honest about its cost.

Strategy Mechanism Trade-off
Fixture teardown delete in the same test's after-hook free, precise; misses crashed runs
Sweeper job periodic delete of run_id < now-2d catches leaks; records linger up to the window
Reset endpoint truncate/reseed between runs fastest clean slate; only for disposable envs
Ephemeral env create/destroy whole environment total isolation; minutes of provision + cost

The pragmatic default for most teams: per-test fixture deletion plus a scheduled sweeper as the safety net — the sweeper converts "test crashed before cleanup" from a permanent leak into a two-day artifact. One caveat that saves hours of debugging: delete children before parents (order lines before orders before users), or foreign keys will make your cleanup itself the failure. And cleanup must be idempotent — re-running it, or running it when the object was already deleted, must be a no-op, because it will run in exactly the situations where something else already went wrong.

Measure the swamp before and after. Track records-created-per-run as a suite metric; when it grows faster than test count, someone is creating data in loops or leaking. Pair it with a run_id-scoped inventory query on staging, and the data layer becomes as observable as the tests themselves. The end state of this whole phase: specs that read as product journeys (fluent APIs), on a predictable world (fixtures and factories), in an environment that stays clean under parallelism — the foundation every framework topic beyond this one, API testing included, builds on.

Quiz

1. What is the essential lifecycle shape of a per-test fixture?

Question 1 options

2. Why does makeUser({ plan: 'pro', card: 'expired' }) beat an inline six-field user object in the test?

Question 2 options

3. A CI job crashes mid-run and leaves 40 orphaned records on shared staging. What pair of mechanisms prevents both the immediate leak and its accumulation?

Question 3 options

4. When should a test create its user through the signup UI instead of a fixture API call?

Question 4 options

Flashcards

Question

What does an authedPage fixture's create→yield→destroy look like?

Answer

Per-test fixture delivering a fresh authenticated page and its user: create (API signup) → yield via use()/yield (test body) → destroy (delete user, close context). Teardown is framework-guaranteed even on throw. Default scope is per-test because it is the only scope that survives parallelism and retries.

Question

The factory-with-overrides pattern and its three design rules.

Answer

makeUser(overrides) with documented defaults and spread-override semantics: tests state only differences, one factory edit updates the whole suite, and the factory (not the caller) owns uniqueness (timestamp+sequence emails). Factories compose (makeOrder({ customer: makeUser(...) })) and randomness is seeded for reproducibility.

Question

How is test data isolated per run and per worker?

Answer

Unique run-scoped identifiers (qa+<runid>-<worker>-<seq>@...) so parallel workers and re-runs never collide, plus run_id metadata on every record for greppability and bulk cleanup; tenant-per-run for multi-tenant apps; ephemeral envs when shared staging lies too often. The shared mutated account is the anti-pattern this replaces.

Question

Cleanup strategies and the correct default pair.

Answer

Fixture teardown (precise, free — but misses crashed runs) + scheduled sweeper on run_id (catches leaks, records linger within the window) is the pragmatic default; reset endpoints for disposable envs; ephemeral environments for total isolation at provision cost. Cleanup must be idempotent and delete children before parents (FK order).

Question

The arrange-via-API / verify-via-UI principle, and when it does not apply.

Answer

Arrange at the fastest reliable level (API), act and assert at the level where the behavior lives (usually UI) — this halves median runtime and separates arrange failures from subject failures. Exception: when the creation flow itself is the subject, drive the UI for the arrange step too.

Revision Notes

Key Takeaways

  • 1. Fixtures deliver a fresh, authenticated world per test with framework-guaranteed teardown — shared accounts are the root of parallel and order-dependence failures
  • 2. Factories with defaults and overrides make tests state only their differences, centralize uniqueness, and double as living documentation of the default world
  • 3. Isolation is namespacing: run-scoped unique ids plus run_id metadata turn collisions into impossibilities and cleanup into one query
  • 4. Cleanup is a system, not a habit: fixture deletion plus a scheduled sweeper covers crashes; idempotence and FK order keep cleanup from failing itself
  • 5. Arrange at the cheapest honest level (API) and verify where the behavior lives (UI) — the level rule that halves median runtime

Interview Tips

  • • "How do you manage test data?" — the three-layer answer: fixtures (lifecycle), factories (shape+uniqueness), isolation/cleanup (namespacing+sweeper); naming the create→yield→destroy guarantee shows the mechanism
  • • "Tests pass alone, fail in parallel — data angle?" — shared mutable account or non-unique identifiers; the fix is run-scoped unique ids and per-test worlds, tying it to order-dependence diagnosis
  • • "How do you clean up test data?" — fixture teardown + sweeper as the honest default, idempotent and child-before-parent; mention metrics (records per run) to detect leaks
  • • "When do you use UI vs API setup?" — arrange via API for speed and separation, but drive the UI when the creation flow is the subject; state it as the level rule, not a preference

Cheat Sheet

Fixtures & Test Data Layers

Fixture lifecycle — create → yield → destroy (framework-guaranteed teardown):

user: async ({ request }, use) => {
  const user = await createUserViaApi(request, { plan: 'pro' });
  await use(user);                       // test body
  await deleteUserViaApi(request, user.id);  // runs even on throw
}

pytest: yield in fixture · JUnit: AfterEach/extension · Selenium: base-class hooks.

Factory with overrides:

makeUser({ plan: 'pro', card: 'expired' })   // test states only differences
// defaults + uniqueness (qa+<runid>-<worker>-<seq>@…) + seeded randomness
// composes: makeOrder({ customer: makeUser(...) })
// personas = named presets (dunningUser) — only where the combo is product vocabulary

Isolation ladder: unique run-scoped ids → run_id metadata (greppable, bulk-deletable) → tenant-per-run → ephemeral env. Never a shared mutated account.

Cleanup matrix:

Strategy Trade-off
fixture teardown free/precise; misses crashed runs
sweeper on run_id catches leaks; 2-day window
reset endpoint fastest; disposable envs only
ephemeral env total isolation; provision cost
Default: fixture deletion + sweeper. Rules: idempotent, children before parents, API for crashes.

Level rule: arrange via API, act/verify via UI — except when creation IS the subject.

Metric: records-created-per-run should track test count, not exceed it.

Continue Learning

Before You Start

Related Reading