DEV Community

Wei-Wei Wu
Wei-Wei Wu

Posted on Originally published at momentic.ai

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

Originally published on the Momentic blog.

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());
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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' })
})
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Run the example with these commands.

npm install -D @playwright/test
npx playwright install chromium
npx playwright test auto-console.spec.ts
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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' },
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

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',
  });
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode
// 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();
});
Enter fullscreen mode Exit fullscreen mode

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 .

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');
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 },
      },
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

Create example.spec.ts.

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

test('opens the configured page', async ({ readyPage }) => {
  await expect(readyPage.locator('h1')).toHaveText('Example Domain');
});
Enter fullscreen mode Exit fullscreen mode

Run it with these commands.

npm init -y
npm install -D @playwright/test
npx playwright install chromium
npx playwright test
Enter fullscreen mode Exit fullscreen mode

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 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 .

Where fixtures fit in a larger test strategy

Playwright 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 for selector choices and 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 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.

Top comments (0)