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, andrequestfixtures. - 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
autofixtures run without an explicit request. - The guide covers authentication with
storageState, per-project fixture overrides, and whenbeforeEachis simpler than a fixture. - The troubleshooting sections explain common mistakes, including hidden
autodependencies, 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()); }); } }); 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 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 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'; 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); }); 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' }) }) 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(); }); Run the example with these commands.
npm install -D @playwright/test npx playwright install chromium npx playwright test auto-console.spec.ts 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 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'); 