When you write async ({ page }) => {...} in a Playwright test, you're receiving a fixture: Playwright created a fresh browser page before your test ran and will close it automatically after. Custom fixtures work the same way, declared with test.extend() and received by name in the test signature. The difference is that you define the setup and teardown yourself, with await use(value) as the dividing line. This guide covers all five built-in fixtures, the test.extend() pattern for custom fixtures, scope options for sharing expensive setup across tests, and how custom fixtures compose with each other.

What Is a Fixture?

A fixture is a value (or object) that Playwright prepares before your test runs and cleans up after. It's dependency injection for tests.

Instead of writing this:

test('user can log in', async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();
  
  // test code
  
  await page.close();
  await context.close();
  await browser.close();
});

You write this:

test('user can log in', async ({ page }) => {
  // page is ready to use — setup and teardown are handled
});

Playwright handles the lifecycle. You get a clean page for each test, and it's closed automatically after.

Built-in Fixtures

Playwright provides these fixtures out of the box:

| Fixture | Type | What it is |

|---------|------|------------|

| page | Page | A new browser page (tab) for each test |

| browser | Browser | The browser instance (shared across tests in a worker) |

| context | BrowserContext | Browser context — like an incognito window |

| browserName | string | The current browser: 'chromium', 'firefox', 'webkit' |

| request | APIRequestContext | HTTP client for API requests |

page

The most commonly used fixture. Each test gets its own isolated page. After the test, it closes automatically.

test('page loads correctly', async ({ page }) => {
  await page.goto('https://lab.becomeqa.com');
  await expect(page).toHaveTitle(/BecomeQA/);
});

context

A browser context is like an incognito window — it has its own cookies, storage, and session. If you need multiple pages in one test, create them from the same context:

test('two pages share the same session', async ({ context }) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();
  
  await page1.goto('/login');
  // Log in on page1
  
  // page2 also sees the session (same context = same cookies)
  await page2.goto('/dashboard');
  await expect(page2.getByTestId('user-name')).toBeVisible();
});

browser

Usually you don't need browser directly. Use it when you need to create contexts with specific settings:

test('mobile viewport', async ({ browser }) => {
  const context = await browser.newContext({
    viewport: { width: 390, height: 844 },
    userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
  });
  const page = await context.newPage();
  await page.goto('/');
  // Test on mobile-sized page
  await context.close();
});

request

Makes HTTP requests without a browser. Used for API testing and for setting up test data via API before UI tests.

test('create user via API', async ({ request }) => {
  const response = await request.post('/api/users', {
    data: { email: 'new@test.com', password: 'ValidPass1' },
  });
  expect(response.status()).toBe(201);
});

browserName

Use it to conditionally skip tests for specific browsers:

test('file download', async ({ page, browserName }) => {
  test.skip(browserName === 'firefox', 'Download API different in Firefox');
  // ...
});

Custom Fixtures

The real power of fixtures: you can create your own. Custom fixtures work exactly like built-in ones — they're declared once, used anywhere by destructuring.

Simple custom fixture: a pre-navigated page

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

// Define what custom fixtures exist
type MyFixtures = {
  loggedInPage: Page;
};

export const test = base.extend<MyFixtures>({
  loggedInPage: async ({ page }, use) => {
    // SETUP
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'user@test.com');
    await page.fill('[data-testid="password"]', 'ValidPass1');
    await page.click('[data-testid="submit"]');
    await page.waitForURL('/dashboard');
    
    // Give the test access to the page
    await use(page);
    
    // TEARDOWN (runs after the test)
    // Nothing needed here — page closes automatically
  },
});

export { expect };

// tests/dashboard.spec.ts
import { test, expect } from '../fixtures';  // import YOUR test, not @playwright/test

test('dashboard shows welcome message', async ({ loggedInPage }) => {
  // Already logged in — loggedInPage IS the page, after login
  await expect(loggedInPage.getByTestId('welcome')).toBeVisible();
});

Custom fixture: Page Object

The most common pattern — a fixture that provides an initialized page object class:

// fixtures/index.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

type PageObjects = {
  loginPage: LoginPage;
  dashboardPage: DashboardPage;
};

export const test = base.extend<PageObjects>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  
  dashboardPage: async ({ page }, use) => {
    await use(new DashboardPage(page));
  },
});

// tests/login.spec.ts
import { test, expect } from '../fixtures';

test('successful login', async ({ loginPage, dashboardPage }) => {
  await loginPage.goto();
  await loginPage.login('user@test.com', 'ValidPass1');
  await expect(dashboardPage.welcomeMessage).toBeVisible();
});

No need to instantiate page objects in every test.

Custom fixture with teardown

If your fixture creates something that needs cleanup:

type TestFixtures = {
  testUser: { id: number; email: string; token: string };
};

export const test = base.extend<TestFixtures>({
  testUser: async ({ request }, use) => {
    // SETUP: create a user
    const response = await request.post('/api/users', {
      data: {
        email: `test_${Date.now()}@example.com`,
        password: 'ValidPass1',
        role: 'member',
      },
    });
    const user = await response.json();
    
    // Login to get token
    const loginResp = await request.post('/api/auth/login', {
      data: { email: user.email, password: 'ValidPass1' },
    });
    const { token } = await loginResp.json();
    
    // Give test access
    await use({ id: user.id, email: user.email, token });
    
    // TEARDOWN: delete the user
    await request.delete(`/api/users/${user.id}`, {
      headers: { Authorization: `Bearer ${adminToken}` },
    });
  },
});

test('user can update profile', async ({ page, testUser }) => {
  // testUser has id, email, token — fresh and unique per test
  await page.goto(`/users/${testUser.id}`);
  // ...
  // After test, the user is automatically deleted
});

Fixture Scope

By default, fixtures are 'test' scope — recreated for every test. You can set scope to 'worker' for fixtures that are expensive and safe to share:

export const test = base.extend<{}, { sharedToken: string }>({
  sharedToken: [async ({ request }, use) => {
    // Runs once per worker, not once per test
    const response = await request.post('/api/auth/login', {
      data: { email: 'admin@test.com', password: 'AdminPass1' },
    });
    const { token } = await response.json();
    await use(token);
  }, { scope: 'worker' }],
});

Use 'worker' scope for things that are:

  • Expensive to recreate (database seeding, file generation)
  • Read-only (authentication tokens you only read, not modify)
  • Safe to share (no state that one test could corrupt for another)

Combining Fixtures

Custom fixtures can depend on other fixtures (including other custom ones):

export const test = base.extend<{
  loginPage: LoginPage;
  authenticatedPage: Page;
}>({
  loginPage: async ({ page }, use) => {
    await use(new LoginPage(page));
  },
  
  // This fixture USES loginPage
  authenticatedPage: async ({ page, loginPage }, use) => {
    await loginPage.goto();
    await loginPage.login('user@test.com', 'ValidPass1');
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

A Clean Project Structure

project/
├── fixtures/
│   └── index.ts        ← exports your extended test + expect
├── pages/
│   ├── LoginPage.ts
│   └── DashboardPage.ts
└── tests/
    ├── login.spec.ts   ← imports from fixtures/index.ts
    └── dashboard.spec.ts

All test files import from fixtures/index.ts, not from @playwright/test directly. This means every test automatically has access to all custom fixtures.

Summary

| | Built-in | Custom |

|-|----------|--------|

| Where defined | Playwright internals | test.extend() in your codebase |

| Where used | Any test with { page }, { request } etc. | Any test using your exported test |

| Examples | page, browser, request | loginPage, testUser, authToken |

| Lifecycle | Playwright manages | You define setup + await use() + teardown |

Fixtures are the cleanest way to share setup logic across tests. Once you start using them for page objects and authenticated states, you'll find tests become dramatically shorter and more focused on what they're actually testing.

→ See also: Custom Fixtures in Playwright: The Pattern That Makes Tests Read Like Prose | Playwright Test Structure: describe, beforeEach, afterEach, and Hooks | Handling Auth in Playwright with storageState (No Logging In Every Test)