---
title: "Playwright fixtures: built-in, custom and worker-scoped"
description: "Playwright fixtures explained with runnable examples: built-in fixtures, test.extend, worker scope, auto fixtures, storageState auth and overrides."
canonical: "https://momentic.ai/blog/playwright-fixtures-guide"
last-updated: "2026-10-31T09:50:51Z"
---

# Playwright fixtures: built-in, custom and worker-scoped

URL: https://momentic.ai/blog/playwright-fixtures-guide

[blog](/blog) [/ resources](/blog/category/resources)  / playwright-fixtures-guide

Resources

A reference for Playwright fixtures with runnable examples: built-in fixtures, custom fixtures with test.extend, worker and auto fixtures, and authentication.

Wei-Wei Wu

CEO, Momentic

## TL;DR

- Playwright fixtures supply test dependencies and handle setup and teardown. This guide uses the built-in `page`, `context`, `browser`, and `request` fixtures.
- Every code example is runnable as written, including a custom fixture defined with `test.extend`.
- Test-scoped fixtures run for each test. Worker-scoped fixtures can serve multiple tests, while `auto` fixtures run without an explicit request.
- The guide covers authentication with `storageState`, per-project fixture overrides, and when `beforeEach` is simpler than a fixture.
- The troubleshooting sections explain common mistakes, including hidden `auto` dependencies, shared worker state, and missed teardown.

## What a Playwright fixture actually is

Playwright fixtures make test setup explicit through named arguments. When a test requests a fixture in its callback, Playwright resolves that fixture and its dependencies, runs setup, and passes the value to the test. After the test finishes, Playwright runs the fixture’s teardown. For a test-scoped resource such as a page, that lifecycle gives each test its own setup and cleanup.

A `beforeEach` hook runs shared setup before each test. A fixture runs when a test or another fixture requests it, unless you mark it automatic. Fixtures can also depend on one another, so Playwright can set them up in dependency order and tear them down afterward. Hooks remain useful for simple setup that every test needs, but fixtures make reusable resources and their cleanup explicit.

The built-in starting points are `page`, `context`, `browser`, and `request`. They provide a browser page, an isolated browser context, a browser instance, and an API request client, respectively. The next section shows how their scopes affect what tests share.

## Built-in fixtures: page, context, browser, request

Playwright gives each test a fresh `page`, `context`, and `request` fixture by default. The `browser` fixture lives for the worker, which can run multiple tests. A browser contains contexts, and each context contains pages. The `request` fixture provides a separate API request context for HTTP calls, so an API call does not depend on the page’s browser context.

Save this as `tests/built-ins.spec.ts`. The test starts a local HTTP server, so it needs no application or external endpoint.

`import { createServer } from 'node:http'; import { test, expect } from '@playwright/test';  test('uses browser, context, page, and request', async ({  browser,  context,  page,  request, }) => {  const server = createServer((req, res) => {  if (req.url === '/health') {  res.setHeader('Content-Type', 'application/json');  res.end(JSON.stringify({ ok: true }));  return;  }   res.setHeader('Content-Type', 'text/html');  res.end('<h1>Ready</h1>');  });   await new Promise<void>((resolve) => {  server.listen(0, '127.0.0.1', resolve);  });   try {  const address = server.address();  if (!address || typeof address === 'string') {  throw new Error('Server did not bind to a TCP port');  }   const baseURL = `http://127.0.0.1:${address.port}`;   await page.goto(baseURL);  await expect(page.getByRole('heading', { name: 'Ready' }))  .toBeVisible();   expect(context.pages()).toContain(page);  expect(browser.isConnected()).toBe(true);   const response = await request.get(`${baseURL}/health`);  expect(response.ok()).toBe(true);  expect(await response.json()).toEqual({ ok: true });  } finally {  await new Promise<void>((resolve, reject) => {  server.close((error) => error ? reject(error) : resolve());  });  } });`

Install Playwright and its Chromium browser, then run the file.

`npm install --save-dev @playwright/test npx playwright install chromium npx playwright test tests/built-ins.spec.ts`

Playwright creates a new browser context and page for each test, which keeps browser state isolated. It reuses the worker-scoped browser to avoid launching a browser for every test. The test-scoped `request` fixture has its own request context and cleanup.

## Writing a custom fixture with test.extend

A custom fixture can give each test a seeded API client and close its local server afterward. `test.extend` defines the dependencies. Playwright starts the server before creating the client, then runs teardown after the test releases it with `use()`.

Install the test runner, then save the two files below.

`npm init -y npm install -D @playwright/test @types/node`

Create `fixtures.ts`. The `seededApi` fixture requests both the custom `apiServer` fixture and Playwright’s built-in `request` fixture. The server keeps its data in memory, so the example needs no external service.

`import { createServer } from 'node:http'; import { test as base } from '@playwright/test';  type Item = { id: number; name: string }; type SeededApi = {  seededItem: Item;  create: (name: string) => Promise<Item>;  get: (id: number) => Promise<Item>; };  export const test = base.extend<{  apiServer: string;  seededApi: SeededApi; }>({  apiServer: async ({}, use) => {  const items = new Map<number, Item>();   const server = createServer(async (req, res) => {  res.setHeader('content-type', 'application/json');   if (req.method === 'POST' && req.url === '/items') {  let body = '';  for await (const chunk of req) body += chunk;   const { name } = JSON.parse(body) as { name: string };  const item = { id: items.size + 1, name };  items.set(item.id, item);  res.end(JSON.stringify(item));  return;  }   const id = Number(req.url?.match(/^\/items\/(\d+)$/)?.[1]);  const item = items.get(id);  res.statusCode = item ? 200 : 404;  res.end(JSON.stringify(item ?? { error: 'Not found' }));  });   await new Promise<void>(resolve =>  server.listen(0, '127.0.0.1', resolve)  );   const address = server.address();  if (!address || typeof address === 'string') {  throw new Error('Server did not bind to a TCP port');  }   await use(`http://127.0.0.1:${address.port}`);   await new Promise<void>((resolve, reject) =>  server.close(error => error ? reject(error) : resolve())  );  },   seededApi: async ({ apiServer, request }, use) => {  const create = async (name: string): Promise<Item> => {  const response = await request.post(`${apiServer}/items`, {  data: { name },  });  return response.json() as Promise<Item>;  };   const get = async (id: number): Promise<Item> => {  const response = await request.get(`${apiServer}/items/${id}`);  return response.json() as Promise<Item>;  };   const seededItem = await create('Existing item');  await use({ seededItem, create, get });  }, });  export { expect } from '@playwright/test';`

Create `items.spec.ts`. Import the extended `test`, not the unmodified one, so Playwright can resolve `seededApi`.

`import { test, expect } from './fixtures';  test('reads a seeded item and creates another', async ({ seededApi }) => {  expect(await seededApi.get(seededApi.seededItem.id))  .toEqual(seededApi.seededItem);   const created = await seededApi.create('New item');  expect(await seededApi.get(created.id)).toEqual(created); });`

Run `npx playwright test items.spec.ts`. Both custom fixtures are test-scoped by default. Each test gets a fresh server, and the code after `await use(...)` closes it.

## Test-scoped vs worker-scoped fixtures

A test-scoped fixture runs for each test that requests it. A worker-scoped fixture runs once in a worker process and can serve multiple tests, including tests in different files assigned to that worker. Playwright may restart a worker after a failure, so worker scope does not mean once for the entire suite.

Save this example as `fixture-scope.spec.ts`. The server starts once per worker, while `apiResult` makes a fresh request for each test.

`import { test as base, expect } from '@playwright/test' import { createServer } from 'node:http'  type TestFixtures = { apiResult: { status: string } } type WorkerFixtures = { serverURL: string }  const test = base.extend<TestFixtures, WorkerFixtures>({  serverURL: [  async ({}, use) => {  const server = createServer((_, response) => {  response.setHeader('Content-Type', 'application/json')  response.end(JSON.stringify({ status: 'ok' }))  })   await new Promise<void>(resolve => {  server.listen(0, '127.0.0.1', resolve)  })   const address = server.address()  if (!address || typeof address === 'string') {  throw new Error('Server did not receive a TCP port')  }   try {  await use(`http://127.0.0.1:${address.port}`)  } finally {  await new Promise<void>((resolve, reject) => {  server.close(error => error ? reject(error) : resolve())  })  }  },  { scope: 'worker' }  ],   apiResult: async ({ request, serverURL }, use) => {  const response = await request.get(serverURL)  try {  await use(await response.json())  } finally {  await response.dispose()  }  } })  test('returns the server response', async ({ apiResult }) => {  expect(apiResult).toEqual({ status: 'ok' }) })`

Run it with `npx playwright test fixture-scope.spec.ts`. Use worker scope for expensive resources that tests can safely share. Keep mutable test data in test-scoped fixtures, because changes to worker-scoped state can leak into later tests.

## Auto fixtures that run without being requested

An auto fixture runs for every test that uses the extended `test`, even when the test does not request the fixture by name. You can use one to collect browser console errors and fail the test after its assertions finish.

Save this as `auto-console.spec.ts`.

`import { test as base, expect } from '@playwright/test';  type Fixtures = {  consoleErrors: string[]; };  const test = base.extend<Fixtures>({  consoleErrors: [  async ({ page }, use) => {  const errors: string[] = [];  const { type(): string; text(): string }) => {  if (message.type() === 'error') errors.push(message.text());  };   page.on('console', onConsole);  await use(errors);  page.off('console', onConsole);  expect(errors).toEqual([]);  },  { auto: true },  ], });  test('shows the save button', async ({ page }) => {  await page.setContent('');  await expect(page.getByRole('button', { name: 'Save' })).toBeVisible(); });`

Run the example with these commands.

`npm install -D @playwright/test npx playwright install chromium npx playwright test auto-console.spec.ts`

The test requests only `page`, but Playwright still starts `consoleErrors` before the test and checks the collected messages afterward. Keep auto fixtures limited to checks you want in every test. Otherwise, a test can fail because of setup it never names.

## Authentication fixtures with storageState

Authenticate once in a setup project, then load the saved browser state into each test’s fresh context. The example below runs against a small local app, so it needs no external account. The login credentials and cookie are for demonstration only.

Install Playwright and its browser.

`npm init -y npm i -D @playwright/test npx playwright install chromium`

Create `app.cjs`.

`const { createServer } = require('node:http');  createServer(async (req, res) => {  if (req.method === 'GET' && req.url === '/login') {  res.setHeader('Content-Type', 'text/html');  res.end(``);  return;  }   if (req.method === 'POST' && req.url === '/login') {  let body = '';  for await (const chunk of req) body += chunk;  const fields = new URLSearchParams(body);   if (fields.get('user') === 'demo' &&  fields.get('password') === 'demo') {  res.writeHead(302, {  Location: '/dashboard',  'Set-Cookie': 'session=demo; HttpOnly; SameSite=Lax',  });  res.end();  return;  }  }   if (req.url === '/dashboard' &&  req.headers.cookie?.includes('session=demo')) {  res.setHeader('Content-Type', 'text/html');  res.end('<h1>Dashboard</h1>');  return;  }   res.writeHead(401);  res.end('Unauthorized'); }).listen(3100, '127.0.0.1');`

Create `playwright.config.ts`. The dependency makes the setup project finish before the authenticated spec starts.

`import { defineConfig } from '@playwright/test';  export default defineConfig({  use: { baseURL: 'http://127.0.0.1:3100' },  webServer: {  command: 'node app.cjs',  url: 'http://127.0.0.1:3100/login',  },  projects: [  { name: 'setup', testMatch: '**/auth.setup.ts' },  {  name: 'chromium',  testMatch: '**/auth.spec.ts',  dependencies: ['setup'],  use: { browserName: 'chromium' },  },  ], });`

Create `auth.setup.ts` to write the state file.

`import { mkdir } from 'node:fs/promises'; import { test, expect } from '@playwright/test';  test('authenticate', async ({ page }) => {  await page.goto('/login');  await page.locator('[name="user"]').fill('demo');  await page.locator('[name="password"]').fill('demo');  await page.getByRole('button', { name: 'Log in' }).click();  await expect(page.getByRole('heading', { name: 'Dashboard' }))  .toBeVisible();   await mkdir('playwright/.auth', { recursive: true });  await page.context().storageState({  path: 'playwright/.auth/user.json',  }); });`

Create `fixtures.ts` and `auth.spec.ts`. Overriding Playwright’s `storageState` fixture supplies the saved state when Playwright creates each test context.

`// fixtures.ts import { test as base } from '@playwright/test';  export const test = base.extend({  storageState: async ({}, use) => {  await use('playwright/.auth/user.json');  }, }); export { expect } from '@playwright/test';`

`// auth.spec.ts import { test, expect } from './fixtures';  test('opens the authenticated dashboard', async ({ page }) => {  await page.goto('/dashboard');  await expect(page.getByRole('heading', { name: 'Dashboard' }))  .toBeVisible(); });`

Run `npx playwright test`. Add `playwright/.auth/` to `.gitignore` because saved auth state can contain sensitive cookies. A shared login keeps setup fast, but tests that modify account data may need separate accounts to preserve [test isolation](/blog/session-management-and-isolation-for-reliable-tests).

## Fixtures vs beforeEach

Use a fixture when setup provides a value that tests request by name. Use `beforeEach` when every test in a file needs the same simple setup. Both approaches can prepare a page, but a fixture keeps setup beside the value it supplies and can run cleanup after `use()` returns.

In `tests/fixture.spec.ts`, `readyPage` depends on Playwright’s built-in `page` fixture. The test requests `readyPage`, so Playwright navigates before running the test.

`import { test as base, expect, type Page } from '@playwright/test';  const test = base.extend<{ readyPage: Page }>({  readyPage: async ({ page }, use) => {  await page.goto('https://example.com/');  await use(page);  }, });  test('fixture setup', async ({ readyPage }) => {  await expect(readyPage).toHaveTitle('Example Domain'); });`

In `tests/hook.spec.ts`, the hook performs the same navigation before each test. No custom fixture is needed.

`import { test, expect } from '@playwright/test';  test.beforeEach(async ({ page }) => {  await page.goto('https://example.com/'); });  test('hook setup', async ({ page }) => {  await expect(page).toHaveTitle('Example Domain'); });`

Run both files with `npx playwright test tests/fixture.spec.ts tests/hook.spec.ts`. Playwright closes the built-in `page` after each test in either version. If your setup creates another resource, a fixture can release it after `await use(value)`. Hook-based setup needs a corresponding `afterEach` for that cleanup. For a single file that only needs navigation, `beforeEach` remains the simpler choice.

## Overriding fixtures per project

Project configuration can change a fixture option without duplicating the fixture implementation. The same `readyPage` fixture below opens a different path for each project because it reads `startPath` before passing the page to `use()`. Playwright applies each project's `use` values when it runs the test.

Create `fixtures.ts`.

`import { test as base, expect, type Page } from '@playwright/test';  export type Options = { startPath: string };  export const test = base.extend<Options & { readyPage: Page }>({  startPath: ['/', { option: true }],  readyPage: async ({ page, startPath }, use) => {  await page.goto(startPath);  await use(page);  }, });  export { expect };`

Create `playwright.config.ts`. Both projects use the same public site so the example runs without an application server.

`import { defineConfig } from '@playwright/test'; import type { Options } from './fixtures';  export default defineConfig<Options>({  projects: [  {  name: 'desktop',  use: {  baseURL: 'https://example.com',  startPath: '/',  viewport: { width: 1280, height: 720 },  },  },  {  name: 'mobile',  use: {  baseURL: 'https://example.com',  startPath: '/?view=mobile',  viewport: { width: 390, height: 844 },  },  },  ], });`

Create `example.spec.ts`.

`import { test, expect } from './fixtures';  test('opens the configured page', async ({ readyPage }) => {  await expect(readyPage.locator('h1')).toHaveText('Example Domain'); });`

Run it with these commands.

`npm init -y npm install -D @playwright/test npx playwright install chromium npx playwright test`

For staging and production, you can set a different `baseURL` in each project while keeping `readyPage` unchanged. A project can override a declared fixture _option_ through `use`, as `startPath` does here. Replacing a fixture's setup function requires a separate extended `test`, rather than a function assigned to the project's `use` configuration.

## Common mistakes with Playwright fixtures

Playwright fixtures can make tests flaky when their scope or cleanup does not match the state they manage. The [fixture lifecycle](https://playwright.dev/docs/test-fixtures) helps explain four common mistakes.

- **Forgetting to request a fixture.** A normal custom fixture runs only when a test or another fixture names it as a dependency. Defining an `apiClient` fixture does not initialize it for every test. Add `apiClient` to the test argument’s destructured properties wherever the test needs it.
- **Keeping mutable state in a worker-scoped fixture.** Playwright reuses a worker fixture across tests in that worker. If one test changes a shared client’s headers or account state, a later test may inherit the change. Keep mutable test state in a test-scoped fixture, or reset shared state between tests.
- **Overusing `auto`.** An auto fixture runs without appearing in the test’s arguments. Use it for work every test needs, such as collecting failure evidence. Otherwise, hidden setup makes a test’s dependencies harder to inspect and adds work to tests that do not need it.
- **Skipping asynchronous teardown.** A fixture must await `use(value)` before it cleans up, and it must await asynchronous cleanup such as `client.close()`. Without those awaits, a shared resource can close too early or remain open when the next test starts.

When failures depend on test order or appear only in CI, check fixture scope and cleanup alongside the other causes in [this flaky-test debugging guide](/blog/how-to-debug-and-fix-flaky-playwright-tests).

## Where fixtures fit in a larger test strategy

[Playwright fixtures](https://playwright.dev/docs/test-fixtures) give tests the setup they request and run teardown afterward. Test-scoped fixtures help isolate tests, while worker-scoped fixtures let tests share resources. Neither scope decides which product behavior deserves a test or whether a UI change calls for a test update.

To extend a Playwright suite, use the [Playwright locators guide](/blog/playwright-locators-guide) for selector choices and [Playwright E2E testing best practices](/blog/playwright-e2e-testing-best-practices) for broader test design. As the product changes, you also need a way to maintain tests and distinguish regressions from test failures. Evaluate [Momentic](/docs) against those requirements.

## Frequently asked questions

Can a custom fixture override the built-in `page` fixture?    Yes. Define page in test.extend and use the original page as its dependency. Your replacement can configure the page before passing it to use().    Can fixtures be async?    Yes. An async fixture can await setup, pass its value to use(), and await cleanup after use() returns.    How many fixtures can one test use?    Playwright documents no fixed limit. Request the fixtures the test needs in its argument object. Playwright also resolves fixtures that those fixtures depend on.    Do fixtures work with `test.describe.parallel`?    Yes. Test-scoped fixtures run for each test. Worker-scoped fixtures run once per worker, and parallel workers do not share their fixture instances.    What happens when merged extended tests define the same fixture name?    The definitions cannot remain independent under one name. Give the fixtures distinct names, or combine their behavior in one fixture before merging the tests.

Still have additional questions?

## Keep reading.

[Resources   Testing iOS and Android apps with Claude Code     Four ways to test iOS and Android apps with Claude Code, from simctl and adb to XCUITest, Espresso, mobile MCP servers and Momentic mobile tests.     Wei-Wei Wu     12 min read](/blog/claude-code-mobile-app-testing)[Resources   UI and end-to-end testing with OpenAI Codex     How far Codex gets on UI and end-to-end tests, and how AGENTS.md instructions plus a test runner over MCP turn its output into checks you can trust.     Wei-Wei Wu     8 min read](/blog/codex-ui-testing-guide)[Resources   Migrating from Appium to Maestro     How to move an Appium suite to Maestro flows, with a worked login example, a concept map and the cases where Appium is still the better fit.     Wei-Wei Wu     6 min read](/blog/migrate-appium-to-maestro)

## Close the feedback loop.

Point Momentic at your app. Free to start, no credit card.

[Try for free](https://app.momentic.ai/signup) [Contact sales](/sales)
