Test Data Management Strategies

8 min read

The fixtures from the previous lesson are the mechanism. Test data is the content you put inside them. A suite that runs 200 tests in parallel can't have all of them creating a user named alice@test.com — they'd collide on a unique-email constraint by the second worker. A suite that depends on a shared "test user 1234" account that's been mutated by every test for two years is one accidental delete away from collapsing. This lesson is the four strategies real Playwright teams use, when each one earns its keep, and the rules that make all of them play well with parallel execution.

The four strategies

Most data needs in a real test suite fall into one of four buckets:

  • API-seeded data — every test creates its own data via API and cleans up after itself. The default for state-mutating tests.
  • Storage state — auth cookies and localStorage saved to disk once, reused by every test that needs a logged-in session. The default for authentication.
  • Test data factories — small TypeScript helpers that produce typed, unique objects on demand (createTestUser({ role: 'admin' })).
  • JSON fixture files — static, hand-curated data sitting in a fixtures/ folder. The default for read-only, deterministic test data.

Most mature suites use all four together. The trick is knowing which to reach for in each case.

Strategy 1 — API-seeded data

The pattern from the previous lesson. Wrap creation and cleanup in a fixture; every test gets its own resource:

import { test as base } from "@playwright/test";
 
export const test = base.extend<{ testProduct: Product }>({
  testProduct: async ({ request }, use) => {
    const res = await request.post("/api/products", {
      data: {
        name: `Test Product ${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
        price: 29.99,
        stock: 10
      }
    });
    const product = await res.json();
    await use(product);
    await request.delete(`/api/products/${product.id}`);
  }
});

When to use it: the test mutates state — creates, updates, deletes. The setup is custom per test. You want full isolation.

The unique-id rule. Every API-seeded resource needs a unique key. Date.now() alone fails when two parallel workers create resources in the same millisecond. Combine with a random suffix or a counter:

const uniqueEmail = `test-${Date.now()}-${Math.random().toString(36).slice(2, 10)}@test.com`;
const uniqueSku = `SKU-${process.env.PW_TEST_WORKER_INDEX || 0}-${Date.now()}`;

The PW_TEST_WORKER_INDEX environment variable is set by Playwright per worker — combining it with the timestamp gives you a guaranteed-unique key even under heavy parallelism.

Strategy 2 — Storage state for auth

Logging in via UI for every test is the single biggest waste of CI time. Instead, log in once, save the cookies, reuse the file:

// tests/auth.setup.ts
import { test as setup } from "@playwright/test";
 
setup("authenticate", async ({ page }) => {
  await page.goto("/login");
  await page.getByLabel("Email").fill("alice@test.com");
  await page.getByLabel("Password").fill("Sup3rS3cret!");
  await page.getByRole("button", { name: "Sign in" }).click();
  await page.waitForURL(/dashboard/);
  await page.context().storageState({ path: "tests/.auth/user.json" });
});
// playwright.config.ts
projects: [
  { name: "setup", testMatch: /.*\.setup\.ts/ },
  {
    name: "chromium",
    use: { ...devices["Desktop Chrome"], storageState: "tests/.auth/user.json" },
    dependencies: ["setup"]
  }
];

Now every test in the chromium project starts authenticated. The setup runs once at the start of the test run; saved cookies are fed into every test's BrowserContext automatically. Lesson 4 of chapter 6 covers this in depth — for now, know that storageState is the canonical way to handle "every test needs a logged-in user."

When to use it: the test needs an auth session and the auth flow itself isn't what's under test. Multi-role suites get one storage state per role (admin.json, user.json, viewer.json) and one project per role.

Strategy 3 — Test data factories

A factory is a small TypeScript function that produces a typed object with sensible defaults and overrides:

// tests/factories.ts
import type { User, Product, Order } from "../types";
 
let userCounter = 0;
 
export function createTestUser(overrides: Partial<User> = {}): User {
  userCounter++;
  return {
    name: `Test User ${userCounter}`,
    email: `user-${Date.now()}-${userCounter}@test.com`,
    role: "user",
    createdAt: new Date().toISOString(),
    ...overrides
  };
}
 
export function createTestProduct(overrides: Partial<Product> = {}): Product {
  return {
    sku: `SKU-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
    name: "Test Product",
    price: 29.99,
    stock: 10,
    category: "general",
    ...overrides
  };
}

Used in tests:

test("admin can create users with elevated roles", async ({ apiClient }) => {
  const adminData = createTestUser({ role: "admin" });
  const res = await apiClient.post("/api/users", { data: adminData });
  expect(res.ok()).toBeTruthy();
});

When to use it: when you need a typed, customisable starting point for an API call. Combine with API-seeded fixtures: the factory produces the data, the fixture sends the request and handles cleanup.

The TypeScript-for-QA course's data-factory pattern covers the full type-safety angle (generics, branded types, partial overrides). The Playwright-side win is that factory + APIRequestContext + custom fixture composes into a clean three-line setup for any test scenario.

Strategy 4 — JSON fixture files

Sometimes the test needs deterministic data that never changes — a fixed product catalogue, a known good order JSON, a sample CSV for upload tests. JSON files in a fixtures folder are the simplest answer:

tests/
├── fixtures/
│   ├── data/
│   │   ├── golden-orders.json
│   │   ├── sample-products.json
│   │   └── upload.csv
│   └── index.ts
└── specs/
    └── orders.spec.ts
import goldenOrders from "../fixtures/data/golden-orders.json";
 
test("renders historic orders correctly", async ({ page, request }) => {
  // Use the golden file to mock the API response
  await page.route("**/api/orders", async route =>
    route.fulfill({ status: 200, json: goldenOrders })
  );
  await page.goto("/orders");
  await expect(page.getByText("Order #1001")).toBeVisible();
});

When to use it: the data is read-only, doesn't vary per test, and represents a known good shape you want to lock in. Visual regression tests, parser tests, and any test where deterministic input is the whole point.

When to reach for which

Four data strategies — when each one earns its keep

API-seeded

  • Best for: state-mutating tests

  • Pro: full isolation, parallel-safe with unique IDs

  • Pro: matches production data shapes automatically

  • Con: requires API endpoints to exist for every resource

Storage state

  • Best for: authentication

  • Pro: log in once, every test starts authenticated

  • Pro: tens of seconds saved per test on big suites

  • Con: storageState files leak auth — never commit them

Factories

  • Best for: typed, dynamic data with sensible defaults

  • Pro: TypeScript catches schema drift at compile time

  • Pro: composes cleanly with API fixtures

  • Con: by itself, doesn't solve persistence — pair with API

Fixture files

  • Best for: read-only, deterministic data

  • Pro: simplest possible — just JSON in a folder

  • Pro: ideal for visual / parser / golden-file tests

  • Con: brittle to schema changes; manual updates needed

The golden rule of test isolation

Whichever strategies you mix, one rule beats every other consideration: each test owns its own data and cleans up after itself. Never depend on data created by another test.

Two consequences:

  • No "test 1 creates the user, test 2 logs in as that user." If test 1 is skipped or fails, test 2 has no user.
  • No shared accounts that every test mutates. If 50 tests all add to the same cart, the cart's state is whatever the most recent test left behind — usually nothing useful, often actively harmful.

Tests are independent units. Treat them like pure functions: same input, same output, regardless of what ran before or after.

Database reset — when and how

For complex apps, test data accumulates faster than fixture cleanup can keep up. The pragmatic solution is a database reset before the test suite runs, not between tests:

// global-setup.ts
async function globalSetup() {
  await fetch("http://localhost:3001/test-utils/reset-database", { method: "POST" });
}
export default globalSetup;

This runs once at the start of the suite. It's slow (seconds), so doing it per-test would destroy parallelism. Once at the start gives a known baseline; per-test fixtures then add and remove their specific data on top.

For CI, the equivalent is a fresh database per pipeline run — Docker containers or a CI-only test database that's spun up clean for every job.

Coming from Cypress?

The mappings:

  • cy.task('seedDatabase') for setup → Playwright's globalSetup script in TS, called once.
  • cy.fixture('users.json') → import the JSON directly: import users from '../fixtures/users.json'.
  • Cypress.session(...) for auth caching → Playwright's storageState (more powerful, persists to disk).
  • Cypress factories are usually plain JS helpers — same shape in Playwright, with TypeScript types added.

If your Cypress suite uses cy.session heavily, the storageState migration is the biggest single win — it's faster, more flexible, and integrates with Playwright's project-based auth pattern (lesson 4 of chapter 6).

⚠️ Common mistakes

  • Hardcoding alice@test.com in tests. Two parallel workers, both trying to create alice, both fail. Tests that depend on "alice" assume she always exists. Use a unique-by-construction email for every test (timestamp + random suffix) and let each test own its data.
  • Committing storage state files. tests/.auth/admin.json contains real cookies. If you push it to a public repo, those cookies are public. Always add tests/.auth/ to .gitignore. The setup file regenerates the state on each CI run.
  • Mixing strategies for the same data. Half the suite creates products via API; the other half pre-seeds them in fixtures. You now have two sources of truth and your tests fight each other when the schema changes. Pick one strategy per resource type and stick with it.

🎯 Practice task

Build all four strategies in one project. 30-40 minutes.

  1. API-seeded fixture. Add a testPost fixture (from the previous lesson) that creates and deletes a JSONPlaceholder post.

  2. Factory. Create tests/factories.ts:

    let counter = 0;
     
    export function createTestPost(overrides: Partial<{ title: string; body: string; userId: number }> = {}) {
      counter++;
      return {
        title: `Test Post ${counter}-${Date.now()}`,
        body: "Auto-generated body",
        userId: 1,
        ...overrides
      };
    }
  3. JSON fixture file. Create tests/fixtures/data/golden-posts.json with three handpicked posts:

    [
      { "id": 1, "title": "Golden Post 1", "body": "First", "userId": 1 },
      { "id": 2, "title": "Golden Post 2", "body": "Second", "userId": 1 },
      { "id": 3, "title": "Golden Post 3", "body": "Third", "userId": 2 }
    ]
  4. Storage state. (You'll wire this fully in chapter 6 lesson 4. For now, just create an empty placeholder file tests/.auth/.gitkeep so the folder exists.)

  5. Combine all three in one spec, tests/data-strategies.spec.ts:

    import { test, expect } from "../fixtures"; // your fixtures with testPost
    import { createTestPost } from "./factories";
    import goldenPosts from "./fixtures/data/golden-posts.json";
     
    test("strategy 1 — API-seeded via fixture", async ({ testPost }) => {
      expect(testPost.id).toBeDefined();
    });
     
    test("strategy 2 — factory + API call", async ({ request }) => {
      const data = createTestPost({ title: "Custom title" });
      const res = await request.post("https://jsonplaceholder.typicode.com/posts", { data });
      expect(res.status()).toBe(201);
      const created = await res.json();
      expect(created.title).toBe("Custom title");
    });
     
    test("strategy 3 — JSON fixture for read-only mock", async ({ page }) => {
      await page.route("**/posts", route =>
        route.fulfill({ status: 200, json: goldenPosts })
      );
      const responsePromise = page.waitForResponse("**/posts");
      await page.goto("https://jsonplaceholder.typicode.com/posts");
      const response = await responsePromise;
      const data = await response.json();
      expect(data).toHaveLength(3);
      expect(data[0].title).toBe("Golden Post 1");
    });
  6. Run all three tests across all three browsers — they pass independently and in any order.

  7. Demonstrate the unique-id rule. Run the tests with --workers=4 --repeat-each=3 (12 total runs in parallel). They should all pass. Now change the factory to use a fixed email like 'alice@test.com' and add a unique-constraint check (e.g., a database with a unique index). Some runs would fail. The unique-by-construction rule isn't optional — it's what enables parallelism.

  8. Stretch: add a globalSetup.ts that "resets" the test environment by deleting any posts older than 1 hour (against your own backend). Wire it into playwright.config.ts. This is the database-reset pattern; in real apps it runs once per CI build and gives every run a known baseline.

You now have every primitive a real QA team uses to manage test data. The next and final lesson of this chapter wires it all together at the suite level — globalSetup, globalTeardown, and the project-based authentication pattern that turns 50 logged-in tests from a 4-minute slog into a 2-second start.

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