Writing Custom Fixtures

9 min read

page, context, request, and browser cover the basics. Real test suites rapidly need more: a logged-in admin page, a freshly-seeded test product, a typed API client with the auth token already attached. You can copy-paste setup into every test, or you can write a fixture once and have every test that asks for it inherit the setup and the cleanup. This lesson is the test.extend pattern — typed custom fixtures, fixture composition, and worker-scoped fixtures for expensive resources. It's the single highest-leverage refactor most Playwright codebases ever go through.

The test.extend pattern

Custom fixtures are added by extending the base test object. The shape:

// fixtures/index.ts
import { test as base, type Page } from "@playwright/test";
 
type MyFixtures = {
  loggedInPage: Page;
};
 
export const test = base.extend<MyFixtures>({
  loggedInPage: async ({ page }, use) => {
    // SETUP — runs before the test that uses this fixture
    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/);
 
    // PROVIDE — pass the fixture value to the test
    await use(page);
 
    // TEARDOWN — runs after the test (use storageState elsewhere if you want speed)
    // For this fixture there's no specific cleanup; the page is closed by Playwright automatically
  }
});
 
export { expect } from "@playwright/test";

Three things to internalise:

  • Type the fixture. base.extend<MyFixtures>(...) makes loggedInPage typed everywhere it's destructured. TypeScript autocompletes it; refactors are caught at compile time.
  • use(value) is the "yield" point. Code before use() is setup. Code after is teardown. The value passed to use() is what the test sees.
  • Ship a custom test from your fixtures file. Every spec then imports { test, expect } from your file instead of @playwright/test. The custom fixtures are now available; the original ones still work.

Using a custom fixture

// tests/dashboard.spec.ts
import { test, expect } from "../fixtures";
 
test("admin sees the user table", async ({ loggedInPage }) => {
  await loggedInPage.goto("/admin/users");
  await expect(loggedInPage.getByRole("table")).toBeVisible();
});
 
test("admin can navigate to settings", async ({ loggedInPage }) => {
  await loggedInPage.getByRole("link", { name: "Settings" }).click();
  await expect(loggedInPage).toHaveURL(/settings/);
});

Both tests start logged in. Neither has a beforeEach calling login. The fixture is opt-in: tests that don't ask for loggedInPage don't pay the login cost.

Fixture with proper teardown — try/finally of the fixture world

When a fixture creates a resource that needs explicit cleanup, the structure is the same before/use/after:

type Fixtures = {
  testUser: { id: number; email: string };
};
 
export const test = base.extend<Fixtures>({
  testUser: async ({ request }, use) => {
    // SETUP — create
    const res = await request.post("/api/users", {
      data: {
        email: `test-${Date.now()}@example.com`,
        password: "pw123"
      }
    });
    const user = await res.json();
 
    // PROVIDE
    await use(user);
 
    // TEARDOWN — always runs, even if the test failed
    await request.delete(`/api/users/${user.id}`);
  }
});

The teardown block runs after the test, regardless of whether the test passed or threw. No try/finally boilerplate inside the test. This is the canonical "API setup + UI test + API cleanup" pattern from chapter 4, lifted into a reusable fixture.

Composing fixtures — fixtures that depend on fixtures

A fixture can take other fixtures (built-in or custom) as its dependencies:

type Fixtures = {
  testUser: { id: number; email: string; password: string };
  loggedInPage: Page;
};
 
export const test = base.extend<Fixtures>({
  testUser: async ({ request }, use) => {
    const password = "pw123";
    const res = await request.post("/api/users", {
      data: { email: `test-${Date.now()}@example.com`, password }
    });
    const user = await res.json();
    await use({ ...user, password });
    await request.delete(`/api/users/${user.id}`);
  },
 
  loggedInPage: async ({ page, testUser }, use) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill(testUser.email);
    await page.getByLabel("Password").fill(testUser.password);
    await page.getByRole("button", { name: "Sign in" }).click();
    await page.waitForURL(/dashboard/);
    await use(page);
  }
});

A test that asks for loggedInPage triggers the chain: request and page exist (built-ins), testUser builds on request, loggedInPage builds on page and testUser. Playwright resolves the dependency graph automatically and tears everything down in reverse order.

The DAG visualised:

Scoping — test vs worker

By default, fixtures are test-scoped: created fresh for each test that uses them, torn down at test end. That's the right default for state that should be isolated.

For expensive setup that's safe to share (a seeded database, a started backend service, a long-lived API token), worker-scoped fixtures create the resource once per worker process and reuse it across every test in that worker:

type WorkerFixtures = {
  apiToken: string;
};
 
export const test = base.extend<{}, WorkerFixtures>({
  apiToken: [
    async ({}, use) => {
      // Runs once per worker
      const res = await fetch("https://auth.example.com/token", {
        method: "POST",
        body: JSON.stringify({ client: process.env.CLIENT_ID, secret: process.env.CLIENT_SECRET })
      });
      const { token } = await res.json();
      await use(token);
      // No teardown needed for a token; if you started a server, you'd shut it down here
    },
    { scope: "worker" }
  ]
});

Note the second type parameter on extend<{}, WorkerFixtures> — that's the worker-scope slot. And the array form [fn, { scope: 'worker' }] — that's how you opt in.

Use worker scope when:

  • The setup is expensive (>500ms)
  • The resulting resource is safe to share — pure data or read-only services. Don't worker-scope a logged-in page; per-test contexts are how Playwright stays isolated.

A multi-fixture file in practice

Real projects keep all custom fixtures in one file:

// fixtures/index.ts
import { test as base, expect, type Page, type APIRequestContext } from "@playwright/test";
import type { Product, User } from "../types";
 
type Fixtures = {
  testUser: User;
  testProduct: Product;
  loggedInPage: Page;
  apiClient: APIRequestContext;
};
 
export const test = base.extend<Fixtures>({
  apiClient: async ({ request }, use) => {
    // Provide an authenticated request context
    await use(request);
  },
 
  testUser: async ({ apiClient }, use) => {
    const res = await apiClient.post("/api/users", {
      data: { email: `u-${Date.now()}@test.com`, password: "pw123", role: "user" }
    });
    const user = await res.json();
    await use(user);
    await apiClient.delete(`/api/users/${user.id}`);
  },
 
  testProduct: async ({ apiClient }, use) => {
    const res = await apiClient.post("/api/products", {
      data: { name: `Test Product ${Date.now()}`, price: 29.99, stock: 10 }
    });
    const product = await res.json();
    await use(product);
    await apiClient.delete(`/api/products/${product.id}`);
  },
 
  loggedInPage: async ({ page, testUser }, use) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill(testUser.email);
    await page.getByLabel("Password").fill(testUser.password);
    await page.getByRole("button", { name: "Sign in" }).click();
    await page.waitForURL(/dashboard/);
    await use(page);
  }
});
 
export { expect };

Then a test asking for "a logged-in user with a product to test against" looks like:

import { test, expect } from "../fixtures";
 
test("logged-in user can buy a test product", async ({ loggedInPage, testProduct }) => {
  await loggedInPage.goto(`/products/${testProduct.id}`);
  await loggedInPage.getByRole("button", { name: "Add to cart" }).click();
  await loggedInPage.getByRole("link", { name: "Cart" }).click();
  await expect(loggedInPage.getByText(testProduct.name)).toBeVisible();
});

Three resources spin up automatically: a user (via API), a product (via API), and a logged-in browser session. Three teardowns happen automatically: product deleted, user deleted, page closed. The test body is six lines and reads like a story.

Coming from Cypress?

Custom commands (Cypress.Commands.add('login', ...)) are the closest equivalent. Two differences:

  • Cypress commands attach to cy.* and run inside the Cypress chain. Playwright fixtures are values destructured into the test signature.
  • Cypress has no real teardown story for commands. Playwright's use() boundary gives you setup and teardown in one place.

If your Cypress codebase has commands like cy.loginViaApi() and cy.createProduct(), those become Playwright fixtures: testUser, testProduct. The migration is usually a clean win — the test code reads more like a story than a sequence of helper invocations.

⚠️ Common mistakes

  • Forgetting await use(...). If you write use(page) without await, the test runs before setup completes — you get races, "element not found" errors, weird flakes. Always await use(...). ESLint with @typescript-eslint/no-floating-promises catches this.
  • Putting test-specific logic in a fixture. A fixture should produce a reusable resource. If your loggedInPage fixture also clicks "Accept cookies banner" and "dismiss the welcome modal," that's specific to one test's UI state. Keep fixtures focused; let the test decide what to do with the resource.
  • Worker-scoping things that share state. A worker-scoped logged-in page is a recipe for cross-test contamination — test A logs out, test B sees the logged-out state. Per-test scope is the safe default; worker scope is for immutable shared resources only (tokens, configs, seeded data).

🎯 Practice task

Write a fixtures file with three composed fixtures. 30 minutes.

  1. Create fixtures/index.ts with three fixtures: testPost (API-creates a JSONPlaceholder post), apiClient (just re-exposes request for convenience), and pageWithMockedPosts (a page with the /posts endpoint pre-mocked):

    import { test as base, expect, type Page } from "@playwright/test";
     
    type Post = { id: number; title: string; body: string; userId: number };
     
    type Fixtures = {
      testPost: Post;
      pageWithMockedPosts: Page;
    };
     
    export const test = base.extend<Fixtures>({
      testPost: async ({ request }, use) => {
        const res = await request.post("https://jsonplaceholder.typicode.com/posts", {
          data: { title: `Test ${Date.now()}`, body: "Body", userId: 1 }
        });
        const post = await res.json();
        await use(post);
        // JSONPlaceholder doesn't actually persist, so cleanup is a no-op here;
        // in a real app you'd: await request.delete(`/posts/${post.id}`)
      },
     
      pageWithMockedPosts: async ({ page }, use) => {
        await page.route("**/posts", async route => {
          await route.fulfill({
            status: 200,
            json: [
              { id: 1, title: "Mocked post 1", body: "First", userId: 1 },
              { id: 2, title: "Mocked post 2", body: "Second", userId: 2 }
            ]
          });
        });
        await use(page);
      }
    });
     
    export { expect };
  2. Create tests/fixtures-practice.spec.ts:

    import { test, expect } from "../fixtures";
     
    test.describe("Custom fixtures practice", () => {
      test("uses testPost — created via API", async ({ testPost }) => {
        expect(testPost.id).toBeDefined();
        expect(testPost.title).toContain("Test");
      });
     
      test("uses pageWithMockedPosts — fetches mocked data", async ({ pageWithMockedPosts }) => {
        const responsePromise = pageWithMockedPosts.waitForResponse("**/posts");
        await pageWithMockedPosts.goto("https://jsonplaceholder.typicode.com/posts");
        await responsePromise;
     
        const text = await pageWithMockedPosts.locator("body").textContent();
        expect(text).toContain("Mocked post 1");
        expect(text).not.toContain("sunt aut facere"); // a string from the real first post
      });
     
      test("uses both — testPost + pageWithMockedPosts", async ({ testPost, pageWithMockedPosts }) => {
        expect(testPost.id).toBeGreaterThan(0);
        await pageWithMockedPosts.goto("https://jsonplaceholder.typicode.com/posts");
        await expect(pageWithMockedPosts.locator("body")).toContainText("Mocked");
      });
    });
  3. Run all three tests across all three browsers. The third test demonstrates fixture composition — both fixtures resolve before the test runs.

  4. Force a fixture failure. Add throw new Error("forced setup failure") to the testPost setup. Run. Notice that tests asking for testPost fail with the setup error before the test body runs — and tests that don't ask for testPost (none in this file) would still pass. This is the isolation property in action.

  5. Stretch: add a worker-scoped fixture appConfig that fetches a JSON config file once and exposes it to every test. Verify by adding console.log('config loaded') in the setup — with --workers=2, you should see "config loaded" exactly twice (once per worker), regardless of how many tests use it.

You can now build typed, composable, self-cleaning building blocks for any test surface your app exposes. The next lesson zooms in on the content of those fixtures — the data-management strategies that keep parallel tests from stepping on each other.

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