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.
Table of Contents
- 1. Why fixtures solve the shared state problem
- 2. Built-in fixtures: page, context, browser, and more
- 3. Defining custom fixtures with test.extend()
- 4. The authenticated-page pattern for login state
- 5. Worker-scoped vs. test-scoped fixtures
- 6. Fixture composition and dependency chains
- 7. Teardown patterns: cleaning up with yield
- 8. Common fixture mistakes and anti-patterns
- 9. Fixture patterns compared side by side
- 10. Summary
- 11. FAQ
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.