Testing Patterns August 12, 2026 18 min read

Playwright Fixtures and Hooks Guide 2026: Master Test Setup, Teardown, and Reusable Patterns

Playwright fixtures and hooks are the foundation of scalable test architecture. This guide covers everything from built-in fixtures and lifecycle hooks to advanced patterns like custom fixtures, worker-scoped resources, and fixture composition — with production-ready TypeScript examples you can use today.

Every Playwright test needs some form of setup: a browser page to interact with, an authenticated session, test data in a database, or an API client ready to make requests. How you manage that setup determines whether your test suite scales cleanly to hundreds of tests or collapses into a tangled mess of duplicated code and brittle dependencies.

Playwright gives you two complementary mechanisms for test setup and teardown: fixtures and hooks. Understanding when to use each — and how to combine them — is the difference between a test suite that grows effortlessly and one that becomes a maintenance burden.

This playwright fixtures and hooks guide covers both mechanisms from first principles, with real-world TypeScript examples you can adapt for your own projects.


What Are Playwright Fixtures?

If you have used testing frameworks like Jest or Mocha, you are familiar with beforeEach and afterEach for test setup and teardown. Playwright fixtures take a fundamentally different approach. Instead of running setup code before every test regardless of whether the test needs it, fixtures are dependency-injected — a test declares what it needs, and Playwright provides it.

Think of fixtures as named, reusable setup/teardown units that are:

  • Lazy — a fixture only runs if a test actually requests it
  • Composable — fixtures can depend on other fixtures
  • Encapsulated — setup and teardown live together in one place
  • Automatically scoped — Playwright handles lifecycle management
  • Type-safe — full TypeScript autocomplete and type checking

When you write test('my test', async ({ page }) => { ... }), the page parameter is a fixture. Playwright creates a fresh browser page before your test runs and closes it after the test finishes. You never call browser.newPage() or page.close() manually — the fixture handles both.

TypeScript — fixture injection
import { test, expect } from '@playwright/test';

// 'page' is a built-in fixture — injected automatically
test('homepage loads correctly', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveTitle('Example Domain');
});

// You can request multiple fixtures
test('API and browser together', async ({ page, request }) => {
  // 'request' is another built-in fixture for API testing
  const response = await request.get('/api/status');
  await expect(response).toBeOK();
});

Why this matters: In a traditional beforeEach approach, every test pays the setup cost even if it does not need all the resources. Fixtures are lazy — if a test only needs a page, Playwright will not create an API request context. This makes your test suite faster and more resource-efficient.


Built-in Playwright Fixtures

Playwright ships with several built-in fixtures that cover the most common testing needs. Understanding each one is essential before creating custom fixtures.

page

The most commonly used fixture. Provides a fresh, isolated Page instance for each test. Each page runs in its own BrowserContext, meaning cookies, local storage, and session data are isolated between tests.

TypeScript
test('page fixture provides isolated browser page', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc');
  await page.getByPlaceholder('What needs to be done?').fill('Write tests');
  await page.getByPlaceholder('What needs to be done?').press('Enter');
  await expect(page.getByTestId('todo-title')).toHaveText('Write tests');
});

context

Provides the BrowserContext instance that the page fixture belongs to. Use this when you need to control context-level settings like cookies, permissions, geolocation, or when you need multiple pages in the same context.

TypeScript
test('grant geolocation permission', async ({ context, page }) => {
  await context.grantPermissions(['geolocation']);
  await context.setGeolocation({ latitude: 40.7128, longitude: -74.0060 });
  await page.goto('https://example.com/store-locator');
  await expect(page.getByText('New York')).toBeVisible();
});

test('multi-page scenario', async ({ context }) => {
  // Create two pages in the same context (shared cookies/storage)
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  await page1.goto('https://example.com/sender');
  await page2.goto('https://example.com/receiver');
});

browser

Provides the shared Browser instance for the current worker. This fixture is worker-scoped — the same browser instance is reused across all tests running on the same worker. Use it when you need to create multiple independent browser contexts.

TypeScript
test('two users chatting', async ({ browser }) => {
  // Create two isolated contexts — different users
  const aliceContext = await browser.newContext();
  const bobContext = await browser.newContext();

  const alicePage = await aliceContext.newPage();
  const bobPage = await bobContext.newPage();

  // Each user has independent cookies, storage, sessions
  await alicePage.goto('https://chat.example.com');
  await bobPage.goto('https://chat.example.com');

  await aliceContext.close();
  await bobContext.close();
});

browserName

A string fixture that returns the current browser name: 'chromium', 'firefox', or 'webkit'. Useful for conditional test logic when a feature behaves differently across browsers.

TypeScript
test('browser-specific behavior', async ({ page, browserName }) => {
  test.skip(browserName === 'webkit', 'WebKit does not support this API');

  await page.goto('https://example.com/webgpu-demo');
  await expect(page.getByText('WebGPU Active')).toBeVisible();
});

request

Provides an APIRequestContext for making HTTP requests without a browser. Perfect for API testing, setting up test data, or verifying server-side state. The request context shares the same baseURL and storageState as the browser context.

TypeScript
test('API creates resource correctly', async ({ request }) => {
  const response = await request.post('/api/todos', {
    data: { title: 'Learn fixtures', completed: false }
  });

  await expect(response).toBeOK();

  const todo = await response.json();
  expect(todo.title).toBe('Learn fixtures');
  expect(todo.id).toBeDefined();
});

Custom Fixtures with test.extend()

Built-in fixtures cover the basics, but real-world test suites need custom infrastructure: an authenticated user, a seeded database, a mock API server, or a configured API client. Playwright custom fixtures let you package this setup into reusable, type-safe, dependency-injected units using test.extend().

Basic custom fixture

Here is a simple custom fixture that provides a to-do page — a page that has already navigated to the to-do app and is ready for interaction:

TypeScript — fixtures.ts
import { test as base, expect } from '@playwright/test';

// Define fixture types
type MyFixtures = {
  todoPage: Page;
};

// Extend the base test with custom fixtures
export const test = base.extend<MyFixtures>({
  todoPage: async ({ page }, use) => {
    // SETUP: navigate to the app
    await page.goto('https://demo.playwright.dev/todomvc');

    // PROVIDE: hand the page to the test
    await use(page);

    // TEARDOWN: runs after the test finishes (automatic cleanup)
    // In this case, page cleanup is handled by Playwright
  },
});

export { expect };
TypeScript — todo.spec.ts
import { test, expect } from './fixtures';

test('can add a todo item', async ({ todoPage }) => {
  // todoPage is already on the TodoMVC app
  await todoPage.getByPlaceholder('What needs to be done?').fill('Buy groceries');
  await todoPage.getByPlaceholder('What needs to be done?').press('Enter');
  await expect(todoPage.getByTestId('todo-title')).toContainText('Buy groceries');
});

The pattern is always the same: setup, use, teardown. Code before await use() runs before the test. Code after await use() runs after the test finishes, even if the test fails. This is more reliable than afterEach because the teardown is guaranteed to run.

Authenticated user fixture

One of the most common playwright custom fixtures is an authenticated page. Instead of logging in before every test, you create a fixture that handles authentication once:

TypeScript — auth fixture
import { test as base } from '@playwright/test';
import type { Page } from '@playwright/test';

type AuthFixtures = {
  authenticatedPage: Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ browser }, use) => {
    // Create a new context with saved auth state
    const context = await browser.newContext({
      storageState: './auth/admin.json'
    });
    const page = await context.newPage();

    await use(page);

    // Teardown: close the context (and its pages)
    await context.close();
  },
});

Database seed fixture

For tests that need specific data in the database, a seed fixture ensures clean, predictable state:

TypeScript — database fixture
import { test as base } from '@playwright/test';
import { db } from './helpers/database';

type DbFixtures = {
  seededDb: { userId: string; projectId: string };
};

export const test = base.extend<DbFixtures>({
  seededDb: async ({}, use) => {
    // Setup: seed test data
    const user = await db.createUser({ email: 'test@example.com' });
    const project = await db.createProject({ ownerId: user.id });

    await use({ userId: user.id, projectId: project.id });

    // Teardown: clean up test data
    await db.deleteProject(project.id);
    await db.deleteUser(user.id);
  },
});

API client fixture

A reusable API client fixture with pre-configured authentication and base URL:

TypeScript — API client fixture
import { test as base, APIRequestContext } from '@playwright/test';

type ApiFixtures = {
  apiClient: APIRequestContext;
};

export const test = base.extend<ApiFixtures>({
  apiClient: async ({ playwright }, use) => {
    const apiContext = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: {
        'Authorization': `Bearer ${process.env.API_TOKEN}`,
        'Content-Type': 'application/json',
      },
    });

    await use(apiContext);

    await apiContext.dispose();
  },
});

Playwright Hooks: beforeAll, beforeEach, afterAll, afterEach

Playwright hooks are lifecycle callbacks that run at specific points during test execution. If you have used any testing framework before, these will be familiar. Playwright supports four hooks for playwright test setup teardown:

beforeEach

Runs before every individual test in the file. This is the most commonly used hook — ideal for navigation, form resets, or per-test setup that does not warrant a custom fixture.

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

test.beforeEach(async ({ page }) => {
  // Runs before EVERY test in this file
  await page.goto('https://demo.playwright.dev/todomvc');
});

test('can add a todo', async ({ page }) => {
  await page.getByPlaceholder('What needs to be done?').fill('Learn Playwright');
  await page.getByPlaceholder('What needs to be done?').press('Enter');
  await expect(page.getByTestId('todo-title')).toHaveText('Learn Playwright');
});

test('page starts empty', async ({ page }) => {
  await expect(page.getByTestId('todo-title')).toHaveCount(0);
});

afterEach

Runs after every individual test, regardless of whether the test passed or failed. Use it for cleanup, logging, or capturing debug information on failure.

TypeScript
test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status !== testInfo.expectedStatus) {
    // Capture screenshot on failure for debugging
    const screenshotPath = `screenshots/${testInfo.title}-failed.png`;
    await page.screenshot({ path: screenshotPath, fullPage: true });
    await testInfo.attach('failure-screenshot', {
      path: screenshotPath,
      contentType: 'image/png',
    });
  }
});

beforeAll

Runs once before all tests in the file. This hook runs once per worker process. Use it for expensive one-time setup: seeding a database, creating shared test data via API, or starting a mock server.

Important: beforeAll does not have access to the page or context fixtures because those are test-scoped. You can use the browser fixture or create your own context within the hook.

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

let authToken: string;

test.beforeAll(async ({ request }) => {
  // Runs ONCE before all tests in this file
  // Create test data via API
  const response = await request.post('/api/auth/login', {
    data: { email: 'admin@example.com', password: 'secret' }
  });
  const body = await response.json();
  authToken = body.token;
});

test('can access admin dashboard', async ({ page }) => {
  await page.setExtraHTTPHeaders({ 'Authorization': `Bearer ${authToken}` });
  await page.goto('/admin/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

afterAll

Runs once after all tests in the file have completed. Use it to clean up resources created in beforeAll: delete test data, stop mock servers, or close database connections.

TypeScript
let testUserId: string;

test.beforeAll(async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { name: 'Test User', email: 'test@example.com' }
  });
  const user = await response.json();
  testUserId = user.id;
});

test.afterAll(async ({ request }) => {
  // Clean up: delete the test user
  await request.delete(`/api/users/${testUserId}`);
});

Fixtures vs Hooks: When to Use Which

This is where many teams make mistakes. Fixtures and hooks are not interchangeable — each has a clear use case. The following comparison table summarizes the key differences for your playwright fixtures tutorial reference:

Aspect Fixtures Hooks
Execution Lazy — only runs if test requests it Eager — runs for every test regardless
Reusability Shared across files via import Scoped to single file
Composition Fixtures can depend on other fixtures No dependency chain
Teardown Guaranteed — runs after use() even on failure afterEach/afterAll — also runs on failure
Type safety Full TypeScript inference Standard TS support
Simplicity Requires test.extend() boilerplate Simple callback — no boilerplate
Best for Reusable infrastructure (auth, DB, API clients) Simple, file-scoped setup (navigation, reset)

Rule of thumb: If you will use the same setup in more than one test file, make it a fixture. If the setup is simple and only relevant to one file, use a hook. If in doubt, prefer fixtures — they are more flexible and easier to refactor later. Combine fixtures with the Page Object Model for maximum reusability.



Advanced Fixture Patterns

Once you understand the basics of playwright custom fixtures, you can build sophisticated test infrastructure using advanced patterns. These patterns are what separate hobby projects from production-grade test suites.

Fixture Composition

Fixtures can depend on other fixtures, creating a composition chain. Playwright resolves the dependency graph automatically and ensures fixtures are created in the correct order and torn down in reverse order.

TypeScript — composed fixtures
import { test as base } from '@playwright/test';
import type { Page } from '@playwright/test';

type Fixtures = {
  adminPage: Page;
  dashboardPage: Page;
};

export const test = base.extend<Fixtures>({
  // Base fixture: authenticated admin page
  adminPage: async ({ browser }, use) => {
    const context = await browser.newContext({
      storageState: './auth/admin.json',
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  // Composed fixture: depends on adminPage
  dashboardPage: async ({ adminPage }, use) => {
    await adminPage.goto('/admin/dashboard');
    await adminPage.waitForLoadState('networkidle');
    await use(adminPage);
  },
});

// Usage: dashboardPage automatically creates adminPage first
test('dashboard shows metrics', async ({ dashboardPage }) => {
  await expect(dashboardPage.getByText('Total Users')).toBeVisible();
});

When the test requests dashboardPage, Playwright automatically creates adminPage first (because dashboardPage depends on it), then passes it to the dashboardPage fixture. On teardown, dashboardPage tears down first, then adminPage — reverse order.

Worker-Scoped Fixtures

By default, fixtures are test-scoped — created fresh for every test. Worker-scoped fixtures are created once per worker process and shared across all tests running on that worker. This is essential for expensive resources that do not need per-test isolation.

TypeScript — worker-scoped fixture
import { test as base } from '@playwright/test';

type WorkerFixtures = {
  authToken: string;
  apiBaseUrl: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  // The second type parameter marks these as worker-scoped
  authToken: [async ({ playwright }, use) => {
    // This runs ONCE per worker, not per test
    const request = await playwright.request.newContext();
    const response = await request.post('https://api.example.com/auth', {
      data: { email: 'admin@example.com', password: 'secret' }
    });
    const { token } = await response.json();

    await use(token);

    await request.dispose();
  }, { scope: 'worker' }],

  apiBaseUrl: [async ({}, use) => {
    await use(process.env.API_URL ?? 'https://api.staging.example.com');
  }, { scope: 'worker' }],
});

Constraint: Worker-scoped fixtures cannot depend on test-scoped fixtures. A worker-scoped fixture lives for the entire worker lifetime, but test-scoped fixtures are created and destroyed for each test. The dependency only works in one direction: test-scoped fixtures can depend on worker-scoped fixtures.

Parameterized Fixtures

Fixtures can have configurable default values that individual test files or test.describe blocks can override. This is powerful for running the same tests with different configurations.

TypeScript — parameterized fixtures
import { test as base } from '@playwright/test';

type OptionsFixtures = {
  defaultUser: { email: string; role: string };
};

export const test = base.extend<OptionsFixtures>({
  // Option fixture with default value
  defaultUser: [{ email: 'user@example.com', role: 'viewer' }, { option: true }],
});

// In a test file: override the default
test.describe('admin tests', () => {
  test.use({ defaultUser: { email: 'admin@example.com', role: 'admin' } });

  test('admin can see settings', async ({ page, defaultUser }) => {
    // defaultUser.role is 'admin' here
    console.log(`Testing as ${defaultUser.email}`);
  });
});

test.describe('viewer tests', () => {
  // Uses the default: role 'viewer'
  test('viewer cannot see settings', async ({ page, defaultUser }) => {
    // defaultUser.role is 'viewer' here
  });
});

Fixture Dependencies and Ordering

When fixtures depend on each other, Playwright builds a dependency graph and resolves it automatically. Here is a real-world example with multiple layers:

TypeScript — multi-layer fixture chain
import { test as base } from '@playwright/test';

type Fixtures = {
  dbConnection: DbClient;
  testData: { userId: string; projectId: string };
  authenticatedApi: ApiClient;
  projectPage: Page;
};

export const test = base.extend<Fixtures>({
  // Layer 1: database connection
  dbConnection: async ({}, use) => {
    const client = await connectToDb();
    await use(client);
    await client.disconnect();
  },

  // Layer 2: depends on dbConnection
  testData: async ({ dbConnection }, use) => {
    const user = await dbConnection.createUser({ name: 'Test' });
    const project = await dbConnection.createProject({ owner: user.id });
    await use({ userId: user.id, projectId: project.id });
    await dbConnection.cleanup([user.id, project.id]);
  },

  // Layer 2: depends on testData for user credentials
  authenticatedApi: async ({ testData, playwright }, use) => {
    const api = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: { 'X-User-Id': testData.userId },
    });
    await use(api);
    await api.dispose();
  },

  // Layer 3: depends on testData for navigation
  projectPage: async ({ page, testData }, use) => {
    await page.goto(`/projects/${testData.projectId}`);
    await use(page);
  },
});

// Playwright resolves: dbConnection → testData → projectPage
test('project page shows title', async ({ projectPage }) => {
  await expect(projectPage.getByRole('heading')).toBeVisible();
});

Automatic Fixtures

Sometimes you want a fixture to run for every test without requiring the test to explicitly request it. Use the auto option for cross-cutting concerns like logging, performance monitoring, or analytics:

TypeScript — auto fixture
export const test = base.extend<{ perfLogger: void }>({
  perfLogger: [async ({ page }, use, testInfo) => {
    const startTime = Date.now();

    await use();

    const duration = Date.now() - startTime;
    console.log(`[PERF] ${testInfo.title}: ${duration}ms`);
  }, { auto: true }],
});

Best Practices for Playwright Fixtures and Hooks

After working with Playwright fixtures across dozens of production test suites, these are the patterns that consistently lead to maintainable, fast, and reliable tests. These align closely with the Playwright best practices for 2026. Consider these playwright reusable test patterns as your starting template.

Do

  • Use fixtures for anything reusable across files. Authentication, API clients, database connections, and test data factories should always be fixtures, never duplicated beforeEach blocks.
  • Keep fixture setup and teardown together. The code before use() sets up; the code after tears down. This makes the lifecycle obvious and guarantees cleanup runs.
  • Use worker-scoped fixtures for expensive resources. Database connections, auth tokens, and mock servers that do not need per-test isolation should be worker-scoped to avoid redundant setup.
  • Name fixtures descriptively. Names like authenticatedPage, adminApiClient, and seededDatabase make tests self-documenting. Avoid generic names like setup or data.
  • Use test.use() for per-file or per-describe configuration. Override fixture defaults with test.use() instead of creating multiple fixture variants.
  • Export your extended test object from a central file. Create a fixtures.ts or base.ts that all test files import from. This gives you a single place to add new fixtures.

Do Not

  • Do not duplicate fixture logic in hooks. If you have a beforeEach that logs in, and a fixture that also logs in, you are doubling work and creating confusion. Pick one approach per concern.
  • Do not store mutable state in worker-scoped fixtures. If test A modifies data created by a worker-scoped fixture, test B will see the modified state. Each test should be independent.
  • Do not over-compose fixtures. If your fixture dependency chain is 5+ layers deep, it becomes hard to debug. Flatten where possible.
  • Do not use beforeAll for setup that needs per-test isolation. If each test needs a fresh user, use a test-scoped fixture or beforeEach — not beforeAll, which shares state across tests.
  • Do not skip teardown. Always clean up resources in the post-use() section of fixtures or in afterAll/afterEach. Leaked resources cause flaky tests and CI failures.
  • Do not use auto fixtures excessively. Every auto fixture runs for every test. If only some tests need it, make it opt-in by requiring explicit declaration in the test signature.

Common Mistakes

  1. Forgetting await use() — Without the await use() call, the fixture never provides its value to the test. Your test will hang or receive undefined.
  2. Using page in beforeAll — The page fixture is test-scoped and not available in beforeAll. Use request or browser instead.
  3. Mixing extended test objects — If file A imports test from fixtures-a.ts and file B from fixtures-b.ts, and both extend the base test independently, their fixtures are not compatible. Use a single chain of extensions.
  4. Not typing fixtures — Skipping the TypeScript type parameter on test.extend<MyFixtures>() loses type safety. Always define the fixture type.

Putting It All Together: Real-World Example

Here is a complete, production-ready fixture setup that combines multiple patterns from this guide. This is representative of what a mature Playwright test suite looks like:

TypeScript — fixtures/base.ts
import { test as base } from '@playwright/test';
import type { Page, APIRequestContext } from '@playwright/test';

// Test-scoped fixture types
type TestFixtures = {
  adminPage: Page;
  userPage: Page;
  apiClient: APIRequestContext;
};

// Worker-scoped fixture types
type WorkerFixtures = {
  adminStorageState: string;
  userStorageState: string;
};

export const test = base.extend<TestFixtures, WorkerFixtures>({

  // Worker fixture: authenticate admin ONCE per worker
  adminStorageState: [async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    await page.goto('/login');
    await page.getByLabel('Email').fill('admin@example.com');
    await page.getByLabel('Password').fill('admin-password');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await page.waitForURL('/dashboard');

    const path = './auth/admin-state.json';
    await context.storageState({ path });
    await context.close();

    await use(path);
  }, { scope: 'worker' }],

  // Worker fixture: authenticate regular user ONCE per worker
  userStorageState: [async ({ browser }, use) => {
    const context = await browser.newContext();
    const page = await context.newPage();
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('user-password');
    await page.getByRole('button', { name: 'Sign In' }).click();
    await page.waitForURL('/dashboard');

    const path = './auth/user-state.json';
    await context.storageState({ path });
    await context.close();

    await use(path);
  }, { scope: 'worker' }],

  // Test fixture: admin page (uses worker-scoped auth)
  adminPage: async ({ browser, adminStorageState }, use) => {
    const context = await browser.newContext({
      storageState: adminStorageState,
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  // Test fixture: regular user page
  userPage: async ({ browser, userStorageState }, use) => {
    const context = await browser.newContext({
      storageState: userStorageState,
    });
    const page = await context.newPage();
    await use(page);
    await context.close();
  },

  // Test fixture: API client with auth
  apiClient: async ({ playwright, adminStorageState }, use) => {
    const context = await playwright.request.newContext({
      baseURL: process.env.API_URL ?? 'https://api.example.com',
      storageState: adminStorageState,
    });
    await use(context);
    await context.dispose();
  },
});

export { expect } from '@playwright/test';
TypeScript — tests/admin-settings.spec.ts
import { test, expect } from '../fixtures/base';

test.describe('Admin Settings', () => {
  test.beforeEach(async ({ adminPage }) => {
    await adminPage.goto('/admin/settings');
  });

  test('can update company name', async ({ adminPage }) => {
    await adminPage.getByLabel('Company Name').fill('Acme Corp');
    await adminPage.getByRole('button', { name: 'Save' }).click();
    await expect(adminPage.getByText('Settings saved')).toBeVisible();
  });

  test('regular user cannot access settings', async ({ userPage }) => {
    await userPage.goto('/admin/settings');
    await expect(userPage.getByText('Access Denied')).toBeVisible();
  });

  test('API returns settings', async ({ apiClient }) => {
    const response = await apiClient.get('/api/settings');
    await expect(response).toBeOK();
    const settings = await response.json();
    expect(settings.companyName).toBeDefined();
  });
});

Notice how fixtures and hooks work together naturally: worker-scoped fixtures handle expensive authentication once, test-scoped fixtures provide isolated pages, and a simple beforeEach handles per-test navigation. Each concern lives in exactly one place.


Frequently Asked Questions

What is the difference between Playwright fixtures and hooks?

Fixtures are dependency-injected setup/teardown units that are lazy, composable, and automatically scoped. Hooks (beforeAll, beforeEach, afterAll, afterEach) are lifecycle callbacks that run at specific points regardless of whether a test needs them. Fixtures are preferred for reusable infrastructure like authenticated pages, API clients, and database connections. Hooks are better for simple, file-scoped setup like navigating to a URL or clearing a cache.

How do I create a custom fixture in Playwright?

Use test.extend() to create custom fixtures. Define a new test object with your fixture name and a function that sets up the resource, yields it via the use callback, and optionally tears it down afterward. Import the extended test object in your spec files instead of the default one from @playwright/test. See the custom fixtures section above for complete examples.

When should I use beforeAll vs beforeEach in Playwright?

Use beforeEach when you need fresh setup before every individual test — like navigating to a page or resetting form state. Use beforeAll when you need expensive one-time setup shared across all tests in a file — like seeding a database, starting a mock server, or creating test data via API. Remember that beforeAll runs once per worker, so its state is shared across tests in the same file on that worker.

What are worker-scoped fixtures in Playwright?

Worker-scoped fixtures are created once per worker process and shared across all tests on that worker, rather than being recreated for each test. Define them by adding { scope: 'worker' } to your fixture options. They are ideal for expensive resources like database connections, authentication tokens, or mock servers. Worker-scoped fixtures cannot depend on test-scoped fixtures.

Can I use fixtures and hooks together in Playwright?

Yes. A common and recommended pattern is using fixtures for reusable infrastructure (authenticated user, API client, database) and hooks for simple per-file setup (navigating to a specific page, setting a viewport). Avoid duplicating the same setup logic in both — pick one approach per concern. Fixtures are generally preferred because they are more composable and only run when a test actually needs them.

How do I share state between tests using Playwright fixtures?

Use worker-scoped fixtures to share expensive state like database connections or auth tokens across tests. For test data, use beforeAll to create shared data and store it in a variable accessible by all tests in the file. Avoid sharing mutable state between tests — each test should be independent. If tests need shared login state, use storageState to save and restore browser authentication cookies and local storage across tests without re-logging in.


Asim Noaman - Playwright and Claude AI course instructor

Asim Noaman

Senior QA Automation Engineer & AI Testing Specialist

With years of hands-on experience building test automation frameworks for production applications, Asim specializes in combining traditional QA methodologies with cutting-edge AI tools. He has helped teams adopt Playwright and AI-driven testing workflows to ship faster with fewer bugs.

Udemy Instructor Published course author
Playwright + AI Expert Specialized in AI-powered QA
Production Experience Enterprise-grade frameworks
Connect on LinkedIn