Your suite passes on your machine and fails in CI. You run one test with --grep and it fails, though the whole file is green. The cause is almost always the same: setup lives in beforeEach hooks and state lives in variables declared next to them, so every test silently depends on the test that ran before it.
Playwright’s answer to this is fixtures. Not a nicer hook syntax — a different model. A beforeEach is an instruction to the runner; a fixture is a declaration by the test about what it needs. That difference is what makes tests independently runnable, and independently runnable tests are the only kind you can safely parallelize.
Why the beforeEach Chain Eventually Collapses
Here is a file that works today and will break the first time someone touches the config:
// tests/orders.spec.ts
import { test, expect } from '@playwright/test';
let orderId: string; // module-level state
let customerEmail: string;
test.describe('orders', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('qa*****@*****le.com');
await page.getByLabel('Password').fill('Passw0rd!');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('user-menu')).toBeVisible();
});
test('creates an order', async ({ page }) => {
customerEmail = 'bu***@*****le.com';
await page.goto('/orders/new');
await page.getByLabel('Customer email').fill(customerEmail);
await page.getByRole('button', { name: 'Create' }).click();
orderId = (await page.getByTestId('order-id').textContent()) ?? '';
expect(orderId).not.toBe('');
});
test('cancels the order', async ({ page }) => {
await page.goto(`/orders/${orderId}`); // depends on the test above
await page.getByRole('button', { name: 'Cancel order' }).click();
await expect(page.getByTestId('order-status')).toHaveText('Cancelled');
});
});
Three separate problems are stacked here.
The module-level variables are a hidden contract. orderId is written by one test and read by another. Nothing in the second test’s signature says so. Run it alone and orderId is undefined, so you navigate to /orders/undefined and get a 404 that reports as a locator timeout.
beforeEach is not setup, it is a sequential side effect. It logs in through the UI for every test in the file. That cost is paid per test, it exercises the login form hundreds of times for no additional coverage, and when the login page changes every test in the suite turns red at once.
Ordering is an assumption, not a guarantee. Tests inside one file run sequentially by default, so the file is green. Add fullyParallel: true, or test.describe.configure({ mode: 'parallel' }), or shard across workers, and the two tests land in different browser contexts at the same time. The second one never sees the order the first created.
The typical shape of this failure is worth naming: the suite is stable at --workers=1 and flaky above it, and the flakiness is proportional to the number of workers. That is not a browser problem or a timing problem. It is shared state.
What a Fixture Is: The Test Declares What It Needs
A fixture is dependency injection for tests. beforeEach says “before every test, do this.” A fixture says “this test needs this resource.” The runner reads the destructured parameters in the test signature and builds exactly those, in the right order.
You already use fixtures. page, context, browser, request are built-in ones:
import { test, expect } from '@playwright/test';
test('uses a page', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveTitle(/Shop/);
});
test('uses the API only — never starts a browser page', async ({ request }) => {
const res = await request.get('/api/health');
expect(res.ok()).toBeTruthy();
});
Two properties follow from this and they are the whole point:
Setup is lazy. The second test does not ask for page, so no page is created. A beforeEach cannot do this — it runs for every test in scope whether the test needs it or not.
Dependencies resolve themselves. A fixture can request other fixtures. page depends on context, which depends on browser. Your own fixtures sit on the same mechanism, so adminPage can depend on context, which depends on a storageState your setup produced, and Playwright orders the graph for you. You never write the order by hand, and you never get it wrong.
Lifecycle and Scope: Test or Worker
A fixture body is one function with a hinge in the middle. Everything before await use(value) is setup. use() hands the value to the test and blocks until the test finishes. Everything after is teardown.
someFixture: async ({}, use) => {
const resource = await createResource(); // setup
await use(resource); // test runs here
await resource.dispose(); // teardown
},
Fixtures have two scopes. Test-scoped (the default) is built and torn down for every test — that is what guarantees isolation. Worker-scoped is built once per worker process and reused by every test that worker runs, which is how you avoid paying for something expensive repeatedly.

The decision rule is one sentence: nothing a test mutates may be worker-scoped. A database connection pool, a compiled binary, a read-only auth token — fine. A user account whose profile a test edits, a shopping cart, a tenant whose settings a test flips — never. The failure mode of getting this wrong is nasty because it is order-dependent: test A passes, then test B changes the shared record, then test A fails on a rerun with a different sharding layout. It looks like flakiness and it is actually a scope bug.
When a worker-scoped resource must be unique per worker, key it on the worker index:
// fixtures/tenant.ts
import { test as base } from '@playwright/test';
type WorkerFixtures = { tenantSlug: string };
export const test = base.extend<{}, WorkerFixtures>({
tenantSlug: [
async ({}, use, workerInfo) => {
const slug = `tenant-w${workerInfo.parallelIndex}`;
await use(slug);
},
{ scope: 'worker' },
],
});
parallelIndex is stable and bounded by the worker count, so tenant-w0 … tenant-w3 are reserved lanes. Use it for seeded fixtures that must not collide, and prefer it over workerInfo.workerIndex, which increases when a worker is restarted after a crash.
Writing Your Own Fixture: test.extend and Type Safety
Declare the fixture types explicitly. Without the type block you lose autocomplete and you get no error when a test asks for adminpage instead of adminPage.
// fixtures/checkout.ts
import { test as base, expect, type Page, type APIRequestContext } from '@playwright/test';
class CheckoutPage {
constructor(private readonly page: Page) {}
async open() {
await this.page.goto('/checkout');
await expect(this.page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
}
async pay(cardNumber: string) {
await this.page.getByLabel('Card number').fill(cardNumber);
await this.page.getByRole('button', { name: 'Pay' }).click();
}
}
type CheckoutFixtures = {
checkout: CheckoutPage;
api: APIRequestContext;
};
export const test = base.extend<CheckoutFixtures>({
api: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: process.env.API_URL ?? 'http://localhost:3000',
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
await use(context);
await context.dispose(); // teardown
},
checkout: async ({ page }, use) => { // depends on the built-in page fixture
const checkout = new CheckoutPage(page);
await checkout.open();
await use(checkout);
},
});
export { expect };
And the test:
// tests/checkout.spec.ts
import { test, expect } from '../fixtures/checkout';
test('pays with a valid card', async ({ checkout, page }) => {
await checkout.pay('4242424242424242');
await expect(page.getByTestId('receipt')).toBeVisible();
});
No hooks, no module-level variables, and the test signature is the full list of what this test touches. The return value of a fixture does not have to be a page object — api above returns an HTTP client, and it could just as easily return a domain object (order, tenant, licensedUser) that the test manipulates.
Each Test Creates and Reclaims Its Own Data Inside the Fixture
Set data up over the API, not the UI. UI setup is slow, and it makes an unrelated form a dependency of every test that needs a record.
// fixtures/order.ts
import { test as base, expect, type APIRequestContext } from '@playwright/test';
import { randomUUID } from 'node:crypto';
export const RUN_ID = process.env.RUN_ID ?? randomUUID().slice(0, 8);
type Order = { id: string; reference: string };
type OrderFixtures = { api: APIRequestContext; order: Order };
export const test = base.extend<OrderFixtures>({
api: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: process.env.API_URL ?? 'http://localhost:3000',
extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
});
await use(context);
await context.dispose();
},
order: async ({ api }, use, testInfo) => {
const reference = `e2e-${RUN_ID}-${testInfo.parallelIndex}-${randomUUID().slice(0, 6)}`;
const created = await api.post('/api/orders', {
data: { reference, currency: 'EUR', items: [{ sku: 'SKU-1', qty: 1 }] },
});
expect(created.ok()).toBeTruthy();
const order = (await created.json()) as Order;
await use(order);
const deleted = await api.delete(`/api/orders/${order.id}`);
expect([200, 204, 404]).toContain(deleted.status());
},
});
export { expect };
Two things to be precise about. Teardown after use() runs even when the test fails an assertion or times out — that is why cleanup belongs here and not at the end of the test body. But it does not run when the process itself dies: a killed CI job, an OOM, a SIGKILL on a cancelled pipeline. Cleanup is best-effort, so the data must be identifiable afterwards. That is the job of RUN_ID in the reference: leftovers are greppable and a scheduled job can delete anything older than a day matching e2e-*.
Data ownership is a topic of its own, and the fixture is only the mechanism — the rules for what each test may and may not share are in test data management: why every test must own its data.
Session Isolation: Managing storageState With Fixtures
Logging in through the UI in every test is the most common avoidable cost in an E2E suite. Sharing one logged-in session across all tests is the most common avoidable risk. The middle path is to authenticate once per role, store the cookies and local storage, and let every test load them read-only.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const roles = [
{ name: 'admin', email: 'ad***@*****le.com', file: '.auth/admin.json' },
{ name: 'customer', email: 'cu******@*****le.com', file: '.auth/customer.json' },
];
for (const role of roles) {
setup(`authenticate as ${role.name}`, async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill(role.email);
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('user-menu')).toBeVisible();
await page.context().storageState({ path: role.file });
});
}
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
fullyParallel: true,
use: { baseURL: 'http://localhost:4200' },
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{ name: 'chromium', dependencies: ['setup'], use: { browserName: 'chromium' } },
],
});
// fixtures/roles.ts
import { test as base, type Page } from '@playwright/test';
type RoleFixtures = { adminPage: Page; customerPage: Page };
export const test = base.extend<RoleFixtures>({
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: '.auth/admin.json' });
const page = await context.newPage();
await use(page);
await context.close();
},
customerPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: '.auth/customer.json' });
const page = await context.newPage();
await use(page);
await context.close();
},
});
export { expect } from '@playwright/test';
Each test gets a fresh browser context seeded from the file. The file is never written by tests, so there is no shared mutable session. A test that needs both perspectives asks for both pages in its signature.
The limit of this pattern: the session is isolated but the account is not. If two parallel tests both mutate the admin user’s saved filters or notification settings, they collide at the data layer no matter how many contexts you create. At that point you need a pool — N pre-provisioned accounts, handed out per worker via parallelIndex, with the same “nothing mutated is worker-scoped” rule applied to whatever the account owns.
Automatic Fixtures: Enforcing Behavior in Every Test
An auto: true fixture runs for every test in scope whether the test asks for it or not. That makes it the right tool for rules, not resources.
// fixtures/guards.ts
import { test as base, expect } from '@playwright/test';
type Guards = { failOnConsoleError: void; pinnedFlags: void };
export const test = base.extend<Guards>({
failOnConsoleError: [
async ({ page }, use) => {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
await use();
expect(errors, `console errors:\n${errors.join('\n')}`).toHaveLength(0);
},
{ auto: true },
],
pinnedFlags: [
async ({ context }, use) => {
await context.addInitScript(() => {
window.localStorage.setItem('ff.newCheckout', 'true');
});
await use();
},
{ auto: true },
],
});
export { expect };
Now no test can forget to check the console, and no test runs against an unknown feature flag configuration. Other good candidates: recording failed network requests, attaching a trace artifact on failure, asserting no unexpected 5xx responses.
The cost is that an automatic fixture is a tax every test pays, including tests that do not need it. Three of them that each add a second of setup add three seconds to every test in the suite, and because nothing in the signature mentions them, the cost is invisible when you go looking for slow tests. Keep automatic fixtures cheap, keep them few, and scope them to a project or a directory-level test export rather than globally when only part of the suite needs the rule.
Splitting and Composing Fixtures Across Files
A single fixtures.ts starts clean and becomes a dumping ground within a quarter: every helper anyone needed, one type block with thirty entries, and a file that conflicts on every branch. Split by domain instead, then compose.
A layout that holds up:
fixtures/
api.ts // APIRequestContext, tokens
roles.ts // adminPage, customerPage
order.ts // order lifecycle data
guards.ts // auto fixtures
index.ts // mergeTests of the above
tests/
checkout.spec.ts
// fixtures/index.ts
import { mergeTests, mergeExpects, expect as baseExpect } from '@playwright/test';
import { test as apiTest } from './api';
import { test as rolesTest } from './roles';
import { test as orderTest } from './order';
import { test as guardsTest } from './guards';
export const test = mergeTests(apiTest, rolesTest, orderTest, guardsTest);
export const expect = mergeExpects(baseExpect);
mergeTests combines fixture sets and their types, so a test can request adminPage and order together and still get full type inference. Different projects can import different combinations — a smoke project that merges only api and guards, a full regression project that merges everything.
The discipline that makes this work is import hygiene: no spec file imports test from @playwright/test. One direct import is enough to silently bypass every automatic fixture in the suite. Enforce it rather than documenting it:
// eslint.config.js
export default [
{
files: ['tests/**/*.spec.ts'],
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: '@playwright/test',
importNames: ['test'],
message: "Import { test } from 'fixtures' instead.",
}],
}],
},
},
];
Keep expect importable from anywhere; it is the test object that carries the fixtures.
Where beforeEach Is Still the Right Answer
Moving everything into fixtures has its own price: indirection. A test whose signature lists six fixtures spread across four files is harder to read than one with a two-line hook.
beforeEach is still correct when the step is local to one file, needs no cleanup, and creates no resource. Navigating to the page this file is about. Dismissing a cookie banner. Setting a viewport for a file of responsive tests. Those are fine as hooks and turning them into fixtures adds a file for no gain.
Four questions decide it:
- Does it create a resource? If something must be deleted, closed, or disposed afterwards, it is a fixture — teardown after
use()is the only place that reliably runs. - Will more than one file use it? Second file: fixture.
- Should the test’s dependency be visible in its signature? If a reader needs to know this test operates on an existing order or an admin session, put it in the signature.
- Does every test in scope actually need it? If only three of twelve do, a hook wastes work a fixture would skip.
What to Do Next
Start by finding the coupling you already have, in this order.
Prove which tests depend on their neighbours. Run the suite with --repeat-each=3 and with --workers=1 versus your normal worker count, then run suspicious files with --grep on a single test title. Playwright has no shuffle flag, so approximate: flip fullyParallel: true, or add test.describe.configure({ mode: 'parallel' }) to one file at a time. Any test that passes in a file and fails alone is reading state it did not create.
Convert the most repeated beforeEach first. That is almost always UI login. Replace it with a setup project plus storageState, and the per-test cost of authentication disappears along with the suite’s single largest shared failure point.
Audit every worker-scoped fixture. For each one ask what a test can mutate through it. If the answer is anything other than “nothing”, either move it to test scope or key it on parallelIndex.
Route all test imports through one module. Merge your fixture files into a single test export and add the lint rule, so automatic fixtures cannot be bypassed by accident.
If tests remain unstable after this, the cause is no longer setup — it is timing, environment, or the application itself. That is a different diagnosis, and the method is in diagnosing flaky tests: finding the root cause of instability in Playwright.