Home / Blog / Playwright fixtures

Playwright fixtures: when to write one, and when a function will do

Quick answer

A Playwright fixture is a piece of setup a test asks for by name, in its argument list. You define one with test.extend, and the code after await use() is its teardown. Write one when the setup produces a value the test needs, when it has to be undone, or when it is expensive enough to build once per worker.

Every spec file in the suite opens with a beforeEach that signs in. Around spec eighty someone added a second one that seeds a record, and a third that flips a feature flag, and all three have been copied between files with divergences nobody can account for. Fixtures are the standard advice here, and the question worth asking is narrower: which of the three should move, and what does moving them cost the next person who opens the file?

What is a Playwright fixture?

Playwright's fixtures guide defines them this way: "Playwright Test is based on the concept of test fixtures. Test fixtures are used to establish the environment for each test, giving the test everything it needs and nothing else. Test fixtures are isolated between tests."

You are already using them. page, context, browser and request are built-in fixtures, and so is baseURL, whose value comes from the config. A test names the ones it wants by destructuring its first argument, and the runner sets up those and no others.

The mechanism is small. What it changes is where setup lives: a fixture moves it out of the file that depends on it, and that move is the cost this page is about.

The code below is Playwright 1.62. Every block is a file that ran against a local application, with channel: 'chrome' in the config, before it went on the page.

How do you write a custom fixture?

test.extend takes an object of fixture functions and returns a new test. Each function gets the fixtures it depends on as its first argument and a use callback as its second, and whatever you hand to use is what the test receives.

import { test as base, expect, type Page } from '@playwright/test';

const test = base.extend<{ appPage: Page }>({
  appPage: async ({ page }, use) => {
    await page.goto('/sign-in');
    await use(page);
  },
});

test('a visitor with no session lands on the sign-in form', async ({ appPage }) => {
  await expect(appPage.getByRole('button', { name: 'Sign in' })).toBeVisible();
});
Playwright 1.62 · TypeScript · passes

The type parameter puts appPage in the argument list with a type on it, so a misspelling is a compile error instead of an undefined at run time. The extension and the test share one file here to keep the sample whole. In a suite the extend call goes in its own module, that module re-exports test and expect, and the specs import from there instead of from @playwright/test. Every later sample on this page is written that way.

Should this beforeEach become a fixture?

Take one hook you already have and answer these against it. One of the answers is to leave it where it is.

The documentation never makes the case for that function, and it is often the right answer. Setup that takes no browser, produces a value, needs no teardown and is called by three specs out of two hundred should be a function. Someone reading createCustomer() in a test body can jump to its definition; someone reading customer in an argument list first has to work out which module test was extended in.

The worry that a refactor leaves the suite less readable for everybody else is correct, and it has a threshold. A spec whose setup is four fixtures it never names has put that setup where the file cannot show you. Fixtures pay while the argument list still reads as a list of what the test needs.

A fixture can also hand a test a page object, which is how the documentation's own example is built. Whether the class is worth having at all is a question with its own page.

How do you get a signed-in page without logging in every test?

Signing in through the form in every test puts the slowest and least stable operation in the suite in front of all of them, and it puts a flake in the one place where a failure tells you nothing about the feature being tested. This fixture hands the test a page that already has a session.

import { test as base, type Page } from '@playwright/test';

export const test = base.extend<{ signedInPage: Page }>({
  signedInPage: async ({ browser, baseURL }, use) => {
    const context = await browser.newContext({
      baseURL,
      storageState: 'playwright/.auth/user.json',
    });
    const page = await context.newPage();
    await page.goto('/');
    await use(page);
    await context.close();
  },
});

export { expect } from '@playwright/test';
Playwright 1.62 · TypeScript · tests/fixtures/auth.ts
import { test, expect } from './fixtures/auth';

test('the home page names the signed-in user', async ({ signedInPage }) => {
  await expect(signedInPage.getByRole('heading')).toHaveText('Signed in as ada@example.com');
});
Playwright 1.62 · TypeScript · passes

The state file is written once, before the run, by a login that happens in one place. Producing it is a subject of its own: where the storage state comes from covers the setup project that writes the file, and what changes when there are four roles instead of one.

Two things before you copy it. The fixture builds its own context, so a test asking for signedInPage and page together gets two browser contexts and only one of them is signed in. The context is closed after use, which is what stops a run of two hundred tests ending with two hundred contexts open.

How do you make test data clean up after itself?

request is a built-in fixture and it is the shortest way to create the record a test needs. The create happens before use, the delete after it, both in one function.

import { test as base, expect } from '@playwright/test';

type Order = { id: string; reference: string };

export const test = base.extend<{ order: Order }>({
  order: async ({ request }, use, testInfo) => {
    const created = await request.post('/api/orders', {
      data: { reference: `ORD-${testInfo.testId}` },
    });
    expect(created.status()).toBe(201);
    const order: Order = await created.json();
    await use(order);
    await request.delete(`/api/orders/${order.id}`);
  },
});

export { expect } from '@playwright/test';
Playwright 1.62 · TypeScript · tests/fixtures/order.ts
import { test, expect } from './fixtures/order';

test('an order page is titled by its reference', async ({ page, order }) => {
  await page.goto(`/orders/${order.id}`);
  await expect(page.getByRole('heading')).toHaveText(`Order ${order.reference}`);
});
Playwright 1.62 · TypeScript · passes

The teardown runs when the body fails. Run against 1.62 with a deliberately failing assertion in the test, the DELETE still went out, which is what a try/finally around an afterEach would otherwise have to arrange by hand. It is not a guarantee for every way a test can end, though, and which endings take a pending teardown down with them is measured in the article on seeding and cleaning up test data.

The reference is keyed to testInfo.testId rather than being a constant. Two workers running two specs that both seed ORD-1001 collide, and what comes back is a uniqueness error from the API in a test that has nothing to do with either of them, or a pass that the other test's record paid for.

How do you build one expensive resource per worker?

Workers are processes. The retries guide says so under Failures: "Playwright Test runs tests in worker processes. These processes are OS processes, running independently, orchestrated by the test runner." The parallelism guide adds the part that makes worker scope worth having: "Playwright Test reuses a single worker as much as it can to make testing faster, so multiple test files are usually run in a single worker one after another." Something built once per worker is built once for a long run of test files.

import { test as base, request } from '@playwright/test';

type Container = { id: string; name: string; url: string };

export const test = base.extend<{}, { dbContainer: Container }>({
  dbContainer: [async ({}, use, workerInfo) => {
    const api = await request.newContext({ baseURL: 'http://localhost:3000' });
    const created = await api.post('/api/containers', {
      data: { name: `orders-db-${workerInfo.workerIndex}` },
    });
    const container: Container = await created.json();
    await use(container);
    await api.delete(`/api/containers/${container.id}`);
    await api.dispose();
  }, { scope: 'worker' }],
});

export { expect } from '@playwright/test';
Playwright 1.62 · TypeScript · tests/fixtures/container.ts
import { test, expect } from './fixtures/container';

const containersSeenInThisWorker: string[] = [];

test('the container is named for the worker that built it', async ({ dbContainer }) => {
  containersSeenInThisWorker.push(dbContainer.id);
  expect(dbContainer.name).toBe(`orders-db-${test.info().workerIndex}`);
});

test('the next test in the same worker is handed the same container', async ({ dbContainer }) => {
  expect(containersSeenInThisWorker).toContain(dbContainer.id);
});
Playwright 1.62 · TypeScript · passes

The tuple syntax is required, the function and then { scope: 'worker' }, and worker fixtures are declared in the second type parameter of extend, which is why the first one here is empty.

The container is named from workerInfo.workerIndex, and the two indices are not interchangeable. workerIndex is unique and a restarted worker gets a new one; parallelIndex sits between 0 and workers - 1 and a restarted worker keeps the value the old one had. Name something the fixture creates itself with workerIndex, so two containers can never collide. Claim one of a fixed pool that already exists, four seeded accounts or four schemas, with parallelIndex, which only has to be as large as the number of workers. Both arms were run against a restarted worker in the article on seeding and cleaning up test data, which is where the evidence for that split sits.

A worker fixture cannot ask for page, request or baseURL, because all three are test-scoped. The runner refuses before it runs anything:

worker fixture "leased" cannot depend on a test fixture "baseURL" defined in <builtin>.
Playwright 1.62 · the run that refused

Which is why the fixture above builds its own API context with request.newContext() and hands it a URL of its own.

Worker scope is the right answer when the thing is expensive to create and no test modifies it: a container, a compiled artifact, a warmed cache, a licence checked out from a server.

Worker scope is a shared-state bug waiting the moment a test writes to it. The value is built once and handed to every test that worker runs, so anything one test mutates is still mutated for the next one. Worker assignment moves with --workers and with file order, so the spec that goes red is not the spec that did it, and it goes green again the moment anyone runs it on its own. If a test writes to it, make it test-scoped and pay for the rebuild.

How do you change a fixture's behaviour per project?

An option fixture is a fixture whose default can be overridden from the config. Declare it with the tuple syntax and { option: true }, then set it project by project.

import { test as base } from '@playwright/test';

export type PlanOptions = { plan: 'free' | 'enterprise' };

export const test = base.extend<PlanOptions>({
  plan: ['free', { option: true }],
});

export { expect } from '@playwright/test';
Playwright 1.62 · TypeScript · tests/fixtures/plan.ts
import { defineConfig } from '@playwright/test';
import type { PlanOptions } from './tests/fixtures/plan';

export default defineConfig<PlanOptions>({
  testDir: './tests',
  use: {
    channel: 'chrome',
    baseURL: 'http://localhost:3000',
  },
  projects: [
    { name: 'free', use: { plan: 'free' } },
    { name: 'enterprise', use: { plan: 'enterprise' } },
  ],
});
Playwright 1.62 · TypeScript · playwright.config.ts
import { test, expect } from './fixtures/plan';

const heading = { free: 'Free plan', enterprise: 'Enterprise plan' } as const;

test('the billing page shows the plan this project runs against', async ({ page, plan }) => {
  await page.goto(`/billing/${plan}`);
  await expect(page.getByRole('heading')).toHaveText(heading[plan]);
});
Playwright 1.62 · TypeScript · passes

Both blocks are needed and neither runs without the other. The declaration gives the option its type and its default; the config decides what each project runs with. defineConfig takes the option type as a parameter, so a project that sets plan: 'entrprise' fails to compile instead of failing on a 404 twenty minutes into CI.

Without one, that variation lives in process.env branches inside the specs, and a spec then reads differently depending on an environment variable that CI sets and nobody's laptop does. Here every value the option can take sits in one config file a reviewer reads. test.use() sets the same options for a single file or a single describe block, where a whole project is too coarse.

What runs when: scope, lifetime and automatic fixtures

The fixtures guide states the rules once, under Execution order: "Each fixture has a setup and teardown phase before and after the await use() call in the fixture. Setup is executed before the test/hook requiring it is run, and teardown is executed when the fixture is no longer being used by the test/hook." Three rules follow it, verbatim:

Laziness is the property that gets underused. A fixture nobody in this run asks for is never set up, so a suite can carry one for something expensive and only pay for it in the runs that touch it. A beforeAll charges every run.

Automatic fixtures are the exception: "Automatic fixtures are set up for each test/worker, even when the test does not list them directly." That is how you build a global beforeEach, and this is the smallest useful one.

import { test as base } from '@playwright/test';

export const test = base.extend<{ serverErrors: void }>({
  serverErrors: [async ({ page }, use, testInfo) => {
    const failures: string[] = [];
    page.on('response', response => {
      if (response.status() >= 500)
        failures.push(`${response.status()} ${response.url()}`);
    });
    await use();
    if (failures.length > 0)
      await testInfo.attach('server-errors', { body: failures.join('\n') });
  }, { auto: true }],
});

export { expect } from '@playwright/test';
Playwright 1.62 · TypeScript · tests/fixtures/server-errors.ts

Every test that imports this test now runs the fixture, and no spec file mentions it. It also depends on page, so every test gets a browser page whether it wanted one or not, an API-only spec included. An automatic fixture is a standing charge on every test in the suite, levied in a file nobody opens, so keep the list of them short enough to recite. Everything else worth holding a suite to has a list of its own.

When this bites you

A fixture throws during setup. The test is reported as failed and its body never runs. The code snippet in the report tells them apart: it points at the line inside the fixture that threw and shows no line of the spec, where an assertion failure points into the spec. When the report opens on a file none of your tests live in, read the argument list.

A fixture throws during teardown, after the body has passed. The test is reported as failed. Run against 1.62, a fixture that threw after await use() with a passing assertion in the body produced one failed test and an error naming the teardown line. A cleanup call that starts returning 500 takes a green suite red, which is the right default.

A fixture is slow and the test times out. The timeouts guide is explicit: "Playwright Test enforces a timeout for each test, 30 seconds by default. Time spent by the test function, fixture setups, and beforeEach hooks is included in the test timeout." The rest is arithmetic on that 30-second default, which is configurable: a fixture that takes 28 seconds leaves 2 for everything the test does, so a body that needs 4 fails at 30. Run that way, the report reads Test timeout of 30000ms exceeded. and names no fixture at all. The documented fix is to give the fixture a budget of its own, which the timeouts guide recommends for slow fixtures so that the test timeout can stay small.

import { test as base, expect } from '@playwright/test';

const test = base.extend<{ warmedCache: string }>({
  warmedCache: [async ({ request }, use) => {
    const response = await request.get('/api/orders');
    await use(`${response.status()}`);
  }, { timeout: 60_000 }],
});

test('the report is ready before the test starts', async ({ warmedCache }) => {
  expect(warmedCache).toBe('200');
});
Playwright 1.62 · TypeScript · passes

A test fails and the worker is discarded. "Should any test fail, Playwright Test will discard the entire worker process along with the browser and will start a new one. Testing will continue in the new worker process starting with the next test." Everything worker-scoped goes with it. Run against 1.62 with a worker fixture that logged its own setup and teardown, the failing test discarded the first worker, its teardown ran, and the replacement worker built the fixture again under a different worker index. The teardown does happen, so the container is released rather than leaked, and one failed assertion costs a rebuild of everything that worker held.

What does not belong in a fixture?

Setup that has to happen once for the whole run. A database migration, a service that must be up before anything starts, a seed the entire suite reads: none of that is per test or per worker. Playwright's guidance is a setup project, a project the others depend on, in preference to globalSetup, and one of the reasons it gives is that a setup project can use fixtures at all, on top of appearing in the HTML report and recording a trace.

The one-off does not belong either. Setup that exactly one test needs belongs in that test, where whoever reads the file can see it.

Nor does setup cheap enough that the fixture costs more than it saves. Two lines of arrangement in a spec are two lines; a module, a type and an import are a worse trade.

Once the question is which hooks stay, which become fixtures, which become plain functions and where the option fixtures live, it has stopped being a question about one hook and become one about the shape of the repository, which is the question Playwright framework development is the engagement for.

Questions

What is the difference between a fixture and a beforeEach hook in Playwright?

A hook runs for every test in its scope and cannot return anything, so a value it produces has to leave through a variable in module scope. A fixture is asked for by name in a test's argument list, runs only for the tests that ask, hands its value over through await use() and cleans up in the same function. If the hook produces a value or has to be undone it wants to be a fixture; if every test in the file needs it and nothing else ever will, the hook is already right.

When should I use a worker-scoped fixture?

When the thing is expensive to create and no test modifies it: a container, a warmed cache, a licence checked out from a server. It is built once per worker process and reused by every test that worker runs, so anything a test writes to it is still written for the next one, and worker assignment moves with the number of workers and with file order, so the spec that fails is not the spec that caused it. If a test mutates it, make it test-scoped.

How do I clean up after a fixture?

Everything after await use() in the fixture body is the teardown. It runs when the test finishes, including when the test failed, so a record created before use and deleted after it cannot be left behind by a failing assertion. A fixture that throws in its own teardown marks the test as failed even when the body passed.

Can a fixture depend on another fixture?

Yes. List it in the first argument, the same way a test does. The documented order is that when fixture A depends on fixture B, B is always set up before A and torn down after A, so a fixture can rely on everything it names being ready, and still standing while it tears down. The rule that catches people: a worker-scoped fixture cannot depend on a test-scoped one, and the runner refuses at load time.

Do fixtures replace the page object model?

No. They sit at different points, and the fixture is the delivery: a page object is one of the things it can hand over, which is how the documentation's own fixtures example is built. Whether the class is worth having is argued on its own page.

How many hooks are at the top of your spec files?

Send the shape of the suite: roughly how many specs, how many beforeEach hooks between them, and whether it runs in parallel today. Those answers decide whether this is an afternoon of moving setup around or a structural job. If you would rather start from what you already have, the audit is optional and no engagement here waits on one.