Playwright fixtures are a game-changer for keeping your E2E tests DRY, but most people don’t realize they’re fundamentally a dependency injection system for your tests, not just a way to share setup.

Let’s see them in action. Imagine you have a common setup: logging into an application.

// tests/login.spec.js
import { test, expect } from '@playwright/test';

// A simple fixture that logs in a user
test.fixture('loginPage', async ({ page }) => {
  await page.goto('https://myapp.com/login');
  await page.fill('input[name="username"]', 'testuser');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');
  await expect(page).toHaveURL('https://myapp.com/dashboard');
  return page; // Return the page object after login
});

test('should display dashboard after login', async ({ loginPage }) => {
  // loginPage fixture is automatically injected here
  await expect(loginPage).toHaveURL('https://myapp.com/dashboard');
  await expect(loginPage.locator('h1')).toContainText('Dashboard');
});

test('should allow navigation to profile after login', async ({ loginPage }) => {
  // loginPage fixture is automatically injected again
  await loginPage.click('a[href="/profile"]');
  await expect(loginPage).toHaveURL('https://myapp.com/profile');
});

In this example, loginPage is a fixture. When a test function declares loginPage as an argument, Playwright automatically runs the loginPage fixture function before the test function executes. The return value of the fixture (page in this case) is then passed as the argument to the test. This means loginPage in test('should display dashboard after login', async ({ loginPage }) => { ... }); is not just a variable; it’s the result of running our login setup.

The real power comes from how Playwright manages these dependencies. Each fixture is essentially a function that depends on other fixtures (or Playwright’s built-in page and context objects). Playwright builds a directed acyclic graph (DAG) of these dependencies. When you request a fixture, Playwright traces its dependencies, runs them in the correct order, and caches their results.

Consider a more complex scenario where you need a logged-in user and some pre-populated data:

// tests/data.spec.js
import { test, expect } from '@playwright/test';

test.fixture('apiClient', async () => {
  // Assume this returns an authenticated API client
  const client = {
    post: async (url, data) => { /* ... */ return { json: () => ({ id: 'new-item-123' }) }; },
    get: async (url) => { /* ... */ return { json: () => ([{ id: 'existing-item-456' }]) }; }
  };
  return client;
});

test.fixture('loggedInPage', async ({ page, apiClient }) => {
  // Use the apiClient to create a user or get tokens
  const token = await apiClient.post('/auth/register', { username: 'fixtureuser', password: 'password' }).json().then(res => res.token);
  await page.goto('https://myapp.com');
  await page.evaluate(([token]) => localStorage.setItem('authToken', token), [token]);
  await page.reload(); // Reload to apply the token
  await expect(page).toHaveURL('https://myapp.com/dashboard');
  return page;
});

test.fixture('userWithItems', async ({ loggedInPage, apiClient }) => {
  // Use the loggedInPage to get the current user's ID (e.g., from DOM or a global JS var)
  const userId = await loggedInPage.evaluate(() => window.currentUser.id);
  await apiClient.post(`/users/${userId}/items`, { name: 'Initial Item' });
  await apiClient.post(`/users/${userId}/items`, { name: 'Another Item' });
  return { page: loggedInPage, userId };
});

test('should list user items', async ({ userWithItems }) => {
  const { page, userId } = userWithItems;
  const items = await apiClient.get(`/users/${userId}/items`).json(); // Note: apiClient isn't directly injected here, but it was used *within* a fixture.

  await page.goto('https://myapp.com/items');
  await expect(page.locator('.item-list li')).toHaveCount(items.length);
  await expect(page.locator('.item-list li').first()).toContainText('Initial Item');
});

Here, userWithItems depends on loggedInPage, which in turn depends on page and apiClient. Playwright understands this chain. When userWithItems is requested, it ensures apiClient and loggedInPage are run first. The apiClient fixture runs only once, loggedInPage runs once per test that needs it (unless scoped to a worker or test file), and userWithItems also runs once per test.

The dependency injection aspect is key: you declare what your test needs, and Playwright figures out how to provide it by orchestrating the execution of your fixtures. This makes tests declarative and isolates their setup concerns. You can even have fixtures that provide other fixtures, creating reusable building blocks.

A common pattern that trips people up is understanding fixture scope and sharing. By default, fixtures are scoped to the test file. However, you can explicitly define a worker-scoped fixture using test.extend and worker: true. This means the fixture’s setup and teardown will run only once for the entire worker process, significantly speeding up test suites with common, expensive setup like database seeding or API client initialization.

// tests/e2e.spec.js
// ... other imports ...

const base = require('@playwright/test');

const test = base.test.extend({
  // This fixture is scoped to the worker process.
  // It runs once per worker, not per test file.
  apiConfig: [async ({}, use) => {
    console.log('Setting up worker-scoped API config');
    const config = { apiKey: 'super-secret-key-for-worker' };
    await use(config);
    console.log('Tearing down worker-scoped API config');
  }, { worker: true }],

  // Example of a fixture depending on a worker-scoped fixture
  seededDatabase: [async ({ apiConfig }, use) => {
    console.log('Seeding database for worker...');
    // Use apiConfig here
    await use({ db: 'seeded_db_instance' });
    console.log('Tearing down seeded database...');
  }, { worker: true, scope: 'worker' }], // scope: 'worker' is redundant if worker: true is set, but good for clarity
});

test.use({
  // Override the default page fixture to use our worker-scoped seeded DB
  page: async ({ seededDatabase, browser }, use) => {
    // You can't directly pass the seededDatabase to the page fixture's
    // constructor. Instead, you might use it to configure the page
    // or its context before it's created.
    const context = await browser.newContext();
    // Example: Add a cookie or modify local storage based on seededDatabase
    await context.addCookies([{
      name: 'db_session',
      value: 'mock-session-id',
      url: 'https://myapp.com',
    }]);
    const page = await context.newPage();
    await use(page);
    await context.close();
  },
});

test('should use worker-scoped setup', async ({ page, apiConfig, seededDatabase }) => {
  console.log('Running test that uses worker-scoped setup');
  expect(apiConfig.apiKey).toBe('super-secret-key-for-worker');
  expect(seededDatabase.db).toBe('seeded_db_instance');
  // Use page, which implicitly used seededDatabase setup
});

When you define fixtures using test.extend with worker: true, Playwright ensures that the setup function for that fixture is executed only once per Playwright worker process. This is a critical optimization for time-consuming setup tasks like spinning up a local database, initializing a complex service, or fetching large datasets that can be shared across all tests running within that worker. The use() function then provides the setup result to tests, and the teardown code runs when the worker exits. This pattern is fundamental for scaling your test suite’s performance without sacrificing test isolation.

The next step is exploring how to parameterize fixtures to create variations of your setup for different test scenarios.

Want structured learning?

Take the full Playwright course →