DEV Community

Cover image for Playwright in Pictures: How Fixtures Work
Vitaliy Potapov
Vitaliy Potapov

Posted on • Originally published at vitalets.github.io

Playwright in Pictures: How Fixtures Work

Playwright in Pictures is a series of articles where I use playwright-timeline-reporter to visualize different Playwright concepts with simple timeline charts.

Fixtures are a central part of the Playwright runner. They let you move setup and teardown code out of the test body, so the test itself stays focused on the behavior you want to check. You can think of fixtures as before/after hooks on steroids.

But fixtures also make test execution less explicit. Fixture code can run before a test, after a test, once per worker, or even without being named in the test. In this post, I'll go through the main fixture patterns and show what each one looks like on a timeline.

Simple Fixture

Fixtures are declared with test.extend(). Here is an example of the fixture myTestFixture:

fixtures.ts

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

export const test = base.extend<{ myTestFixture: string }>({
  myTestFixture: async ({}, use) => {
    // fixture setup...
    await use('fixture value');
    // fixture teardown...
  },
});
Enter fullscreen mode Exit fullscreen mode

Fixtures are test-scoped by default. A test-scoped fixture runs for every test that uses it. Let's use myTestFixture in a test:

example.spec.ts

import { test } from './fixtures';

test('test 1', async ({ myTestFixture }) => { // uses 'myTestFixture'
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Run the test:

npx playwright test
Enter fullscreen mode Exit fullscreen mode

Playwright now sets up myTestFixture before the test and tears it down after it (blue bars):

myTestFixture setup and teardown around one test

Fixture is created before the test and destroyed after it (live report ↗)

Now add a second test that uses the same myTestFixture fixture:

example.spec.ts

test('test 1', async ({ myTestFixture }) => { // uses 'myTestFixture'
  // ...
});

test('test 2', async ({ myTestFixture }) => { // uses 'myTestFixture'
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Because myTestFixture is test-scoped, Playwright creates a new instance for each test. The first instance is destroyed before the second one is created:

myTestFixture gets a separate instance for each test

Each test gets its own fixture setup and teardown (live report ↗)

Fixtures Are Lazy

A fixture does not run just because it is declared. Playwright sets it up only when a test or another fixture asks for it.

Now remove myTestFixture from test 2 arguments:

example.spec.ts

test('test 1', async ({ myTestFixture }) => { // uses 'myTestFixture'
  // ...
});

test('test 2', async () => { // does not use 'myTestFixture'
  // ...
});
Enter fullscreen mode Exit fullscreen mode

test 2 no longer requests myTestFixture, so Playwright does not set it up for that test. On the timeline, the blue fixture bar remains only around test 1:

myTestFixture runs only for test 1

A fixture runs only for the test that uses it (live report ↗)

Auto Fixtures

Sometimes a fixture should run for every test. You do not need to add it to every test signature. Mark it with { auto: true }:

fixtures.ts

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

export const test = base.extend<{ myTestFixture: string }>({
  myTestFixture: [async ({}, use) => {
    // fixture setup...
    await use('fixture value');
    // fixture teardown...
  }, { auto: true }], // <-- make 'auto' fixture
});
Enter fullscreen mode Exit fullscreen mode

Now the test does not reference myTestFixture:

example.spec.ts

import { test } from './fixtures';

test('test 1', async () => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Playwright still runs the fixture because it is automatic:

Auto fixture runs without being referenced in the test

An auto fixture runs without being referenced in the test (live report ↗)

Built-in Fixtures

Playwright also provides several fixtures out of the box. The most common one is page, which gives you access to a browser page:

example.spec.ts

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

test('test 1', async ({ page }) => {
  // ...
});

test('test 2', async ({ page }) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

After running these tests, the timeline shows page setup before each test (blue bars):

The built-in page fixture pulls in its dependencies

The built-in page fixture setup (live report ↗)

Two things stand out in this timeline:

  • Besides the blue bars for the test-scoped page fixture, there is a yellow bar for the worker-scoped browser fixture. This is because the page fixture depends on the browser fixture, so Playwright creates that dependency first. I'll cover fixture dependencies and worker-scoped fixtures later.
  • The second page setup is much shorter than the first. Playwright uses some internal optimization to set up subsequent page fixtures.

Fixture Override

You can override any fixture with another test.extend() call. For example, this code overrides the built-in page fixture and adds custom setup and teardown around it:

fixtures.ts

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

export const test = base.extend({
  page: async ({ page }, use) => {
    // custom page setup...
    await use(page);
    // custom page teardown...
  },
});
Enter fullscreen mode Exit fullscreen mode

Notice the { page } dependency inside the override. This is the original Playwright page fixture. Playwright creates that original page first, then runs your wrapper around it.

The test still looks the same. It asks for page, and Playwright gives it the overridden version:

example.spec.ts

import { test } from './fixtures';

test('test 1', async ({ page }) => {
  // ...
});

test('test 2', async ({ page }) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The timeline shows the wrapper around the original fixture:

Overridden page fixture wraps the built-in page fixture

The custom page fixture wraps the original built-in page fixture (live report ↗)

Fixture Dependencies

Fixtures can also depend on other fixtures. In this example, fixtureC depends on fixtureA and fixtureB:

fixtures.ts

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

export const test = base.extend<{
  fixtureA: string;
  fixtureB: string;
  fixtureC: string;
}>({
  fixtureA: async ({}, use) => {
    // fixture A setup...
    await use('A');
    // fixture A teardown...
  },
  fixtureB: async ({}, use) => {
    // fixture B setup...
    await use('B');
    // fixture B teardown...
  },
  fixtureC: async ({ fixtureA, fixtureB }, use) => {
    // fixture C setup...
    await use(`${fixtureA} - ${fixtureB} - C`);
    // fixture C teardown...
  },
});
Enter fullscreen mode Exit fullscreen mode

The test references only fixtureC:

example.spec.ts

import { test } from './fixtures';

test('test 1', async ({ fixtureC }) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Playwright sees that fixtureC needs fixtureA and fixtureB, so it sets up both parent fixtures first. Teardown goes in the opposite order:

Fixture C pulls in Fixture A and Fixture B

Requesting fixtureC also runs its parent fixtures, fixtureA and fixtureB (live report ↗)

Worker Fixtures

When setup is expensive and can be shared between several tests, use a worker-scoped fixture instead of the default test-scoped. Define it in the same base.extend() call, but wrap the fixture function in an array with the { scope: 'worker' } option:

fixtures.ts

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

export const test = base.extend<{}, { myWorkerFixture: string }>({
  myWorkerFixture: [async ({}, use) => {
    // worker fixture setup...
    await use('worker fixture value');
    // worker fixture teardown...
  }, { scope: 'worker' }],
});
Enter fullscreen mode Exit fullscreen mode

Now use the worker-scoped fixture in two tests:

example.spec.ts

import { test } from './fixtures';

test('test 1', async ({ myWorkerFixture }) => {
  // ...
});

test('test 2', async ({ myWorkerFixture }) => {
  throw new Error('Intentional error');
});
Enter fullscreen mode Exit fullscreen mode

Both tests run in the same worker, so Playwright creates one myWorkerFixture instance and reuses it. There is no worker fixture setup between the two test bodies:

myWorkerFixture is shared by two tests in one worker

A worker fixture is created once in the worker and reused by both tests (live report ↗)

A small reporter detail: this demo intentionally fails test 2 so the report can show the worker fixture cleanup (right yellow bar). In a successful run, Playwright does not currently expose worker-fixture cleanup timing to the reporters API (see playwright#38350).

This limitation affects only the report. Worker fixture setup and cleanup work the same way for passing and failing tests.

Worker-scoped means "once per worker", not "once per test run". To see the difference, split the two tests into separate files:

spec1.test.ts

test('test 1', async ({ myWorkerFixture }) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

spec2.test.ts

test('test 2', async ({ myWorkerFixture }) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

Run the files with two workers:

npx playwright test --workers 2
Enter fullscreen mode Exit fullscreen mode

With two workers, each file runs in a separate worker. Each worker gets its own myWorkerFixture, so the setup runs twice:

myWorkerFixture is created once in each worker

Each worker creates its own worker fixture instance (live report ↗)

Putting It Together

You can combine multiple test-scoped and worker-scoped fixtures in the same test. Playwright initializes every required fixture and its dependencies in the correct order.

Here is one test-scoped fixture and one worker-scoped fixture declared together:

fixtures.ts

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

export const test = base.extend<
  { myTestFixture: string },
  { myWorkerFixture: string }
>({
  myTestFixture: async ({}, use) => {
    // test fixture setup...
    await use('fixture value');
    // test fixture teardown...
  },
  myWorkerFixture: [async ({}, use) => {
    // worker fixture setup...
    await use('worker fixture value');
    // worker fixture teardown...
  }, { scope: 'worker' }],
});
Enter fullscreen mode Exit fullscreen mode

Use both fixtures in tests:

example.spec.ts

import { test } from './fixtures';

test('test 1', async ({ myTestFixture, myWorkerFixture }) => {
  // ...
});

test('test 2', async ({ myTestFixture, myWorkerFixture }) => {
  // ...
});
Enter fullscreen mode Exit fullscreen mode

The timeline shows both scopes together. myTestFixture is initialized and removed for each test, while myWorkerFixture is created once for the worker and reused by both tests:

Test-scoped and worker-scoped fixtures run together

One worker fixture is reused across two separate test fixture lifecycles (live report ↗)

Conclusion

Fixtures are powerful, especially when you keep these principles in mind:

  • Scope: per test or per worker?
  • Check fixture dependencies and overrides.
  • Track fixture durations: slow fixtures can significantly decrease performance.

Happy testing ❤️

Further Reading

⬅️ Previous in series: Playwright in Pictures: Why Workers Restart?

Top comments (0)