Skip to content
Vitaliy Potapov
Go back
Test using a fixture that runs setup before the test and teardown after it

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 code
await use('fixture value');
// fixture teardown code
},
});

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 }) => { // <-- references 'myTestFixture'
// ...
});

Run the test:

npx playwright test

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 }) => { // <-- references 'myTestFixture'
// ...
});
test('test 2', async ({ myTestFixture }) => { // <-- references 'myTestFixture'
// ...
});

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 }) => { // <-- references 'myTestFixture'
// ...
});
test('test 2', async () => { // <-- does not reference 'myTestFixture'
// ...
});

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 code
await use('fixture value');
// fixture teardown code
}, { auto: true }], // <-- make 'auto' fixture
});

Now the test does not reference myTestFixture:

example.spec.ts
import { test } from './fixtures';
test('test 1', async () => {
// ...
});

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 }) => {
// ...
});

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:

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 code
await use(page);
// custom page teardown code
},
});

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 }) => {
// ...
});

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 code
await use('A');
// fixture A teardown code
},
fixtureB: async ({}, use) => {
// fixture B setup code
await use('B');
// fixture B teardown code
},
fixtureC: async ({ fixtureA, fixtureB }, use) => {
// fixture C setup code
await use(`${fixtureA} - ${fixtureB} - C`);
// fixture C teardown code
},
});

The test references only fixtureC:

example.spec.ts
import { test } from './fixtures';
test('test 1', async ({ fixtureC }) => {
// ...
});

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 several tests can share it, use a worker-scoped fixture instead of the default test-scoped fixture. 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 code
await use('worker fixture value');
// worker fixture teardown code
}, { scope: 'worker' }],
});

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');
});

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 teardown. In a successful run, Playwright does not currently expose worker-fixture cleanup timing to reporters. I filed microsoft/playwright#38350 for this, but it appears unlikely to be implemented.

This limitation affects only the report. Worker fixture setup and teardown 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 }) => {
// ...
});
spec2.test.ts
test('test 2', async ({ myWorkerFixture }) => {
// ...
});

Run the files with two workers:

npx playwright test --workers 2

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 code
await use('fixture value');
// test fixture teardown code
},
myWorkerFixture: [async ({}, use) => {
// worker fixture setup code
await use('worker fixture value');
// worker fixture teardown code
}, { scope: 'worker' }],
});

Use both fixtures in tests:

example.spec.ts
import { test } from './fixtures';
test('test 1', async ({ myTestFixture, myWorkerFixture }) => {
// ...
});
test('test 2', async ({ myTestFixture, myWorkerFixture }) => {
// ...
});

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:

Happy testing ❤️

Further Reading

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


Share this post:

Next Post
Playwright in Pictures: Why Workers Restart?