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'sglobalSetupscript in TS, called once.cy.fixture('users.json')→ import the JSON directly:import users from '../fixtures/users.json'.Cypress.session(...)for auth caching → Playwright'sstorageState(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.comin 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.jsoncontains real cookies. If you push it to a public repo, those cookies are public. Always addtests/.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.
-
API-seeded fixture. Add a
testPostfixture (from the previous lesson) that creates and deletes a JSONPlaceholder post. -
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 }; } -
JSON fixture file. Create
tests/fixtures/data/golden-posts.jsonwith 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 } ] -
Storage state. (You'll wire this fully in chapter 6 lesson 4. For now, just create an empty placeholder file
tests/.auth/.gitkeepso the folder exists.) -
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"); }); -
Run all three tests across all three browsers — they pass independently and in any order.
-
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. -
Stretch: add a
globalSetup.tsthat "resets" the test environment by deleting any posts older than 1 hour (against your own backend). Wire it intoplaywright.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.