Playwright Fixtures for Clean Test Isolation
AI generated
PASS
expect()
Testing · Playwright · Fixtures · E2E
Playwright Fixtures for Clean Test Isolation
Setup and teardown without shared state

Playwright fixtures replace fragile beforeEach blocks and shared global test state with clearly defined, reusable building blocks for setup and teardown. Once you understand built in fixtures like page, context and browser and start writing your own with test.extend, you get tests that run in parallel, never interfere with each other, and avoid duplicated setup code across files.

16 min read test.extend · Worker Scope · Teardown Playwright · TypeScript · CI/CD

1. Why fixtures solve the shared state problem

The classic test architecture built around beforeEach and module-level variables looks harmless at first, but it introduces shared, mutable state as a test suite grows. A login object created once in an outer describe block and reused in every test silently carries state from one test into the next. Run that test in parallel or in a different order, and you get exactly the hard-to-reproduce, flaky failures that make E2E suites unusable in practice.

Playwright fixtures solve this structurally, not through discipline. Every fixture is freshly instantiated for each test that requests it, and torn down automatically afterward, without a test file having to remember to do so explicitly. The key difference from beforeEach: fixtures are requested through dependency injection via the test function's parameters, not registered globally. A test that does not need a fixture never instantiates it, which speeds up test runs and visibly reduces coupling between tests.

2. Built-in fixtures: page, context, browser, and more

Playwright ships with a set of built-in fixtures that form the foundation of every test file. The page fixture provides a fresh page inside an isolated browser context, context gives access to the underlying BrowserContext for cookies, storage, or multiple tabs, and browser returns the shared browser instance used for the entire worker run. There is also browserName, request for pure API calls without a browser, and configuration values like baseURL that are sourced from playwright.config.ts.

The isolation guarantee behind context matters most: every test gets a brand new browser context with an empty cookie jar and empty local storage by default, even when the same browser instance is reused. That is why two parallel tests can never log each other in or out, even though both share the same browser process. Project-level settings like storageState can be configured per project and are then automatically picked up by every context fixture.


// playwright.config.ts: base configuration for built-in fixtures
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  workers: process.env.CI ? 4 : undefined,
  use: {
    // Automatically picked up by the "page" fixture of every test
    baseURL: 'https://staging.mironsoft-shop.test',
    trace: 'retain-on-failure',
  },
  projects: [
    {
      name: 'guest',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'logged-in',
      use: {
        ...devices['Desktop Chrome'],
        // storageState pre-fills context/page as already logged in
        storageState: 'playwright/.auth/customer.json',
      },
    },
  ],
});

3. Defining custom fixtures with test.extend()

Custom fixtures are created through test.extend(), which returns a new, extended test object. Each fixture is defined as a function that receives two parameters: the fixtures already available, and a use function that the actual fixture value is passed into. This pattern looks unusual at first, but it allows setup before use() and teardown after use() to live in a single, linear function, with no separate hook methods.

For Magento and Hyvä storefronts, a small library of domain-specific fixtures pays off: a storefrontPage fixture that has already navigated to the homepage, or a productPage fixture that opens a known test product. The advantage over constructing page objects inside every test: the fixture is requested declaratively as a parameter, TypeScript checks the types automatically, and Playwright takes care of instantiation and cleanup.


// fixtures.ts: defining a custom fixture with test.extend()
import { test as base, expect } from '@playwright/test';
import { ProductPage } from './pages/ProductPage';

type MyFixtures = {
  productPage: ProductPage;
};

export const test = base.extend<MyFixtures>({
  productPage: async ({ page }, use) => {
    // Setup: instantiate the page object and navigate to a known product
    const productPage = new ProductPage(page);
    await productPage.goto('/test-product-blue.html');

    // Hand the fixture value to the test
    await use(productPage);

    // Teardown runs automatically after every test, nothing needed here
  },
});

export { expect };

4. The authenticated-page pattern for login state

Filling out a login form in every single test is one of the most common reasons E2E suites become slow and unstable. The authenticated-page pattern solves this by running the login flow exactly once and storing the resulting state as a storageState file. An authenticatedPage fixture then loads that saved state into every test that requests it, so the UI login steps never need to run again.

What matters for clean isolation is that every test still gets its own browser context, even though the login state is shared. If a test changes something in the cart or in address data, that stays invisible to parallel tests because storageState is copied on load, not referenced. For tests that need a genuinely fresh account, such as registration flows, the fixture can be parameterized to create a new test customer through the Magento REST API instead of reusing the logged-in fixture.

5. Worker-scoped vs. test-scoped fixtures

Playwright distinguishes between test-scoped fixtures, which are created fresh for every single test, and worker-scoped fixtures, which are instantiated exactly once per test worker and reused across every test that worker runs. Scope is controlled with the { scope: 'worker' } option in the fixture definition, and the default is always test. Worker-scoped fixtures suit expensive but effectively stateless resources: a database connection, an API client with a base token, or a test dataset that only needs to be seeded once.

The rule of thumb: as soon as a test mutates something in a fixture that another test could observe, that fixture belongs on test scope. Only read-only or genuinely immutable resources should be worker-scoped. A common mistake is seeding a product variant as a worker-scoped fixture and then deleting it inside one test, which causes every subsequent test on that same worker to fail because the resource is suddenly gone.


// fixtures.ts: worker-scoped fixture for expensive, reusable setup
import { test as base } from '@playwright/test';
import { seedTestCatalog, TestCatalog } from './helpers/catalog';

type WorkerFixtures = {
  testCatalog: TestCatalog;
};

export const test = base.extend<{}, WorkerFixtures>({
  testCatalog: [async ({}, use) => {
    // Runs only once per worker, not once per test
    const catalog = await seedTestCatalog({ productCount: 20 });

    await use(catalog);

    // Teardown runs once, when the worker shuts down
    await catalog.cleanup();
  }, { scope: 'worker' }],
});

6. Fixture composition and dependency chains

Fixtures can build on other fixtures simply by requesting them in the first function parameter. A checkoutPage fixture can be built on top of an already prepared authenticatedPage fixture, which in turn uses the built-in page fixture. Playwright resolves this dependency chain automatically and instantiates only the fixtures a given test actually needs, in the correct order.

This composition fully replaces deeply nested helper functions and setup wrappers. Instead of a function like setupCheckoutWithLogin(page) that internally calls several other setup functions, every test simply receives the ready-made checkoutPage fixture as a parameter. The added benefit: every fixture in the chain stays individually testable and reusable, because it has no idea who ultimately consumes it. Over time, new fixtures compose into a growing, well-typed library.


// fixtures.ts: a fixture chain across multiple dependency levels
import { test as base } from '@playwright/test';
import { CheckoutPage } from './pages/CheckoutPage';

type ChainedFixtures = {
  authenticatedPage: import('@playwright/test').Page;
  checkoutPage: CheckoutPage;
};

export const test = base.extend<ChainedFixtures>({
  authenticatedPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: 'playwright/.auth/customer.json',
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  // Depends directly on the authenticatedPage fixture
  checkoutPage: async ({ authenticatedPage }, use) => {
    const checkout = new CheckoutPage(authenticatedPage);
    await checkout.addTestProductToCart();
    await checkout.goto();

    await use(checkout);
  },
});

7. Teardown patterns: cleaning up with yield

The pattern behind Playwright fixtures follows the same principle as a Python context manager: everything before await use(value) is setup, everything after is teardown. What matters is that the code after use() still runs even when the test fails or an assertion throws, as long as the fixture itself initialized successfully. This fully replaces afterEach, because teardown logic lives right next to the setup it belongs to, instead of in a separately maintained function.

For resources that must be released cleanly even if setup itself fails, the critical part belongs inside a try/finally block within the fixture. Typical teardown tasks in a Magento context: deleting a test customer created through the REST API, stopping a recording started with page.context().tracing, or closing a database connection opened by a fixture. Because this code is guaranteed to run, a test suite no longer accumulates orphaned test data over time.


// fixtures.ts: guaranteed teardown via the yield-style use() pattern
import { test as base } from '@playwright/test';
import { createTestCustomer, deleteTestCustomer } from './helpers/api';

type CustomerFixture = {
  testCustomer: { id: number; email: string };
};

export const test = base.extend<CustomerFixture>({
  testCustomer: async ({ request }, use) => {
    // Setup before use(): create a test customer via the REST API
    const customer = await createTestCustomer(request);

    try {
      // Hand off to the test, execution pauses here until the test ends
      await use(customer);
    } finally {
      // Runs no matter what, even if the test fails
      await deleteTestCustomer(request, customer.id);
    }
  },
});

8. Common fixture mistakes and anti-patterns

The most common mistake is leaving expensive setup logic as a test-scoped fixture even though it is actually immutable and could safely be worker-scoped. In large suites, that costs noticeable runtime because the same work repeats hundreds of times. The opposite mistake is at least as dangerous: making mutable state worker-scoped and thereby reintroducing exactly the coupling between tests that fixtures were meant to eliminate.

Another common problem is fixtures with side effects outside their own area of responsibility, such as a page fixture that also mutates global test reporter state. auto: true fixtures, which run for every test without being explicitly requested, should also be used sparingly, because they create invisible behavior that is no longer visible in the test file itself. Opening a fixture and forgetting to guard it with try/finally also risks orphaned resources whenever a test fails.

9. Fixture patterns compared side by side

The table below compares classic test patterns with the corresponding Playwright fixture patterns and shows why the fixture variant is the more robust choice in nearly every case.

Area Anti-pattern Recommended fixture pattern Benefit
Per-test state beforeEach with shared, mutable state test-scoped fixture Test-to-test coupling becomes impossible
Expensive setup Global setup script before the whole suite worker-scoped fixture Runs once per worker instead of once globally
Login state Manual UI login in every single test authenticated-page fixture Login once, storageState reused
Cleanup afterEach cleanup separated from setup Fixture teardown via yield/use() Guaranteed even when a test fails
Reuse Duplicated setup code in every test file Fixture composition Maintained centrally, testable individually

In practice, nearly every stability problem in large E2E suites traces back to one of the five rows in this table. Consistently choosing fixtures over hooks does not just improve readability, it also opens up running tests in parallel without fear of side effects.

Mironsoft

E2E test automation with Playwright for Magento and Hyvä stores

Ready to build a stable Playwright suite?

We build a maintainable Playwright fixture library for your Magento or Hyvä store, from the authenticated-page fixture to a CI pipeline that runs in parallel without flakiness.

Fixture architecture

A test.extend library with clear scope boundaries and teardown

Test isolation audit

Reviewing existing suites for shared state and flakiness

CI/CD integration

Parallel workers, sharding, and reporting in your pipeline

10. Summary

Playwright fixtures solve the core problem of classic E2E test suites: shared, mutable state between tests. Built-in fixtures like page, context, and browser provide isolated foundations for every test, while custom fixtures created through test.extend() make domain-specific setup like the authenticated-page pattern available declaratively. Choosing between test-scoped and worker-scoped directly determines runtime and safety: mutable state belongs on test scope, expensive but immutable resources may be worker-scoped.

Fixture composition replaces nested setup functions with clearly typed, individually testable building blocks, and the yield-style use() pattern guarantees teardown even when a test fails. Applying these principles consistently produces a test suite that runs in parallel without one test ever affecting another's results, and that becomes more maintainable, not more complex, with every fixture added.

Playwright Fixtures: the essentials at a glance

Built-in fixtures

page, context, browser, and request provide isolated base resources per test.

test.extend()

Define custom fixtures declaratively, request them via dependency injection in the test.

Scope choice

Mutable state stays test-scoped, expensive immutable resources may be worker-scoped.

Teardown

Code after use() wrapped in try/finally runs no matter what, even on test failure.

11. FAQ: Playwright Fixtures

1What exactly is a fixture in Playwright?
A reusable building block for setup and teardown, requested via dependency injection through the test parameters. Playwright instantiates it only when needed and tears it down automatically.
2How do test-scoped and worker-scoped fixtures differ?
Test-scoped is recreated per test, worker-scoped once per worker and then reused. Mutable state stays test-scoped, expensive immutable resources may be worker-scoped.
3How do I create a custom fixture with test.extend()?
Pass an object of fixture functions to base.extend(). Each function gets existing fixtures and a use() function; code before use() is setup, code after is teardown.
4How does the authenticated-page fixture handle login state?
Login runs once, the result is saved as storageState. An authenticatedPage fixture loads that state per test, so UI login steps are skipped entirely.
5How does teardown work with the yield-style pattern in a fixture?
Everything after await use(value) is teardown and runs no matter what, even on test failure. Wrap critical cleanup in try/finally as well.
6Can a fixture depend on another fixture?
Yes, via the first function parameter, for example checkoutPage building on authenticatedPage. Playwright resolves the dependency chain automatically in the right order.
7What is an automatic fixture (auto: true)?
Runs for every test automatically without explicit request, for example for global tracing. Use sparingly, since invisible behavior is not visible in the test file itself.
8Why should I replace beforeEach/afterEach with fixtures?
beforeEach/afterEach run for every test regardless of need and encourage shared state. Fixtures instantiate only when needed and keep setup/teardown together.
9How do I share storageState between tests without sharing state?
storageState is copied, not referenced, when a new context is created. Every test starts logged in the same way, changes never affect parallel tests.
10What are common mistakes when working with fixtures?
Wrongly test-scoped expensive setup, wrongly worker-scoped mutable state, and missing try/finally in teardown leaving orphaned test data behind.