Evaluating CodeRabbit? Same review, plus real test runs. See why

Automation Testing11 min read

Playwright Fixtures: Scope, Setup and Teardown

S
Technical Writer, Qodex
A Playwright fixture that creates an account before use() and deletes it after, beside the run log showing the app starting once per worker and each account deleted after its test

Playwright fixtures are the setup and teardown that Playwright Test runs around each test. A test asks for fixtures by name, such as { page }, and Playwright prepares those, their dependencies and any automatic ones. Add your own with test.extend(): setup goes before await use(value), cleanup after it. Fixtures last one test by default, or one worker process with { scope: 'worker' }.

Part of our UI Testing guide. Read the guide

Checked with Playwright 1.63.0, read 4 October 2026. The two files below ran as printed in an empty folder, and the log is copied from that run.

Prefer not to maintain the setup by hand? Describe the flow in a sentence. Qodex drives the real app, brings back a screenshot of what broke, and saves the run as Playwright you own. See Qodex UI testing or try it on your app.

What Playwright fixtures are

Playwright's own definition: "Test fixtures are used to establish environment for each test, giving the test everything it needs and nothing else." The runner reads which fixtures each test declares, prepares those, and passes them in as the test's first argument. Hooks, annotations and other fixtures receive the same object. Source: the Fixtures API page, read 4 October 2026.

Compared with beforeEach and afterEach, the fixtures guide lists these advantages:

  • Setup and teardown sit in one place, next to each other.

  • A fixture is defined once and reused across test files. The built-in page works this way.

  • They are on-demand: Playwright sets up "only the ones needed by your test and nothing else".

  • They compose, because one fixture can depend on another.

Source: the fixtures guide, read 4 October 2026.

Built-in fixtures and their lifetime

Playwright Test ships these fixtures. Lifetimes are stated as the Fixtures API page states them. Source: the Fixtures API page, read 4 October 2026.

FixtureTypeLifetimeWhat it gives the testAdded
pagePageA new one for each testAn isolated tab, belonging to contextv1.10
contextBrowserContextA new one for each testA fresh browser profile, cookies and storagev1.10
browserBrowserShared by the tests in one workerThe browser process itselfv1.10
browserName"chromium", "firefox" or "webkit"Not statedThe engine running the test; default 'chromium'v1.10
requestAPIRequestContextA new one for each testAn HTTP client for API callsv1.10

The same page lists mount, added in v1.62, which mounts a component story from a gallery page you serve. Our Playwright component testing guide covers that setup. For the request fixture in API tests, see the Playwright API testing guide.

The split in that table is the design. The expensive thing, the browser, lives for a whole worker. The thing that holds state, the context, is new for every test, so one test's cookies never leak into the next.

A runnable fixture file

The example needs nothing but Playwright. In an empty folder:

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

Save this as fixtures.ts. It defines four fixtures. appURL starts a small app once per worker. account creates a test account through that app's API and deletes it afterwards. email is an option with a default, and logTitle runs for every test without being asked for. It also overrides the built-in baseURL, so page.goto('/...') reaches the worker's app.

import { test as base, expect } from '@playwright/test';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';

type Account = { id: number; email: string };

type TestFixtures = {
  email: string;
  account: Account;
  logTitle: void;
};

type WorkerFixtures = {
  appURL: string;
};

export const test = base.extend<TestFixtures, WorkerFixtures>({
  // Option fixture: a default value that test.use() or a project can override.
  email: ['ana@example.com', { option: true }],

  // Worker fixture: one small app per worker process, shared by its tests.
  appURL: [async ({}, use, workerInfo) => {
    const accounts = new Map<number, string>();
    let nextId = 1;
    const server = createServer((req, res) => {
      if (req.method === 'POST' && req.url === '/api/accounts') {
        let body = '';
        req.on('data', (chunk) => (body += chunk));
        req.on('end', () => {
          const id = nextId++;
          accounts.set(id, JSON.parse(body).email);
          res.writeHead(201, { 'content-type': 'application/json' });
          res.end(JSON.stringify({ id }));
        });
        return;
      }
      const del = /^\/api\/accounts\/(\d+)$/.exec(req.url ?? '');
      if (req.method === 'DELETE' && del) {
        accounts.delete(Number(del[1]));
        res.writeHead(204);
        return res.end();
      }
      const page = /^\/accounts\/(\d+)$/.exec(req.url ?? '');
      const email = page ? accounts.get(Number(page[1])) : undefined;
      res.writeHead(email ? 200 : 404, { 'content-type': 'text/html' });
      const safe = email?.replace(/[<&]/g, (c) => (c === '<' ? '&lt;' : '&amp;'));
      res.end(safe ? `<h1>Account</h1><p>${safe}</p>` : '<h1>Not found</h1>');
    });
    await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
    const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
    console.log(`worker ${workerInfo.workerIndex}: app started`);
    await use(url);
    await new Promise((resolve) => server.close(resolve));
    console.log(`worker ${workerInfo.workerIndex}: app stopped`);
  }, { scope: 'worker' }],

  // Override a built-in option so page.goto('/...') reaches the worker's app.
  baseURL: async ({ appURL }, use) => {
    await use(appURL);
  },

  // Test fixture: create an account before the test, delete it after.
  account: async ({ request, appURL, email }, use) => {
    const created = await request.post(`${appURL}/api/accounts`, { data: { email } });
    const { id } = await created.json();
    console.log(`  account ${id} created for ${email}`);
    await use({ id, email });
    await request.delete(`${appURL}/api/accounts/${id}`);
    console.log(`  account ${id} deleted`);
  },

  // Automatic fixture: runs for every test, though no test asks for it.
  logTitle: [async ({}, use, testInfo) => {
    console.log(`  > ${testInfo.title}`);
    await use();
  }, { auto: true }],
});

export { expect };

Tests import test from that file instead of from @playwright/test. That one import is how a test gets the custom fixtures. Save this as tests/account.spec.ts:

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

test('shows the account page', async ({ page, account }) => {
  await page.goto(`/accounts/${account.id}`);
  await expect(page.getByText(account.email)).toBeVisible();
});

test('does not create an account it does not ask for', async ({ page }) => {
  await page.goto('/accounts/999');
  await expect(page.getByRole('heading')).toHaveText('Not found');
});

test('cleans up after a failing test too', async ({ account }) => {
  test.fail(); // this test is expected to fail
  expect(account.email).toBe('someone-else@example.com');
});

test.describe('with another email', () => {
  test.use({ email: 'bo@example.com' });

  test('uses the overridden option', async ({ account }) => {
    expect(account.email).toBe('bo@example.com');
  });
});

Run it with one worker, so the log reads in order:

$ npx playwright test --workers=1

Running 4 tests using 1 worker

worker 0: app started
  > shows the account page
  account 1 created for ana@example.com
  account 1 deleted
  ✓  1 tests/account.spec.ts:3:5 › shows the account page (72ms)
  > does not create an account it does not ask for
  ✓  2 tests/account.spec.ts:8:5 › does not create an account it does not ask for (45ms)
  > cleans up after a failing test too
  account 2 created for ana@example.com
  account 2 deleted
  ✘  3 tests/account.spec.ts:13:5 › cleans up after a failing test too (4ms)
  > uses the overridden option
  account 3 created for bo@example.com
  account 3 deleted
  ✓  4 tests/account.spec.ts:21:7 › with another email › uses the overridden option (2ms)
worker 0: app stopped

  4 passed (473ms)

What the run log shows

Each line of that log matches a rule in the fixtures guide's section on execution order. Source: the fixtures guide, read 4 October 2026.

  • Setup before the test, teardown after it. "account 1 created" prints before the first test runs and "account 1 deleted" right after it. Everything before await use() is setup, everything after it is teardown.

  • Dependencies set up first. account depends on appURL, so the app starts before any account exists. The guide's rule: when fixture A depends on fixture B, "B is always set up before A and torn down after A".

  • Fixtures are lazy. The second test never asks for account, and no account is created for it. The guide: non-automatic fixtures "are executed lazily, only when the test/hook needs them".

  • Teardown runs after a failure too. The third test fails on purpose (test.fail() marks it as expected), and "account 2 deleted" still prints. In this run, the failing test did not leave its account behind.

  • Worker fixtures live as long as the worker. "app started" and "app stopped" print once for four tests. Test-scoped fixtures are torn down after each test, worker-scoped ones when the worker process ends.

The ✘ on the third line is the expected failure. Because test.fail() declared it, the run still reports 4 passed.

Worker-scoped and automatic fixtures

Add { scope: 'worker' } to the tuple form, as appURL does, and the fixture runs once per worker process. Use it for things that are slow to create and safe to share: a server, a database container, a signed-in storage state. Worker fixtures receive workerInfo as their third argument, which is where the log's "worker 0" comes from.

A worker can be restarted, for example after a failure, and the new worker sets up its worker fixtures again. The parallelism guide says a replacement worker keeps the same parallelIndex but gets a new workerIndex. Source: parallelism, read 4 October 2026.

{ auto: true } makes a fixture run for every test, or every worker, even when no test lists it. That is what logTitle does, and the log shows its line before each test. The fixtures guide suggests auto fixtures when you want hooks that run before or after each test globally, because beforeEach only covers its own file or describe block. Automatic test fixtures are set up before beforeEach hooks. Source: the fixtures guide, read 4 October 2026.

Option fixtures with test.use and projects

An option is a fixture with a default value, declared with { option: true }. In the example, email defaults to ana@example.com. The test.use({ email: 'bo@example.com' }) line in the describe block changes it for that block only, and the log shows account 3 created for Bo.

Options can also be set per project in playwright.config.ts, under each project's use. That runs the same tests with different values. The parameterization guide shows a person option set to Alice in one project and Bob in another. Source: parameterize tests, read 4 October 2026.

test.use can sit in a file or a describe block, not inside beforeEach or beforeAll. Source: the Test API page, read 4 October 2026.

Fixture timeout, box and title

Three options in the fixture tuple change how a fixture is timed and reported. Source: the fixtures guide, read 4 October 2026.

  • timeout. A test-scoped fixture's setup and teardown count toward the test's timeout. A slow fixture can take { timeout: 60000 } to get its own budget. Worker-scoped fixtures have their own timeout, equal to the test timeout unless you set one.

  • box. Custom fixtures appear as steps in UI mode, Trace Viewer and reports. { box: true } hides a noisy helper from those views, and { box: 'self' } hides only the wrapper while keeping its inner steps.

  • title. { title: 'demo app' } replaces the fixture name in reports and error messages.

Boxing was added in v1.46. Source: the release notes, read 4 October 2026. None of the three changes the log above, so we do not print output for them.

Sign in once per worker

The authentication guide has a fixture-based pattern for suites where tests change server-side state. Each parallel worker gets its own account, signs in once, and shares the saved state with its tests. Source: authentication, read 4 October 2026.

  • A worker-scoped fixture, workerStorageState, picks an account by test.info().parallelIndex, signs in with a clean page, and saves the state to a file under the project's output folder.

  • The built-in storageState fixture is overridden to use that file, so every test in the worker starts signed in.

  • If the file already exists, the fixture reuses it instead of signing in again.

The guide's version calls an acquireAccount(id) function that you write for your app. It also asks that accounts be unique, so several people can run the suite at the same time. Keep the saved files out of git: the guide warns they "may contain sensitive cookies and headers that could be used to impersonate you or your test account". To record the sign-in steps themselves, see Playwright codegen.

Overriding built-ins, merging, hooks and page objects

Override a built-in by defining a fixture with the same name. The example does this for baseURL. The guide also overrides page so every test starts on a given URL, and storageState for sign-in.

Merge fixture files with mergeTests(testA, testB), added in v1.39. A suite can then pull database fixtures from one module and accessibility fixtures from another into a single test. Source: the fixtures guide, read 4 October 2026.

Hooks still have a place for setup that belongs to one file or one describe block. Once the same beforeEach and afterEach pair appears in several files, it is a fixture.

Page objects and fixtures work together. The page object holds the methods, such as addTodo(). The fixture creates it, runs any setup such as navigation, and hands it to the test. The fixtures guide builds its todoPage fixture this way. Locators inside a page object follow the same rules as anywhere else; our getByRole reference covers them.

Conclusion

Move setup into a fixture when the same beforeEach and afterEach pair repeats, or when cleanup must run even after a failure. Keep per-test state in test-scoped fixtures and slow, shareable resources in worker-scoped ones. Then run with one worker and a few console.log lines, as above, to see the order for yourself. For where fixtures fit in a wider UI test strategy, see the UI testing guide.

Frequently Asked Questions

What is a fixture in Playwright?

A fixture is a value a test receives as an argument, such as page or request, together with the code that sets it up and tears it down. Playwright prepares the fixtures a test asks for, their dependencies and any automatic fixtures, and passes them in the test's first argument.

What is the difference between test and worker scope?

A test-scoped fixture is created for each test and torn down after it. A worker-scoped fixture, declared with { scope: 'worker' }, is created once per worker process and torn down when that worker ends. The built-in page is per test; browser is per worker.

How do setup and teardown work around use()?

Code before await use(value) runs before the test. The test runs while use is pending. Code after it runs once the test is done, including when the test failed, as the log above shows.

When should I use a fixture instead of beforeEach?

When the setup is needed in more than one file, when it has cleanup that must stay next to it, or when only some tests need it. A fixture that is not automatic, and that no requested fixture or hook depends on, is never set up for that test. A hook still suits setup that belongs to one file.

Can a fixture return a page object?

Yes. Create the page object inside the fixture, run its setup, and pass it to use(). The fixtures guide builds a todoPage fixture this way.

How do automatic fixtures and option fixtures differ?

An automatic fixture, { auto: true }, runs for every test even when no test lists it. An option fixture, { option: true }, is a value with a default that test.use() or a project's use can change.

How do I sign in once per worker?

Use a worker-scoped fixture that signs in with an account picked by parallelIndex, saves the storage state to a file, and override storageState to return that file. The authentication guide has the full pattern.

Can I combine fixtures from different files?

Yes, with mergeTests(), added in v1.39. It merges the fixtures of several extended test objects into one that your spec files import.

Ship continuously. Test continuously.

Qodex explores your app, writes runnable tests, and replays them on every change at zero LLM cost.