SoftwareTestPilot
Automation TestingPublished: 17 min read

Playwright Custom Fixtures Example: Typed Page Objects, Auth and API Data

A complete Playwright custom fixtures example in TypeScript: typed page objects, authenticated users and API-created data, with setup, teardown, scope and failure cleanup explained.

SoftwareTestPilot
QA Editorial Team
Share:XLinkedInWhatsApp
Playwright custom fixtures example with typed page objects, authenticated users and API data
Playwright custom fixtures example with typed page objects, authenticated users and API data

Last updated 2026-10-16 · 17 min read · By SoftwareTestPilot

Most explanations of fixtures stop at a single example with a fake value. Real suites need more: page objects that every test uses, users that are already signed in, and test data created through the API and removed afterwards. This guide is a complete Playwright custom fixtures example that builds all three in one typed fixture file, then explains what happens at each stage of a test run.

By the end you will have a pattern you can copy into a project: a fixtures/index.ts file that exports an extended test, and spec files that contain only test steps and assertions. We also cover the parts that usually go wrong in real projects, such as cleanup after a failed setup and sharing data across parallel workers.

If you are still deciding whether fixtures are worth it compared with beforeEach, read Playwright fixtures vs hooks first. For a shorter tour of the fixture API, see our fixtures deep dive. This article is the hands-on build.

The examples use a recent Playwright Test release with TypeScript and a fictional shop application running at a local base URL. The code was reviewed against the official documentation but not executed against a live application, so treat URLs, selectors and API endpoints as placeholders for your own app.

The project layout we are building

Keep fixtures in one place so spec files never need to know how anything is constructed. A layout that scales well looks like this:

tests/
  auth.setup.ts          # logs in once, saves session files
  cart.spec.ts
  admin-products.spec.ts
fixtures/
  index.ts               # the extended test and expect
pages/
  LoginPage.ts
  ProductPage.ts
  CartPage.ts
api/
  ShopApi.ts             # small typed API client
playwright/.auth/        # saved sessions (git-ignored)
playwright.config.ts

Every spec imports test and expect from fixtures/index.ts instead of @playwright/test. That single import change is what gives each test access to your custom fixtures. Nothing else in the spec needs to change, and the built-in fixtures such as page, context, browser and request are still available.

We will add fixtures in three layers. First the page objects, because they have no dependencies beyond page. Then authenticated users, which need a saved session. Finally API-created data, which needs an API client and is the layer where teardown matters most.

Step 1: Typed page-object fixtures

Start with plain page-object classes. They take a Page, expose locators and actions, and leave most assertions to the test. Here is a compact CartPage; LoginPage and ProductPage follow the same shape.

// pages/CartPage.ts
import type { Page, Locator } from '@playwright/test';

export class CartPage {
  readonly rows: Locator;
  readonly total: Locator;
  readonly emptyMessage: Locator;

  constructor(private readonly page: Page) {
    this.rows = page.getByRole('row');
    this.total = page.getByTestId('cart-total');
    this.emptyMessage = page.getByText('Your cart is empty');
  }

  async open() {
    await this.page.goto('/cart');
  }

  row(name: string): Locator {
    return this.rows.filter({ hasText: name });
  }

  async remove(name: string) {
    await this.row(name).getByRole('button', { name: 'Remove' }).click();
  }
}

Now declare a type for the fixtures and create them with test.extend. Each fixture receives page, builds the object, and hands it to the test with use.

// fixtures/index.ts (first version)
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductPage } from '../pages/ProductPage';
import { CartPage } from '../pages/CartPage';

type PageFixtures = {
  loginPage: LoginPage;
  productPage: ProductPage;
  cartPage: CartPage;
};

export const test = base.extend<PageFixtures>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  productPage: async ({ page }, use) => {
    await use(new ProductPage(page));
  },
  cartPage: async ({ page }, use) => {
    await use(new CartPage(page));
  },
});

export { expect };

A test now asks for the object by name, and TypeScript knows its exact type:

import { test, expect } from '../fixtures';

test('empty cart shows a message', async ({ cartPage }) => {
  await cartPage.open();
  await expect(cartPage.emptyMessage).toBeVisible();
});

These fixtures have setup (constructing the object) but no teardown, because the built-in page fixture already closes the page after the test. All three share the same page instance within one test, so a test that asks for productPage and cartPage works on one browser tab. Constructing a page object is cheap and does not open anything, so it is fine for these to be test-scoped.

Step 2: Authenticated user fixtures

Logging in through the form in every test is slow and adds a failure point that has nothing to do with what most tests check. Playwright's recommended approach is to log in once, save the browser's cookies and local storage to a file with storageState, and start later contexts from that file.

First, a setup file that signs in each role and saves a session. It runs as its own project before the tests.

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

const users = {
  shopper: { email: 'shopper@example.com', password: process.env.SHOPPER_PASSWORD! },
  admin: { email: 'admin@example.com', password: process.env.ADMIN_PASSWORD! },
};

for (const [role, user] of Object.entries(users)) {
  setup('authenticate ' + role, async ({ page }) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(user.email);
    await page.getByLabel('Password').fill(user.password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await expect(page.getByRole('button', { name: 'Account' })).toBeVisible();
    await page.context().storageState({ path: 'playwright/.auth/' + role + '.json' });
  });
}
// playwright.config.ts (relevant part)
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  use: { baseURL: 'http://localhost:3000' },
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
      dependencies: ['setup'],
    },
  ],
});

Add playwright/.auth to .gitignore. Those files hold real session cookies and must never be committed.

Now the fixture. Instead of hard-coding one user, add an option fixture called role with a default value, and an authedPage fixture that opens a new context from the matching session file. Because the context is created inside the fixture, the fixture also owns its cleanup.

// fixtures/index.ts (added)
import type { Page } from '@playwright/test';

type Role = 'shopper' | 'admin';

type AuthFixtures = {
  role: Role;
  authedPage: Page;
};

// inside base.extend<...>({ ... })
role: ['shopper', { option: true }],

authedPage: async ({ browser, role }, use) => {
  const context = await browser.newContext({
    storageState: 'playwright/.auth/' + role + '.json',
  });
  const page = await context.newPage();
  await use(page);
  await context.close();
},

A spec that needs an admin switches role with one line, and every test in that file receives an admin session:

import { test, expect } from '../fixtures';

test.use({ role: 'admin' });

test('admin sees the product editor', async ({ authedPage }) => {
  await authedPage.goto('/admin/products');
  await expect(authedPage.getByRole('heading', { name: 'Products' })).toBeVisible();
});

If most tests in your suite should be signed in as the same user, a simpler alternative is to set storageState in the project's use block so the built-in page is already authenticated. The option fixture shown here is useful when one suite mixes roles. For more on how tokens and sessions behave at the API level, our guide to testing JWT authentication with Postman, Playwright and REST Assured covers expiry, refresh and tampered tokens.

Explore the Playwright course to build these fixtures step by step inside a full framework.

Step 3: API-created test data

Tests that depend on data created by other tests, or by a shared seed script, break in confusing ways. Each test should create the data it needs and remove it afterwards. Doing that through the API is much faster than clicking through admin screens.

Begin with a small typed client so fixtures do not repeat URLs and response parsing:

// api/ShopApi.ts
import { expect, type APIRequestContext } from '@playwright/test';

export type Product = { id: string; name: string; price: number };

export class ShopApi {
  constructor(private readonly request: APIRequestContext) {}

  async createProduct(data: Omit<Product, 'id'>): Promise<Product> {
    const res = await this.request.post('/api/products', { data });
    expect(res.status(), 'create product').toBe(201);
    return (await res.json()) as Product;
  }

  async deleteProduct(id: string): Promise<void> {
    const res = await this.request.delete('/api/products/' + id);
    expect([200, 204, 404], 'delete product').toContain(res.status());
  }
}

Checking the status inside the client matters. If product creation fails, the test stops right there with a clear message about the API, instead of failing later on a locator that cannot find a product row.

Next, two fixtures. The API client is worker-scoped: it is created once per worker process, reused by every test in that worker, and disposed when the worker shuts down. The product is test-scoped: every test gets its own record, deleted after the test.

// fixtures/index.ts (added)
import { ShopApi, type Product } from '../api/ShopApi';

type DataFixtures = { product: Product };
type WorkerFixtures = { api: ShopApi };

// inside base.extend<TestFixtures, WorkerFixtures>({ ... })
api: [async ({ playwright }, use) => {
  const request = await playwright.request.newContext({
    baseURL: 'http://localhost:3000',
    extraHTTPHeaders: { Authorization: 'Bearer ' + process.env.API_TOKEN },
  });
  await use(new ShopApi(request));
  await request.dispose();
}, { scope: 'worker' }],

product: async ({ api }, use, testInfo) => {
  const product = await api.createProduct({
    name: 'Test mug ' + testInfo.workerIndex + '-' + Date.now(),
    price: 499,
  });
  await use(product);
  await api.deleteProduct(product.id);
},

The unique name, built from the worker index and a timestamp, prevents two parallel tests from colliding on a unique-name rule. A test that needs a product just asks for it:

test('product page shows the price', async ({ authedPage, product }) => {
  await authedPage.goto('/products/' + product.id);
  await expect(authedPage.getByTestId('price')).toHaveText('₹499');
});

The complete fixture file

Here are all three layers together, including a composed filledCart fixture that depends on the others. This is the file you would actually keep in a project.

// fixtures/index.ts
import { test as base, expect, type Page } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { ProductPage } from '../pages/ProductPage';
import { CartPage } from '../pages/CartPage';
import { ShopApi, type Product } from '../api/ShopApi';

type Role = 'shopper' | 'admin';

type TestFixtures = {
  loginPage: LoginPage;
  productPage: ProductPage;
  cartPage: CartPage;
  role: Role;
  authedPage: Page;
  product: Product;
  filledCart: CartPage;
};

type WorkerFixtures = {
  api: ShopApi;
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
  loginPage: async ({ page }, use) => { await use(new LoginPage(page)); },
  productPage: async ({ page }, use) => { await use(new ProductPage(page)); },
  cartPage: async ({ page }, use) => { await use(new CartPage(page)); },

  role: ['shopper', { option: true }],

  authedPage: async ({ browser, role }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/' + role + '.json' });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  api: [async ({ playwright }, use) => {
    const request = await playwright.request.newContext({
      baseURL: 'http://localhost:3000',
      extraHTTPHeaders: { Authorization: 'Bearer ' + process.env.API_TOKEN },
    });
    await use(new ShopApi(request));
    await request.dispose();
  }, { scope: 'worker' }],

  product: async ({ api }, use, testInfo) => {
    const product = await api.createProduct({
      name: 'Test mug ' + testInfo.workerIndex + '-' + Date.now(),
      price: 499,
    });
    await use(product);
    await api.deleteProduct(product.id);
  },

  filledCart: async ({ authedPage, product }, use) => {
    const productPage = new ProductPage(authedPage);
    await productPage.open(product.id);
    await productPage.addToCart();
    const cart = new CartPage(authedPage);
    await cart.open();
    await use(cart);
  },
});

export { expect };
// tests/cart.spec.ts
import { test, expect } from '../fixtures';

test('shows the added product', async ({ filledCart, product }) => {
  await expect(filledCart.row(product.name)).toBeVisible();
});

test('removing the product empties the cart', async ({ filledCart, product }) => {
  await filledCart.remove(product.name);
  await expect(filledCart.emptyMessage).toBeVisible();
});

Note that filledCart builds its page objects from authedPage rather than asking for the cartPage fixture. The page-object fixtures from Step 1 wrap the built-in, signed-out page. Mixing the two would put the product on one tab and the cart check on another. Being explicit about which page an object wraps avoids that subtle bug.

How setup and teardown run

Every fixture function has the same three parts: code before await use(value) is setup, the call to use hands the value to the test and waits for the test to finish, and code after use is teardown.

For the cart test above, Playwright runs things in this order:

  1. Worker starts. The worker-scoped api fixture is built if this is the first test in the worker that needs it.
  2. role resolves to 'shopper' (or the value set by test.use).
  3. authedPage opens a context from the saved session.
  4. product is created through the API.
  5. filledCart adds the product and opens the cart.
  6. The test body runs.
  7. Teardown runs in reverse: filledCart has nothing to clean up, product is deleted, then the authedPage context is closed.
  8. When the worker shuts down, api disposes its request context.

You never write that order down. Playwright derives it from the parameters each fixture lists. That is the main practical gain over hooks: dependencies are declared once, next to the code that needs them, and the runner guarantees a correct order in both directions.

Choosing test or worker scope

Scope decides how long a fixture value lives and who shares it.

  • Test scope (the default) builds a fresh value for every test and tears it down after that test. Use it for anything a test might change: pages, contexts, created records, carts.
  • Worker scope builds the value once per worker process and shares it with every test that runs in that worker. Use it for expensive, read-only or stateless resources: an API client, a connection to a test database, a downloaded fixture file.

Two rules keep scope safe. First, a worker fixture cannot depend on a test fixture, because the test fixture does not exist yet when the worker fixture is built; Playwright reports this as an error. Second, never put data that tests modify into worker scope. If one test deletes the shared product, the next test in the same worker fails for reasons that have nothing to do with what it checks, and the failure depends on which worker happened to run which test.

Remember that workers are not permanent. Playwright starts a new worker after a test fails, so worker fixtures can be built more than once in a run. Write them so that building them again is harmless, which is easy for clients and connections, and hard for "create the one shared admin account" logic. For that kind of once-per-run setup, a setup project like the one in Step 2 is the better tool.

Fixture dependencies and overrides

Dependencies are simply the names a fixture destructures from its first argument. filledCart depends on authedPage and product; product depends on api; authedPage depends on browser and role. Playwright only builds what a test actually needs, so a test that asks for product alone never opens a browser context.

You can replace one link in the chain for a single file without touching the rest. A spec testing high-value orders can override product, and filledCart will use the new product automatically:

import { test as shopTest, expect } from '../fixtures';

const test = shopTest.extend({
  product: async ({ api }, use) => {
    const product = await api.createProduct({ name: 'Espresso machine ' + Date.now(), price: 24999 });
    await use(product);
    await api.deleteProduct(product.id);
  },
});

test('free delivery applies above the threshold', async ({ filledCart }) => {
  await expect(filledCart.total).toContainText('Free delivery');
});

Keep dependency chains shallow. When a test asks for one fixture that quietly triggers six others, failures become harder to read. A good guideline is that a new team member should be able to understand any fixture by reading it and the fixtures it names, without opening a third file.

Failure cleanup: what happens when things break

Cleanup is the reason to put test data into fixtures, so it is worth knowing exactly how it behaves.

The test fails. Teardown still runs. The code after await use() executes after a failed assertion or a timeout in the test body, so the product is deleted and the context closed. You do not need try/finally for this.

A fixture's setup fails before use. That fixture has nothing to clean up because it never handed anything over, and the test is reported as failed. Any fixtures that were already built, for example authedPage when product creation fails, are still torn down. This is why the API client checks status codes during creation: you want the failure to happen inside setup, before use, with a clear message.

Setup creates two things and fails between them. This is the one case where you must help. If a fixture creates a user and then fails creating that user's order, the user has been created but use was never reached, so the delete after use will not run. Either split the work into two fixtures, each owning one resource, or track what was created and clean it in a finally block:

customerWithOrder: async ({ api }, use) => {
  const created: Array<() => Promise<void>> = [];
  try {
    const user = await api.createUser({ email: 'c' + Date.now() + '@example.com' });
    created.push(() => api.deleteUser(user.id));
    const order = await api.createOrder({ userId: user.id, items: 1 });
    created.push(() => api.deleteOrder(order.id));
    await use({ user, order });
  } finally {
    for (const undo of created.reverse()) {
      await undo().catch((e) => console.warn('cleanup failed:', e.message));
    }
  }
},

Teardown itself fails. A failing delete is reported as an error in the test result. Catching and logging cleanup errors, as above, keeps the real test failure visible in the report instead of hiding it behind a cleanup error. Pair this with a scheduled job on your test environment that removes records with your test naming prefix, so anything that slips through, such as data left by a killed CI job, does not pile up.

Checklist before you ship your fixtures

  • Every spec imports test and expect from your fixtures file, not from @playwright/test.
  • Fixture types are declared in the test.extend generics; there is no any in the fixture file.
  • Each fixture calls await use(...) exactly once.
  • Every created resource has its delete after use in the same fixture.
  • Worker-scoped fixtures hold only clients or read-only resources.
  • Saved sessions are git-ignored and secrets come from environment variables.
  • Test data names are unique per worker and run.
  • The suite passes with several workers and with --repeat-each=3, which exposes hidden shared state.
  • CI runs tsc --noEmit, because Playwright strips types when running tests and will not stop on a type error by itself.

Summary

A good fixture file turns spec files into plain descriptions of behaviour. Page-object fixtures remove construction noise. An authenticated user fixture, backed by a session saved once in a setup project, removes slow login steps and makes role changes a one-line test.use. API-created data in test-scoped fixtures gives each test its own records and deletes them even when the test fails. Worker scope is for shared clients, not shared data, and multi-step setup needs explicit tracking so a half-finished setup does not leak records.

To see when hooks are still the simpler choice, read Playwright fixtures vs hooks. To organise this file inside a larger project, continue with Playwright framework setup with TypeScript and the page object model guide.

Explore the Playwright course to build these fixtures step by step inside a full framework.

Key takeaways

  1. 1Declare fixture types in the generics of test.extend<TestFixtures, WorkerFixtures> so every test gets autocomplete and compile-time checks.
  2. 2Page-object fixtures remove repeated new LoginPage(page) lines and keep construction in one place.
  3. 3Log in once in a setup project, save storageState, and let an authenticated fixture open contexts from that file.
  4. 4API-created data belongs in a test-scoped fixture: create before use(), delete after it, so cleanup always runs.
  5. 5Use worker scope for expensive shared resources such as an API client, never for data that tests modify.

Common mistakes to avoid

  • Forgetting await use(...), so the test hangs until it times out.
  • Sharing one created record across tests in a worker fixture, then seeing tests change each other's data.
  • Logging in through the UI inside every test instead of reusing a saved session.
  • Committing the saved storage-state file, which contains live session cookies, to the repository.
  • Ignoring API status codes in fixture setup, so tests fail later with a confusing locator error.

References

  1. 1
    Playwright: Fixtures— playwright.dev
  2. 2
    Playwright: Authentication— playwright.dev
  3. 3
    Playwright: API testing— playwright.dev
  4. 4
  5. 5
    Playwright: Test projects— playwright.dev