Playwright Fixtures vs Hooks: When to Use Fixtures, `beforeEach`, and `beforeAll`

Learn when to use Playwright fixtures, beforeEach, and beforeAll with practical TypeScript examples covering test isolation, parallel execution, authentication, and scalable test architecture.

Playwright Fixtures vs Hooks: When to Use Fixtures, `beforeEach`, and `beforeAll`

Playwright Fixtures vs Hooks: When to Use Fixtures, beforeEach, and beforeAll

When architecting a test automation framework with Playwright and TypeScript, one of the first structural decisions SDETs face is how to manage test lifecycle and preconditions. Coming from frameworks like Jest, Mocha, or Cypress, many automation engineers naturally reach for traditional xUnit lifecycle hooks such as beforeEach, beforeAll, afterEach, and afterAll.

However, Playwright Test is architected around a fundamentally different concept: dependency-injected test fixtures. While hooks remain useful for localized setups, Playwright fixtures provide a composable, on-demand, and parallel-safe mechanism for establishing test environments.

In this article, we will compare Playwright fixtures vs hooks, break down the setup and teardown lifecycles, evaluate worker vs. test scoping, and analyze a realistic end-to-end testing scenario implemented with both approaches.


1. What Are Playwright Fixtures?

In Playwright Test, a fixture is an isolated resource or environment setup prepared specifically for a test run. Rather than initializing objects inside test files or global setup scripts, Playwright fixtures deliver resources directly into test functions via argument destructuring.

Playwright inspects the parameter signature of each test function at runtime. It sets up only the fixtures requested by that specific test signature and ignores unused fixtures entirely.

import { test, expect } from '@playwright/test';

// Playwright inspects { page } and initializes only the required page and context fixtures
test('basic navigation test', async ({ page }) => {
  await page.goto('https://playwright.dev/');
  await expect(page).toHaveTitle(/Playwright/);
});

The setup → use() → teardown Lifecycle

Every fixture in Playwright follows a explicit, symmetrical execution flow built around the await use() callback:

  1. Setup Phase: Everything executing before await use() runs prior to test execution. This is where browser contexts are created, Page Object Models (POMs) are instantiated, and database records are seeded.
  2. Execution Phase (use): Calling await use(fixtureValue) pauses fixture execution and passes the initialized resource into the test body (or into dependent fixtures).
  3. Teardown Phase: Code executing after await use() runs automatically after the test (or dependent fixtures) completes. This guarantees cleanup, such as closing contexts, deleting temporary files, or removing database records.
import { test as base } from '@playwright/test';

export const test = base.extend<{ customResource: string }>({
  customResource: async ({}, use) => {
    // 1. Setup Phase
    const resource = 'initialized_data';

    // 2. Pass resource to the test body
    await use(resource);

    // 3. Teardown Phase (runs after test completion)
    // Perform cleanup actions here
  },
});

2. Built-In vs. Custom Fixtures

Playwright provides built-in fixtures out of the box, which can be extended to create domain-specific custom fixtures.

Built-In Fixtures

Playwright pre-defines several fixtures for browser automation and network testing:

  • page: An isolated Page instance provided to a single test run.
  • context: An isolated BrowserContext instance. The page fixture belongs to this context.
  • browser: A Browser instance shared across tests in a worker to optimize resource utilization.
  • browserName: String indicating the active browser (chromium, firefox, or webkit).
  • request: An isolated APIRequestContext instance configured for API assertions and REST interactions.

Custom Fixtures

SDETs create custom fixtures using test.extend() to encapsulate Page Objects, API clients, or pre-authenticated browser states. Custom fixtures can also override existing built-in fixtures—such as overriding page to automatically navigate to a baseURL.


3. Fixture Scopes: Test-Scoped vs. Worker-Scoped

Playwright fixtures operate under two primary scope levels: test-scoped and worker-scoped.

┌────────────────────────────────────────────────────────────────────────┐
│ Worker Process (OS Process)                                           │
│  ├── Worker-Scoped Fixture (e.g., Database Connection / Admin Token)   │
│  │                                                                     │
│  ├── Test Run 1 ──► [Test-Scoped Fixture] ──► Test Body ──► Teardown   │
│  │                                                                     │
│  └── Test Run 2 ──► [Test-Scoped Fixture] ──► Test Body ──► Teardown   │
└────────────────────────────────────────────────────────────────────────┘

Test-Scoped Fixtures (Default)

Test-scoped fixtures are instantiated and torn down independently for every single test that requests them.

  • Lifecycle: Setup runs before the test and its beforeEach hooks; teardown runs after afterEach hooks and test completion.
  • Isolation: Guarantees complete test isolation.
  • Use Cases: Isolated Page instances, Page Object Models, and test-specific data payloads.

Worker-Scoped Fixtures (scope: 'worker')

Worker-scoped fixtures are initialized once per OS worker process using tuple syntax { scope: 'worker' }.

  • Lifecycle: Lazy setup occurs when the first test assigned to that worker requests the fixture. Teardown executes once when the worker process terminates.
  • Performance: Reuses expensive connections across multiple test files assigned to the same worker. Worker fixtures also receive an independent execution timeout equal to the default test timeout, customizable via { timeout: number }.
  • Use Cases: Worker-level user accounts, database pools, local test servers, or shared authentication state files.

4. Playwright Hooks: beforeEach, beforeAll, afterEach, afterAll

Playwright supports traditional lifecycle hooks for scoping setup and teardown imperatively within test files or test.describe() groups.

  • test.beforeEach(): Runs before each test in its declared file or test.describe() block. Can request test-scoped fixtures.
  • test.afterEach(): Runs after each test in its scope. Receives test fixtures and the TestInfo object to inspect test outcomes (e.g., checking test.info().status).
  • test.beforeAll(): Runs once per worker process before any tests in the file or group execute. Can request worker-scoped fixtures, but cannot access test-scoped fixtures like page.
  • test.afterAll(): Runs once per worker process after all tests in the file or group complete.

Execution Order Breakdown

When a test suite contains automatic fixtures, worker fixtures, hooks, and test fixtures, Playwright executes them in a deterministic order:

  1. Worker Setup Phase: Automatic worker fixtures ({ scope: 'worker', auto: true }) setup → test.beforeAll() hooks run.
  2. Test Setup Phase: Automatic test fixtures ({ auto: true }) setup → Test-scoped fixtures setup → test.beforeEach() hooks run.
  3. Test Body: The individual test() function executes.
  4. Test Teardown Phase: test.afterEach() hooks run → Test-scoped fixtures teardown → Automatic test fixtures teardown.
  5. Worker Teardown Phase: test.afterAll() hooks run → Worker-scoped fixtures teardown → Automatic worker fixtures teardown.
Note: Playwright workers restart automatically after a test failure to ensure a pristine environment for subsequent runs. When a worker restarts, beforeAll hooks and worker fixtures run again in the new worker process.

5. Architectural Comparison Table

DimensionbeforeEachbeforeAllTest-Scoped FixtureWorker-Scoped Fixture
LifecycleRuns before every test in file/group.Runs once per worker before file/group tests.Setup before test; teardown after test.Lazy setup once per worker; teardown at worker shutdown.
IsolationHigh per-test isolation; relies on manual cleanup in afterEach.Low isolation; shared state across tests in worker.Strict hermetic isolation per test.Worker-level isolation; shared across tests in same worker.
ReuseScoped strictly to declared file/describe block.Scoped strictly to declared file/describe block.Reusable across entire framework via test.extend().Reusable across files; tied to worker lifecycle.
Parallel ExecutionRuns in parallel per test across workers.Runs once per worker process in parallel setups.Fully parallel-safe; isolated browser contexts.Parallel-safe if indexed per worker via workerIndex.
MaintainabilityRequires outer let variables; prone to state leakage.Harder to clean up; requires paired afterAll.Excellent encapsulation; setup and teardown in one function.Centralized infrastructure management.
Best Use CasesOne-off, file-specific test adjustments.Heavy file-level setup like temporary repo creation.Page Object Models, API clients, test-specific data.Pre-authenticated worker accounts, DB pools.

6. Real-World Scenario: beforeEach vs. Custom Fixture

To understand how these patterns scale, let's examine the same realistic test automation scenario implemented with both approaches.

Scenario Requirements

  1. Authenticate a session and seed data via API using APIRequestContext.
  2. Instantiate a DashboardPage Page Object Model.
  3. Navigate to the dashboard and perform test assertions.
  4. Clean up seeded API data after test completion.

Implementation 1: The Hook-Based Approach (beforeEach + Local State)

In this traditional pattern, setup code lives inside beforeEach and afterEach hooks, storing instances in module-scoped let variables.

// tests/dashboard-hooks.spec.ts
import { test, expect, APIRequestContext } from '@playwright/test';
import { DashboardPage } from '../pages/dashboard-page';

test.describe('Dashboard Suite - Hooks Approach', () => {
  // Shared state held in module-scoped variables
  let apiContext: APIRequestContext;
  let dashboardPage: DashboardPage;
  let createdWidgetId: string;

  test.beforeEach(async ({ page, playwright, baseURL }) => {
    // 1. Manually create an API context
    apiContext = await playwright.request.newContext({
      baseURL,
      extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    });

    // 2. Seed test data via API
    const response = await apiContext.post('/api/widgets', {
      data: { name: 'Analytics Widget' },
    });
    const data = await response.json();
    createdWidgetId = data.id;

    // 3. Initialize Page Object Model and navigate
    dashboardPage = new DashboardPage(page);
    await dashboardPage.goto();
  });

  test.afterEach(async () => {
    // 4. Teardown seeded data
    if (apiContext && createdWidgetId) {
      await apiContext.delete(`/api/widgets/${createdWidgetId}`);
      await apiContext.dispose();
    }
  });

  test('should display created widget on dashboard', async () => {
    const isVisible = await dashboardPage.isWidgetVisible('Analytics Widget');
    expect(isVisible).toBeTruthy();
  });

  test('should allow toggling widget layout', async () => {
    await dashboardPage.toggleLayout();
    expect(await dashboardPage.getLayoutMode()).toBe('grid');
  });
});

Disadvantages of the Hook Approach

  • State Leakage Risks: Storing apiContext and createdWidgetId in outer let variables creates risks if tests run concurrently or if a setup fails mid-execution.
  • Boilerplate Duplication: Every new spec file requiring dashboard access must re-declare these hooks and variable structures.
  • Eager Execution: beforeEach runs for every test in the describe block, even if a test only needs API validation and no UI setup.

Implementation 2: The Custom Fixture Approach (test.extend())

Here, we extend Playwright's test object to encapsulate API seeding, POM instantiation, and automatic teardown into composable fixtures.

// fixtures/app-fixtures.ts
import { test as base, expect } from '@playwright/test';
import { DashboardPage } from '../pages/dashboard-page';

// 1. Define custom fixture types
type AppFixtures = {
  dashboardApp: { page: DashboardPage; widgetId: string };
};

export const test = base.extend<AppFixtures>({
  dashboardApp: async ({ page, request }, use) => {
    // Setup Phase: Seed data using built-in 'request' fixture
    const response = await request.post('/api/widgets', {
      data: { name: 'Analytics Widget' },
    });
    const { id: widgetId } = await response.json();

    const dashboardPage = new DashboardPage(page);
    await dashboardPage.goto();

    // Pass encapsulated resources to the test
    await use({ page: dashboardPage, widgetId });

    // Teardown Phase: Automatic cleanup after test completion
    await request.delete(`/api/widgets/${widgetId}`);
  },
});

export { expect } from '@playwright/test';
// tests/dashboard-fixtures.spec.ts
import { test, expect } from '../fixtures/app-fixtures';

test.describe('Dashboard Suite - Fixture Approach', () => {
  test('should display created widget on dashboard', async ({ dashboardApp }) => {
    const isVisible = await dashboardApp.page.isWidgetVisible('Analytics Widget');
    expect(isVisible).toBeTruthy();
  });

  test('should allow toggling widget layout', async ({ dashboardApp }) => {
    await dashboardApp.page.toggleLayout();
    expect(await dashboardApp.page.getLayoutMode()).toBe('grid');
  });
});

Why the Fixture Architecture Scales Better

  • Zero Variable Leakage: Setup data (widgetId) is scoped strictly to the fixture callback, preventing accidental cross-test state contamination.
  • Unified Setup & Teardown: Code before await use() prepares resources, while code after handles teardown automatically—eliminating split beforeEach/afterEach logic.
  • On-Demand Execution: If a test signature does not request { dashboardApp }, Playwright skips data seeding and navigation entirely.

7. Advanced Framework Architecture Patterns

1. Authentication and storageState

Playwright allows caching authenticated browser context states (cookies, local storage) into JSON files using storageState.

                                  ┌──► Project "chromium" (reuses user.json)
┌─────────────────┐               │
│ auth.setup.ts   ├─► user.json ──┼──► Project "firefox"  (reuses user.json)
└─────────────────┘               │
                                  └──► Project "webkit"   (reuses user.json)

Playwright supports three authentication strategies based on test requirements:

  1. Global Setup Project (Shared Account): Best for read-only test suites. A dedicated setup project runs auth.setup.ts, authenticates via UI/API, and saves playwright/.auth/user.json. Test projects depend on setup and specify storageState: 'playwright/.auth/user.json' in playwright.config.ts.
  2. Worker-Scoped Authentication (State-Mutating Tests): When tests modify server data in parallel, shared accounts cause race conditions. Using a worker-scoped fixture (workerStorageState), each parallel worker authenticates a unique account (identified via test.info().parallelIndex) and stores its isolated auth state in outputDir.
  3. Multi-Role POM Fixtures: For multi-user interaction testing, custom fixtures can initialize distinct BrowserContext instances for different roles (adminPage, userPage) in a single test:
export const test = base.extend<{ adminPage: AdminPage; userPage: UserPage }>({
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/admin.json' });
    const page = new AdminPage(await context.newPage());
    await use(page);
    await context.close();
  },
  userPage: async ({ browser }, use) => {
    const context = await browser.newContext({ storageState: 'playwright/.auth/user.json' });
    const page = new UserPage(await context.newPage());
    await use(page);
    await context.close();
  },
});

2. Page Object Model (POM) Integration with Decorators

When wrapping POMs in custom fixtures, SDETs can clean up test reporting using the @step TypeScript method decorator with { box: true }. Boxing hides internal locator logs and attributes failure call-sites directly to the POM method call in HTML reports and Trace Viewer.

function step(target: Function, context: ClassMethodDecoratorContext) {
  return function replacementMethod(...args: any[]) {
    const name = `${this.constructor.name}.${String(context.name)}`;
    return test.step(name, async () => target.call(this, ...args), { box: true });
  };
}

export class DashboardPage {
  constructor(readonly page: Page) {}

  @step
  async toggleLayout() {
    await this.page.getByRole('button', { name: 'Toggle Layout' }).click();
  }
}

3. API Testing with APIRequestContext

Playwright's request fixture allows sending direct HTTP requests without launching browser windows. SDETs use APIRequestContext inside fixtures or hooks to establish preconditions fast or validate server-side postconditions after UI interactions.


8. Parallel Execution, Test Isolation, and Common Mistakes

Playwright achieves parallelism by running spec files across independent OS worker processes. Understanding worker isolation prevents common automation pitfalls.

Common Mistakes to Avoid

  1. Shared State Pollution in Parallel Runs: Reusing a single hardcoded user account across parallel workers when tests mutate server state. Use test.info().parallelIndex or test.info().workerIndex to isolate test accounts per worker.
  2. File Path Collisions: Hardcoding static output paths (e.g., 'downloads/report.csv') causes parallel workers to overwrite each other. Always use testInfo.outputPath('report.csv') to generate isolated file paths.
  3. Committing storageState Files: Session JSON files contain sensitive cookies and bearer tokens. Always add playwright/.auth to .gitignore.
  4. Missing Clean Context in Worker Auth: When authenticating inside worker fixtures, forgetting to pass storageState: undefined when creating a browser context can accidentally inherit stale session state.
  5. Over-using beforeAll for Test-Scoped Data: Attempting to initialize test-scoped POMs or pages inside beforeAll leads to shared state bugs, as page belongs to a single test-scoped browser context.

9. Practical Decision Matrix: When to Use Hooks vs. Fixtures

While custom fixtures offer superior framework architecture, hooks remain the simpler and correct choice in specific contexts.

                     Is the setup reusable across multiple files?
                                      │
                     ┌────────────────┴────────────────┐
                     ▼                                 ▼
                  [ YES ]                           [ NO ]
                     │                                 │
     Is it a complex environment,             Is it a quick, file-local
    POM, or resources with teardown?           override or debug check?
             │                                         │
       ┌─────┴─────┐                             ┌─────┴─────┐
       ▼           ▼                             ▼           ▼
  [ Fixture ] [ Fixture ]                   [ Hook ]    [ Hook ]

Choose Playwright Hooks (beforeEach / beforeAll) When:

  • File-Local One-Offs: Performing specialized setup that applies strictly to a single spec file (e.g., mocking a specific network route via page.route() for localized edge cases).
  • Simple Diagnostics: Logging test execution context or titles via console.log(test.info().title) inside a spec file.
  • Resource Initialization in beforeAll: Creating single-use external resources for an entire suite (e.g., spinning up a local test web server or creating a temporary repository).

Choose Playwright Fixtures When:

  • Page Object Model Distribution: Exposing POM instances to tests without boilerplate instantiation.
  • Setup + Teardown Pairing: Any resource requiring reliable cleanup (database records, seeded API entities, temporary files).
  • Cross-Suite Sharing: Setup logic needed across multiple test files or projects.
  • Multi-Role Testing: Testing complex interactions between multiple authenticated user roles in a single test.

Frequently Asked Questions (FAQ)

What is a fixture in Playwright?

A fixture in Playwright is a composable, environment-preparing function that provides tests with the exact resources they need (such as page, context, or custom Page Objects) on demand.

What is the difference between fixtures and hooks?

Hooks (beforeEach, beforeAll) execute setup scripts imperatively for every test in their file scope. Fixtures are declarative, injected via arguments, execute on demand, and combine setup and teardown inside a single function using await use().

Should I use beforeEach or fixtures?

Use beforeEach for simple, file-specific test adjustments. Use fixtures when setup code needs to be reused across spec files, requires guaranteed teardown cleanup, or involves Page Object Models.

When should I use beforeAll?

Use beforeAll for heavy setup actions that need to run once per worker before any test in a specific file executes—such as seeding global static data or initializing external services.

What is a worker-scoped fixture?

A worker-scoped fixture ({ scope: 'worker' }) is initialized once per OS worker process when requested, reused across all tests assigned to that worker, and torn down when the worker shuts down.

Can fixtures work with the Page Object Model?

Yes. Extending base.extend() allows wrapping Page Object Model classes into typed fixtures, enabling tests to receive fully initialized POM instances directly as function arguments.

How do fixtures behave with parallel tests?

Test-scoped fixtures instantiate independent instances per test inside each worker process, maintaining strict context isolation. Worker-scoped fixtures run once per worker process and can utilize test.info().workerIndex or parallelIndex to isolate test data safely across workers.