# 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**
```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 };
```

```typescript
// 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**
```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**
```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**
```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**
```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**
```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**
```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);
  },
});
```

```typescript
// 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();
  },
});
```

```typescript
// 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';
```

```typescript
// 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**
```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 };
```

```javascript
// 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 };
```

```javascript
// 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**
```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 };
```

```typescript
// 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' },
    },
  ],
});
```

```typescript
// 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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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 };
```

### 11. Aborting a Test From a Fixture or Route (`test.abort()`, Playwright 1.60+)

**Use when**: A fixture, hook, or route handler detects an unrecoverable precondition — a required backend is down, a seed step failed, or a route received an unexpected request — and continuing would only produce a confusing downstream failure.
**Avoid when**: The test body itself can assert the condition. Use a normal `expect()` there. `test.abort()` is for code that runs *outside* the test body where you can't `expect`.

Playwright 1.60 adds `test.abort(message?)`, which immediately stops the currently running test. Unlike `test.skip()`, it marks the test as failed/aborted with your message, and unlike throwing it works cleanly from fixtures and route handlers.

**TypeScript**
```typescript
import { test as base, expect } from '@playwright/test';

export const test = base.extend({
  // Abort early if the environment isn't ready, instead of letting every
  // assertion fail with a cryptic message.
  seededDb: async ({}, use) => {
    const res = await fetch(`${process.env.API_URL}/health/db`);
    if (!res.ok) {
      base.abort(`Database not reachable (${res.status}) — aborting test`);
    }
    await use(true);
  },
});

test('checkout works on a healthy backend', async ({ page, seededDb }) => {
  await page.goto('/checkout');
  await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();
});

test('abort from a route handler on an unexpected request', async ({ page }) => {
  await page.route('**/api/**', (route) => {
    if (route.request().url().includes('/api/forbidden')) {
      test.abort('Unexpected call to forbidden endpoint');
    }
    return route.continue();
  });

  await page.goto('/dashboard');
});
```

**JavaScript**
```javascript
const { test, expect } = require('@playwright/test');

test('abort from a route handler on an unexpected request', async ({ page }) => {
  await page.route('**/api/**', (route) => {
    if (route.request().url().includes('/api/forbidden')) {
      test.abort('Unexpected call to forbidden endpoint');
    }
    return route.continue();
  });

  await page.goto('/dashboard');
});
```

> `test.abort()` ends the test now — code after it in the same handler won't run. Use it for *unrecoverable* conditions; for "this test doesn't apply here", prefer `test.skip()`.

## 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`

```typescript
// 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?
});
```

```typescript
// 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

```typescript
// 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());
});
```

```typescript
// 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

```typescript
// 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);
  },
});
```

```typescript
// 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

```typescript
// 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
```

```typescript
// 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

```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
// });
```

```typescript
// 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

- [pom/page-object-model.md](../pom/page-object-model.md) — Page objects are typically consumed via fixtures
- [core/test-organization.md](test-organization.md) — Where to put fixture files in the project tree
- [core/configuration.md](configuration.md) — Option fixtures are configured in `playwright.config`
- [core/authentication.md](authentication.md) — Auth state is the most common worker-scoped fixture
- [ci/global-setup-teardown.md](../ci/global-setup-teardown.md) — For truly global (all-workers) setup, not per-worker
- [pom/pom-vs-fixtures-vs-helpers.md](../pom/pom-vs-fixtures-vs-helpers.md) — When to use each abstraction
