Skip to content

Latest commit

 

History

History
1014 lines (838 loc) · 31.4 KB

File metadata and controls

1014 lines (838 loc) · 31.4 KB

Fixtures and Hooks

When to use: Whenever tests need shared setup, teardown, reusable resources, or configurable context. Fixtures are Playwright's killer feature — prefer them over hooks in every situation where both could work.

Quick Reference

Mechanism Scope Cleanup guaranteed? Parallelism-safe? Use for
test.extend() fixture per-test Yes (via use() callback) Yes Most setup/teardown needs
Worker-scoped fixture per-worker Yes Yes (isolated per worker) Expensive resources: DB connections, auth state
Auto fixture per-test or per-worker Yes Yes Side effects that must always run: blocking analytics, logging
beforeEach / afterEach per-test No (afterEach skipped on crash) Yes Simple, one-off setup that doesn't need cleanup
beforeAll / afterAll per-worker No (afterAll skipped on crash) Dangerous if mutating shared state One-time worker setup with no cleanup needs

The rule: If it needs cleanup, use a fixture. If it doesn't need cleanup and is simple, a hook is acceptable. When in doubt, use a fixture.

Patterns

1. Custom Test Fixture

Use when: Tests need a resource with guaranteed setup and teardown. Avoid when: The setup is a single line with no teardown — a beforeEach is fine.

Fixtures use a use() callback. Everything before use() is setup; everything after is teardown. Teardown runs even if the test crashes.

TypeScript

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

type TodoFixtures = {
  todoPage: TodoPage;
};

class TodoPage {
  constructor(private page: import('@playwright/test').Page) {}

  async addTodo(text: string) {
    await this.page.getByPlaceholder('What needs to be done?').fill(text);
    await this.page.getByPlaceholder('What needs to be done?').press('Enter');
  }

  async todos() {
    return this.page.getByTestId('todo-item');
  }
}

export const test = base.extend<TodoFixtures>({
  todoPage: async ({ page }, use) => {
    // Setup
    await page.goto('/todos');
    const todoPage = new TodoPage(page);

    // Hand the fixture to the test
    await use(todoPage);

    // Teardown — runs even if test fails or crashes
    await page.evaluate(() => localStorage.clear());
  },
});

export { expect };
// todos.spec.ts
import { test, expect } from './fixtures';

test('add a todo item', async ({ todoPage, page }) => {
  await todoPage.addTodo('Buy milk');
  await expect(await todoPage.todos()).toHaveCount(1);
});

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

class TodoPage {
  constructor(page) {
    this.page = page;
  }

  async addTodo(text) {
    await this.page.getByPlaceholder('What needs to be done?').fill(text);
    await this.page.getByPlaceholder('What needs to be done?').press('Enter');
  }

  async todos() {
    return this.page.getByTestId('todo-item');
  }
}

const test = base.extend({
  todoPage: async ({ page }, use) => {
    await page.goto('/todos');
    const todoPage = new TodoPage(page);
    await use(todoPage);
    await page.evaluate(() => localStorage.clear());
  },
});

module.exports = { test, expect };

2. Worker-Scoped Fixtures

Use when: A resource is expensive to create and safe to share across tests in the same worker (database connections, auth tokens, compiled assets). Avoid when: Tests mutate the resource — each test must get its own copy.

Worker-scoped fixtures are created once per worker process, not once per test. They cannot depend on test-scoped fixtures (page, context, request).

TypeScript

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

type WorkerFixtures = {
  dbConnection: DatabaseClient;
  authToken: string;
};

export const test = base.extend<{}, WorkerFixtures>({
  dbConnection: [async ({}, use) => {
    const db = await DatabaseClient.connect(process.env.DB_URL!);
    await use(db);
    await db.disconnect();
  }, { scope: 'worker' }],

  authToken: [async ({}, use) => {
    const response = await fetch(`${process.env.API_URL}/auth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: 'test-user',
        password: process.env.TEST_PASSWORD,
      }),
    });
    const { token } = await response.json();
    await use(token);
    // No teardown needed — token expires on its own
  }, { scope: 'worker' }],
});

export { expect } from '@playwright/test';

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  dbConnection: [async ({}, use) => {
    const db = await DatabaseClient.connect(process.env.DB_URL);
    await use(db);
    await db.disconnect();
  }, { scope: 'worker' }],

  authToken: [async ({}, use) => {
    const response = await fetch(`${process.env.API_URL}/auth/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        username: 'test-user',
        password: process.env.TEST_PASSWORD,
      }),
    });
    const { token } = await response.json();
    await use(token);
  }, { scope: 'worker' }],
});

module.exports = { test, expect };

Important: The second type parameter in base.extend<TestFixtures, WorkerFixtures> is for worker-scoped fixtures. Always put worker fixtures in the second generic argument.

3. Auto Fixtures

Use when: Something must run for every test without being explicitly requested — blocking analytics, capturing console errors, injecting feature flags. Avoid when: Tests need to opt out. Auto fixtures always run; there is no per-test escape hatch.

TypeScript

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

type AutoFixtures = {
  blockAnalytics: void;
  consoleErrors: string[];
};

export const test = base.extend<AutoFixtures>({
  // Blocks all analytics/tracking requests in every test
  blockAnalytics: [async ({ page }, use) => {
    await page.route(/google-analytics|segment|hotjar|mixpanel/, (route) =>
      route.abort()
    );
    await use();
  }, { auto: true }],

  // Captures console errors — fail test if unexpected errors appear
  consoleErrors: [async ({ page }, use) => {
    const errors: string[] = [];
    page.on('console', (msg) => {
      if (msg.type() === 'error') {
        errors.push(msg.text());
      }
    });
    await use(errors);
    // Teardown: assert no unexpected console errors
    const unexpected = errors.filter(
      (e) => !e.includes('Expected warning')
    );
    if (unexpected.length > 0) {
      throw new Error(
        `Unexpected console errors:\n${unexpected.join('\n')}`
      );
    }
  }, { auto: true }],
});

export { expect };

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  blockAnalytics: [async ({ page }, use) => {
    await page.route(/google-analytics|segment|hotjar|mixpanel/, (route) =>
      route.abort()
    );
    await use();
  }, { auto: true }],

  consoleErrors: [async ({ page }, use) => {
    const errors = [];
    page.on('console', (msg) => {
      if (msg.type() === 'error') {
        errors.push(msg.text());
      }
    });
    await use(errors);
    const unexpected = errors.filter(
      (e) => !e.includes('Expected warning')
    );
    if (unexpected.length > 0) {
      throw new Error(
        `Unexpected console errors:\n${unexpected.join('\n')}`
      );
    }
  }, { auto: true }],
});

module.exports = { test, expect };

Auto fixtures can also be worker-scoped with { auto: true, scope: 'worker' } for things like starting a dev server once per worker.

4. Fixture Composition with mergeTests()

Use when: You have multiple fixture files (auth fixtures, API fixtures, UI fixtures) and tests need several of them. Avoid when: You only have one fixture file. Don't over-engineer.

mergeTests() combines multiple extended test objects into one. Each fixture file defines its own concerns.

TypeScript

// fixtures/auth.ts
import { test as base } from '@playwright/test';

type AuthFixtures = {
  authenticatedPage: import('@playwright/test').Page;
};

export const test = base.extend<AuthFixtures>({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});
// fixtures/api.ts
import { test as base, APIRequestContext } from '@playwright/test';

type ApiFixtures = {
  apiClient: APIRequestContext;
};

export const test = base.extend<ApiFixtures>({
  apiClient: async ({ playwright }, use) => {
    const api = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    });
    await use(api);
    await api.dispose();
  },
});
// fixtures/index.ts
import { mergeTests } from '@playwright/test';
import { test as authTest } from './auth';
import { test as apiTest } from './api';

export const test = mergeTests(authTest, apiTest);
export { expect } from '@playwright/test';
// dashboard.spec.ts
import { test, expect } from './fixtures';

test('dashboard loads user data', async ({ authenticatedPage, apiClient }) => {
  // Both fixtures are available
  const data = await apiClient.get('/users/me');
  await expect(authenticatedPage.getByRole('heading')).toContainText('Dashboard');
});

JavaScript

// fixtures/auth.js
const { test: base } = require('@playwright/test');

const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@example.com');
    await page.getByLabel('Password').fill('password123');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

module.exports = { test };
// fixtures/api.js
const { test: base } = require('@playwright/test');

const test = base.extend({
  apiClient: async ({ playwright }, use) => {
    const api = await playwright.request.newContext({
      baseURL: 'https://api.example.com',
      extraHTTPHeaders: { Authorization: `Bearer ${process.env.API_TOKEN}` },
    });
    await use(api);
    await api.dispose();
  },
});

module.exports = { test };
// fixtures/index.js
const { mergeTests } = require('@playwright/test');
const { test: authTest } = require('./auth');
const { test: apiTest } = require('./api');

const test = mergeTests(authTest, apiTest);
module.exports = { test, expect: require('@playwright/test').expect };

5. Parameterized Fixtures (Option Fixtures)

Use when: A fixture's behavior should be configurable per-project or per-describe block (locale, viewport, user role, feature flags). Avoid when: The value never changes — just hardcode it in the fixture.

Option fixtures are declared with { option: true } and can be overridden in playwright.config under use or with test.use() in test files.

TypeScript

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

type OptionFixtures = {
  userRole: 'admin' | 'editor' | 'viewer';
  locale: string;
};

type DerivedFixtures = {
  authenticatedPage: import('@playwright/test').Page;
};

export const test = base.extend<OptionFixtures & DerivedFixtures>({
  // Option fixtures — configurable per project or describe block
  userRole: ['viewer', { option: true }],
  locale: ['en-US', { option: true }],

  // Derived fixture — uses the option values
  authenticatedPage: async ({ page, userRole, locale }, use) => {
    await page.goto(`/login?locale=${locale}`);
    const credentials = {
      admin: { email: 'admin@test.com', password: 'admin-pass' },
      editor: { email: 'editor@test.com', password: 'editor-pass' },
      viewer: { email: 'viewer@test.com', password: 'viewer-pass' },
    };
    const { email, password } = credentials[userRole];
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password').fill(password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use(page);
  },
});

export { expect };
// playwright.config.ts (override per project)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'admin-tests',
      testDir: './tests/admin',
      use: { userRole: 'admin', locale: 'en-US' },
    },
    {
      name: 'viewer-tests',
      testDir: './tests/viewer',
      use: { userRole: 'viewer', locale: 'fr-FR' },
    },
  ],
});
// admin-settings.spec.ts (override per describe block)
import { test, expect } from './fixtures';

test.describe('admin settings', () => {
  test.use({ userRole: 'admin' });

  test('can access settings page', async ({ authenticatedPage }) => {
    await authenticatedPage.goto('/settings');
    await expect(authenticatedPage.getByRole('heading')).toHaveText('Admin Settings');
  });
});

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  userRole: ['viewer', { option: true }],
  locale: ['en-US', { option: true }],

  authenticatedPage: async ({ page, userRole, locale }, use) => {
    await page.goto(`/login?locale=${locale}`);
    const credentials = {
      admin: { email: 'admin@test.com', password: 'admin-pass' },
      editor: { email: 'editor@test.com', password: 'editor-pass' },
      viewer: { email: 'viewer@test.com', password: 'viewer-pass' },
    };
    const { email, password } = credentials[userRole];
    await page.getByLabel('Email').fill(email);
    await page.getByLabel('Password').fill(password);
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use(page);
  },
});

module.exports = { test, expect };

6. Fixture Dependencies

Use when: One fixture needs the output of another fixture. Playwright automatically resolves the dependency graph. Avoid when: You are tempted to nest fixtures more than 3 levels deep — flatten the design instead.

Request fixtures by name in the destructured first argument. Playwright handles ordering and lifecycle.

TypeScript

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

type Fixtures = {
  apiContext: import('@playwright/test').APIRequestContext;
  testUser: { id: string; email: string };
  userPage: import('@playwright/test').Page;
};

export const test = base.extend<Fixtures>({
  apiContext: async ({ playwright }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: process.env.API_URL,
    });
    await use(ctx);
    await ctx.dispose();
  },

  // Depends on apiContext — Playwright creates apiContext first
  testUser: async ({ apiContext }, use) => {
    const response = await apiContext.post('/test/users', {
      data: { email: `user-${Date.now()}@test.com`, role: 'editor' },
    });
    const user = await response.json();
    await use(user);
    // Teardown: delete the test user
    await apiContext.delete(`/test/users/${user.id}`);
  },

  // Depends on page AND testUser — both are ready before this runs
  userPage: async ({ page, testUser }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(testUser.email);
    await page.getByLabel('Password').fill('default-test-password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

export { expect };

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  apiContext: async ({ playwright }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: process.env.API_URL,
    });
    await use(ctx);
    await ctx.dispose();
  },

  testUser: async ({ apiContext }, use) => {
    const response = await apiContext.post('/test/users', {
      data: { email: `user-${Date.now()}@test.com`, role: 'editor' },
    });
    const user = await response.json();
    await use(user);
    await apiContext.delete(`/test/users/${user.id}`);
  },

  userPage: async ({ page, testUser }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill(testUser.email);
    await page.getByLabel('Password').fill('default-test-password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await page.waitForURL('/dashboard');
    await use(page);
  },
});

module.exports = { test, expect };

Teardown runs in reverse dependency order: userPage tears down first, then testUser (which deletes the user), then apiContext.

7. Overriding Built-in Fixtures

Use when: Every test needs the same modification to page, context, or browser — custom headers, viewport, locale, route blocking. Avoid when: Only some tests need the override. Use a new named fixture instead to keep the default page available.

TypeScript

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

export const test = base.extend({
  // Override the built-in context fixture to add custom headers
  context: async ({ browser }, use) => {
    const context = await browser.newContext({
      extraHTTPHeaders: {
        'X-Test-ID': `test-${Date.now()}`,
        'Accept-Language': 'en-US',
      },
      permissions: ['geolocation'],
      geolocation: { latitude: 37.7749, longitude: -122.4194 },
    });
    await use(context);
    await context.close();
  },

  // Override the built-in page fixture to block third-party scripts
  page: async ({ context }, use) => {
    const page = await context.newPage();
    await page.route('**/*.{png,jpg,jpeg,gif,svg}', (route) => route.abort());
    await use(page);
    // No need to close page — context.close() handles it
  },
});

export { expect };

JavaScript

// fixtures.js
const { test: base, expect } = require('@playwright/test');

const test = base.extend({
  context: async ({ browser }, use) => {
    const context = await browser.newContext({
      extraHTTPHeaders: {
        'X-Test-ID': `test-${Date.now()}`,
        'Accept-Language': 'en-US',
      },
      permissions: ['geolocation'],
      geolocation: { latitude: 37.7749, longitude: -122.4194 },
    });
    await use(context);
    await context.close();
  },

  page: async ({ context }, use) => {
    const page = await context.newPage();
    await page.route('**/*.{png,jpg,jpeg,gif,svg}', (route) => route.abort());
    await use(page);
  },
});

module.exports = { test, expect };

8. beforeEach / afterEach — When Hooks Are Acceptable

Use when: Simple, stateless setup with no teardown needed. Navigation to a starting URL is the canonical example. Avoid when: The setup creates something that must be cleaned up. Use a fixture instead.

TypeScript

// acceptable-hooks.spec.ts
import { test, expect } from '@playwright/test';

// Acceptable: simple navigation, no teardown needed
test.beforeEach(async ({ page }) => {
  await page.goto('/dashboard');
});

test('shows welcome message', async ({ page }) => {
  await expect(page.getByRole('heading')).toHaveText('Welcome');
});

test('shows navigation sidebar', async ({ page }) => {
  await expect(page.getByRole('navigation')).toBeVisible();
});

JavaScript

// acceptable-hooks.spec.js
const { test, expect } = require('@playwright/test');

test.beforeEach(async ({ page }) => {
  await page.goto('/dashboard');
});

test('shows welcome message', async ({ page }) => {
  await expect(page.getByRole('heading')).toHaveText('Welcome');
});

test('shows navigation sidebar', async ({ page }) => {
  await expect(page.getByRole('navigation')).toBeVisible();
});

Hooks can use fixtures in their argument list — beforeEach(async ({ page, context }) works. But hooks cannot define fixtures.

9. beforeAll / afterAll — Worker-Level Hooks

Use when: One-time setup that all tests in a file share and that has no cleanup needs, such as logging a diagnostic or checking a precondition. Avoid when: You are creating resources that need cleanup (use worker-scoped fixtures) or storing mutable state (this breaks parallelism).

TypeScript

// health-check.spec.ts
import { test, expect } from '@playwright/test';

// Good: read-only check, no mutable state
test.beforeAll(async ({ request }) => {
  const response = await request.get('/api/health');
  expect(response.ok()).toBeTruthy();
});

test('homepage loads', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/My App/);
});

JavaScript

// health-check.spec.js
const { test, expect } = require('@playwright/test');

test.beforeAll(async ({ request }) => {
  const response = await request.get('/api/health');
  expect(response.ok()).toBeTruthy();
});

test('homepage loads', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/My App/);
});

beforeAll / afterAll receive only worker-scoped fixtures: request, browser, and any custom worker fixtures. They do not receive page or context (those are per-test).

10. Typed Fixtures in TypeScript

Always define an interface for your fixtures. This gives you autocomplete, catches typos at compile time, and documents the fixture contract.

TypeScript

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

// Define fixture types explicitly
interface TestFixtures {
  adminPage: Page;
  editorPage: Page;
  apiClient: APIRequestContext;
}

interface WorkerFixtures {
  sharedToken: string;
}

export const test = base.extend<TestFixtures, WorkerFixtures>({
  sharedToken: [async ({}, use) => {
    const res = await fetch(`${process.env.API_URL}/auth/service-token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ secret: process.env.SERVICE_SECRET }),
    });
    const { token } = await res.json();
    await use(token);
  }, { scope: 'worker' }],

  apiClient: async ({ playwright, sharedToken }, use) => {
    const ctx = await playwright.request.newContext({
      baseURL: process.env.API_URL,
      extraHTTPHeaders: { Authorization: `Bearer ${sharedToken}` },
    });
    await use(ctx);
    await ctx.dispose();
  },

  adminPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'auth/admin.json' });
    const page = await ctx.newPage();
    await use(page);
    await ctx.close();
  },

  editorPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'auth/editor.json' });
    const page = await ctx.newPage();
    await use(page);
    await ctx.close();
  },
});

export { expect };

Decision Guide

I need to set up something before tests run.
│
├── Does it create a resource that MUST be cleaned up?
│   │
│   ├── YES → Use a fixture (setup before `use()`, teardown after)
│   │   │
│   │   ├── Is the resource expensive and safe to share across tests?
│   │   │   ├── YES → Worker-scoped fixture: { scope: 'worker' }
│   │   │   └── NO  → Test-scoped fixture (default)
│   │   │
│   │   ├── Should every test get this automatically?
│   │   │   ├── YES → Auto fixture: { auto: true }
│   │   │   └── NO  → Regular fixture (test declares it in args)
│   │   │
│   │   └── Should the value be configurable per project?
│   │       ├── YES → Option fixture: { option: true }
│   │       └── NO  → Regular fixture with hardcoded setup
│   │
│   └── NO → Hook is acceptable
│       │
│       ├── Per-test setup?
│       │   └── beforeEach
│       │
│       └── One-time per worker?
│           └── beforeAll (but only for read-only / diagnostic checks)
│
└── Am I combining fixtures from multiple domains?
    └── YES → mergeTests() from separate fixture files

Anti-Patterns

1. Global Mutable State in beforeAll

// BAD: Mutable state shared across parallel tests
let testUser: { id: string; email: string };

test.beforeAll(async ({ request }) => {
  const res = await request.post('/api/users', { data: { email: 'shared@test.com' } });
  testUser = await res.json(); // Shared mutable state!
});

test.afterAll(async ({ request }) => {
  await request.delete(`/api/users/${testUser.id}`); // Cleanup may not run
});

test('test 1', async ({ page }) => {
  // Uses testUser — but what if another worker also has a testUser?
});
// GOOD: Worker-scoped fixture — isolated per worker, guaranteed cleanup
import { test as base } from '@playwright/test';

export const test = base.extend<{ testUser: { id: string; email: string } }>({
  testUser: async ({ request }, use) => {
    const res = await request.post('/api/users', {
      data: { email: `user-${Date.now()}@test.com` },
    });
    const user = await res.json();
    await use(user);
    await request.delete(`/api/users/${user.id}`);
  },
});

2. Cleanup in afterEach Instead of Fixture Teardown

// BAD: afterEach is not guaranteed to run on crash
test.beforeEach(async ({ page }) => {
  await page.evaluate(() => localStorage.setItem('debug', 'true'));
});

test.afterEach(async ({ page }) => {
  // This might NOT run if the test crashes or times out
  await page.evaluate(() => localStorage.clear());
});
// GOOD: Fixture teardown always runs
export const test = base.extend<{ debugMode: void }>({
  debugMode: async ({ page }, use) => {
    await page.evaluate(() => localStorage.setItem('debug', 'true'));
    await use();
    await page.evaluate(() => localStorage.clear()); // Guaranteed
  },
});

3. Fixtures That Do Too Many Things

// BAD: One fixture doing setup for unrelated concerns
export const test = base.extend({
  everything: async ({ page }, use) => {
    // Logs in
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@test.com');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    // Creates test data
    await page.request.post('/api/products', { data: { name: 'Widget' } });
    // Blocks analytics
    await page.route(/analytics/, (r) => r.abort());
    // Sets locale
    await page.evaluate(() => localStorage.setItem('locale', 'en'));
    await use(page);
  },
});
// GOOD: Separate fixtures, each with one responsibility
export const test = base.extend({
  authenticatedPage: async ({ page }, use) => {
    await page.goto('/login');
    await page.getByLabel('Email').fill('user@test.com');
    await page.getByLabel('Password').fill('password');
    await page.getByRole('button', { name: 'Sign in' }).click();
    await use(page);
  },

  testProduct: async ({ request }, use) => {
    const res = await request.post('/api/products', { data: { name: 'Widget' } });
    const product = await res.json();
    await use(product);
    await request.delete(`/api/products/${product.id}`);
  },

  blockAnalytics: [async ({ page }, use) => {
    await page.route(/analytics/, (r) => r.abort());
    await use();
  }, { auto: true }],
});

4. Over-Abstracting Fixtures

// BAD: Fixture factory with layers of indirection nobody can follow
const createFixture = (role, permissions, options) =>
  base.extend({
    [`${role}Page`]: async ({ page }, use) => {
      await setupRole(page, role, permissions, options);
      await use(page);
    },
  });

const adminTest = createFixture('admin', ['read', 'write', 'delete'], { mfa: true });
const editorTest = createFixture('editor', ['read', 'write'], { mfa: false });
// Good luck debugging which fixture ran
// GOOD: Explicit fixtures — boring but readable
export const test = base.extend({
  adminPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'auth/admin.json' });
    const page = await ctx.newPage();
    await use(page);
    await ctx.close();
  },

  editorPage: async ({ browser }, use) => {
    const ctx = await browser.newContext({ storageState: 'auth/editor.json' });
    const page = await ctx.newPage();
    await use(page);
    await ctx.close();
  },
});

5. Not Typing Fixtures in TypeScript

// BAD: No type parameter — no autocomplete, no compile-time errors
export const test = base.extend({
  myFixture: async ({ page }, use) => {
    await use({ count: 42 });
  },
});
// test('example', async ({ myFixture }) => {
//   myFixture.cont // No autocomplete, no type safety
// });
// GOOD: Explicit type interface
interface MyFixtures {
  myFixture: { count: number };
}

export const test = base.extend<MyFixtures>({
  myFixture: async ({ page }, use) => {
    await use({ count: 42 });
  },
});
// test('example', async ({ myFixture }) => {
//   myFixture.count // Autocomplete works, typos caught at compile time
// });

Troubleshooting

Symptom Cause Fix
Cannot use a test-scoped fixture in a worker-scoped fixture Worker fixture depends on page, context, or test-scoped custom fixture Worker fixtures can only depend on other worker fixtures or built-in worker fixtures (browser, playwright)
Fixture "X" has already been registered Two fixture files define the same fixture name and are both extended Use mergeTests() instead of chaining .extend() calls, or rename one fixture
Fixture teardown not running You used afterEach instead of the use() callback pattern Move cleanup code after the await use() call inside the fixture
beforeAll can't access page page is test-scoped; beforeAll only gets worker-scoped fixtures Use a worker-scoped fixture, or move logic to beforeEach
Test hangs inside fixture await use() was never called — Playwright waits indefinitely for the fixture to yield Ensure every fixture code path calls await use(value) exactly once
Fixture runs but test doesn't see the value Fixture not declared in the test's destructured arguments Add the fixture name to the test function signature: test('name', async ({ myFixture }) => { ... })
Option fixture value ignored test.use() called inside test() instead of test.describe() test.use() must be at the top level of a describe block or file, not inside a test
Auto fixture not running Fixture file not imported — the custom test is not used Import and use the test from your fixture file, not from @playwright/test

Related