Resource library

QA How-To

How to Use Playwright test fixtures override (2026)

Learn Playwright test fixtures override patterns for page, context, baseURL, storageState, authentication, teardown, fixture scope, typing, and safe reuse.

24 min read | 2,997 words

TL;DR

Create a specialized Playwright `test` with `base.extend`, override the named fixture with an async factory, perform setup, call `await use(value)`, and clean up afterward. Wrap inherited fixtures when possible, replace them only when you need a different object or value, and make ownership and scope explicit.

Key Takeaways

  • Override a fixture through test.extend and import the resulting test object everywhere that needs the behavior.
  • Depend on the inherited fixture when wrapping it, then call await use exactly once.
  • Put setup before await use and teardown after it, with cleanup protected by try and finally when needed.
  • Use test.use or project use for configurable option values, and fixture overrides for lifecycle behavior.
  • Do not close inherited page or context objects unless the override created and owns them.
  • Keep base fixture modules side-effect free and export specialized layers for distinct roles or environments.
  • Verify override chains with type checking, focused tests, parallel execution, and forced failures.

Playwright test fixtures override lets you adapt an existing fixture without changing every test that consumes it. A custom test object created with test.extend can wrap the inherited page, replace storageState, specialize an option, or override one of your own domain fixtures while preserving Playwright's dependency and teardown model.

The key is lifecycle ownership. Setup happens before await use(value), the test or dependent fixture runs while that promise is suspended, and teardown happens afterward. A correct override remains typed, isolated, lazy, and explicit about whether it wraps an inherited resource or creates a replacement.

TL;DR

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

export const test = base.extend({
  page: async ({ page, baseURL }, use) => {
    if (baseURL) await page.goto(baseURL);
    await use(page);
  },
});

export { expect };
Need Preferred mechanism Why
Change a configurable option value Config use or test.use No new lifecycle is needed
Add setup around inherited page Override page and depend on page Preserves built-in creation and cleanup
Supply dynamic auth state Override storageState Produces a value before context creation
Create a different page object Override page from browser, close it yourself The override owns the replacement
Specialize a custom domain fixture Extend the prior custom test Keeps layering and types explicit
Run behavior for every test Automatic fixture Consumers need not request it

Import the specialized test from the fixture module. Tests importing test directly from @playwright/test do not receive your override.

1. What Playwright test fixtures override Means

A Playwright fixture is a value plus lifecycle managed by the test runner. Built-in fixtures include browser, context, page, request, and option fixtures such as baseURL and storageState. Custom fixtures can expose page objects, API clients, accounts, data factories, or logs.

An override uses the same fixture name in a new test.extend definition. The new implementation may depend on the inherited fixture of that name, which wraps it, or depend on lower-level fixtures and produce a replacement. Playwright resolves dependencies, sets up only needed nonautomatic fixtures, calls the consumer, and then tears resources down in reverse dependency order.

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

export const test = base.extend({
  page: async ({ page }, use) => {
    await page.addInitScript(() => {
      window.localStorage.setItem('qa-mode', 'enabled');
    });
    await use(page);
  },
});

This override wraps the inherited page. It does not create a second page and does not close the page after use because the built-in page fixture owns cleanup. The initialization script applies before page scripts in later navigations. The application must tolerate that storage key and origin behavior.

Overriding is not monkey patching. Tests receive a normal fixture value through a specific extended test object. Another file can import the base test and remain unaffected. That makes specialized behavior composable, but it also creates an import contract that code review and linting should enforce.

2. Understand the Fixture Factory Lifecycle

A fixture implementation receives dependencies, a use callback, and fixture information. Everything before await use is setup. The value passed to use becomes available to the test or dependent fixture. Code after it is teardown.

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

type AuditFixtures = {
  auditLog: string[];
};

export const test = base.extend<AuditFixtures>({
  auditLog: async ({}, use, testInfo) => {
    const entries: string[] = [`start:${testInfo.title}`];

    try {
      await use(entries);
    } finally {
      entries.push(`finish:${testInfo.status ?? 'unknown'}`);
    }
  },
});

export { expect };

Call await use exactly once. Forgetting it prevents the consumer from running. Calling it twice violates the fixture contract. Returning a value instead of passing it to use does not provide the fixture.

Use try and finally when cleanup must run even if downstream execution throws. Playwright resumes the fixture after use settles, including failure paths, but an explicit finally protects cleanup from errors in adjacent teardown logic and communicates intent. Cleanup itself should be idempotent because workers can stop after failures and external data may already be absent.

Fixture setup and teardown count toward relevant timeout rules. Test-scoped fixture time normally participates in the test timeout. A slow fixture can receive its own timeout through tuple syntax. Worker fixtures have their own scoped lifecycle and can also define timeout. Do not hide an unbounded external request inside setup.

3. Override the Page Fixture to Navigate Automatically

A common override navigates every requested page to the configured base URL. It depends on both inherited page and the baseURL option, then exposes the same page.

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

export const test = base.extend({
  page: async ({ baseURL, page }, use) => {
    if (!baseURL) {
      throw new Error('baseURL is required by the app page fixture');
    }

    await page.goto(baseURL);
    await use(page);
  },
});

export { expect };
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  use: {
    baseURL: 'https://playwright.dev',
  },
});
// tests/home.spec.ts
import { test, expect } from '../fixtures/app-test';

test('shows the Playwright home page', async ({ page }) => {
  await expect(page).toHaveTitle(/Playwright/);
});

The guard turns missing configuration into a clear setup failure. Do not silently navigate to a hard-coded fallback in a fixture that might run against several environments. Environment validation belongs in config or the fixture boundary.

Automatic navigation is convenient only when every page-consuming test shares the same starting contract. It can obscure intent for API tests or cases that need a blank page, an alternate domain, or navigation-event setup. A domain fixture such as homePage often communicates better than changing the meaning of every page. Choose the narrowest abstraction that makes tests clearer.

4. Wrap an Inherited Context Without Taking Ownership

Override context when every context in a specialized suite needs setup such as cookies, permissions, or routing. Depend on the inherited context and do not close it yourself.

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

export const test = base.extend({
  context: async ({ context }, use) => {
    await context.setExtraHTTPHeaders({
      'x-test-client': 'playwright',
    });

    await use(context);
  },
});

export { expect };

Tests importing this export get a context whose subsequent requests include the header. Confirm that the target allows the header and that it does not bypass production controls. Do not put secrets directly in source code or expose them through trace artifacts.

Wrapping the inherited context preserves Playwright's configured browser context options, including viewport, locale, storage state, base URL behavior, permissions, and project device descriptors. Replacing it with browser.newContext() requires you to reproduce any options you still need, and missed options can create subtle differences. Prefer wrapping when modifying a ready context is supported.

Some settings must be supplied when the context is created. Those should be configured through use options or by overriding the relevant option fixture before context creation, not applied late. For example, storage state is consumed to construct a context. If dynamic state is required, override storageState, as shown next.

5. Override storageState for Dynamic Authentication

storageState is an option fixture consumed by the built-in context fixture. Overriding it can supply cookies and local-storage origins dynamically, before a context is created.

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

async function fetchSessionToken(): Promise<string> {
  const token = process.env.TEST_SESSION_TOKEN;
  if (!token) throw new Error('TEST_SESSION_TOKEN is required');
  return token;
}

export const test = base.extend({
  storageState: async ({}, use) => {
    const token = await fetchSessionToken();

    await use({
      cookies: [
        {
          name: 'session',
          value: token,
          domain: 'example.com',
          path: '/',
          httpOnly: true,
          secure: true,
          sameSite: 'Lax',
          expires: -1,
        },
      ],
      origins: [],
    });
  },
});

export { expect };

The shape uses supported storage-state fields, but the cookie name, domain, security attributes, and token acquisition must match the application. Many systems cannot create a valid browser session from one cookie alone. Use an authentication API or setup project that follows the product's actual security model.

Dynamic auth has cost and expiry considerations. A test-scoped override may fetch a token for every test. If tokens are independent and reusable for a worker, a worker-scoped account fixture can provision credentials, while the test-scoped storage state derives from them. Never share one mutable account across parallel tests merely because cookie creation is easy.

Storage state can contain sensitive tokens. Do not log the value, attach it to reports, or commit generated files. Review Playwright global setup examples when deciding between setup projects, stored state, and fixture-based authentication.

6. Replace Page Only When You Need a Different Object

Wrapping an inherited page is the safest default. A full replacement is appropriate when the specialized test needs a page created in a custom way that cannot be expressed through options. In that case, the override creates and closes the resource.

// fixtures/replacement-page-test.ts
import { test as base, expect, type Page } from '@playwright/test';

type AppFixtures = {
  page: Page;
};

export const test = base.extend<AppFixtures>({
  page: async ({ browser }, use) => {
    const context = await browser.newContext({
      locale: 'en-US',
      colorScheme: 'dark',
    });
    const page = await context.newPage();

    try {
      await use(page);
    } finally {
      await context.close();
    }
  },
});

export { expect };

This override owns the context and page, so closing the context after use is correct. It bypasses the inherited context fixture and any project context options not explicitly reproduced. That includes device descriptors, storage state, permissions, timezone, video settings, and other policies. The apparent simplicity can be expensive.

Usually, put locale and colorScheme in project use configuration and retain the built-in page. If only a few tests need dark mode, create a named project or a separate fixture such as darkPage rather than changing the standard page contract.

Never depend on both inherited page and create a replacement without understanding that the inherited resource will still be set up even if unused. Fixture dependencies drive setup. Keep the dependency list minimal so lazy execution remains effective.

7. Override Custom Fixtures Through Specialized Layers

Overrides are not limited to built-ins. A base fixture can expose an application role, and a specialized test can replace that value for an admin suite. Layering keeps role intent out of individual tests.

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

type AppFixtures = {
  role: 'member' | 'admin';
};

export const test = playwright.extend<AppFixtures>({
  role: async ({}, use) => {
    await use('member');
  },
});

export { expect };
// fixtures/admin-test.ts
import { test as memberTest, expect } from './base-test';

export const test = memberTest.extend({
  role: async ({}, use) => {
    await use('admin');
  },
});

export { expect };
// tests/admin-role.spec.ts
import { test, expect } from '../fixtures/admin-test';

test('uses the admin role contract', async ({ role }) => {
  expect(role).toBe('admin');
});

This example is intentionally value-oriented. In a real suite, a role fixture may depend on an account and authenticated page. Keep the type broad enough at the base layer to accept supported specializations. TypeScript will reject an incompatible override.

Avoid a deep chain such as base -> web -> authenticated -> regional -> admin -> billing unless each layer has a clear consumer. Many layers make the effective fixture hard to locate. Prefer a small base plus a few domain exports, or compose fixture objects when that style is already established. File names should reveal the contract, and tests should import from one obvious module.

8. Distinguish test.use Values From Fixture Overrides

test.use supplies option values for a file or describe. test.extend defines or overrides fixture implementation and lifecycle. Choosing the simpler mechanism prevents unnecessary custom code.

// tests/mobile.spec.ts
import { test, expect } from '@playwright/test';

test.use({
  locale: 'fr-FR',
  colorScheme: 'dark',
  viewport: { width: 390, height: 844 },
});

test('uses configured browser context options', async ({ page }) => {
  await page.goto('https://playwright.dev');
  await expect(page).toHaveTitle(/Playwright/);
});

No lifecycle logic is required, so this does not need a fixture override. The values affect built-in fixture creation. Project use is better when the profile should be named, reported, and reused across files.

Use an override when a value must be computed through async setup, when behavior surrounds resource use, or when a different object should be supplied. Examples include acquiring an account, starting transaction cleanup, attaching logs after failure, or dynamically producing storage state.

Be careful with test.use inside describe scopes when worker-scoped fixture values change. Playwright may need separate workers for different worker fixture signatures. Keep configuration stable and visible. Do not call test.use inside a test body. Fixture and option configuration is declared while tests are collected, not during execution.

Question Use config or test.use Use test.extend override
Is the input a supported context option? Yes Usually no
Does setup need async work? No Yes
Is teardown required? No Yes
Must a custom object be exposed? No Yes
Should a recurring profile appear as a project? Project use Only for lifecycle behavior

9. Preserve Scope, Laziness, and Automatic Behavior

Fixtures are test-scoped by default and set up only when requested by a test, hook, or dependent fixture. Worker-scoped fixtures use tuple syntax with { scope: 'worker' } and are reused within a worker. Automatic fixtures use { auto: true } and run even when tests do not name them. An override should preserve or deliberately change these properties.

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

type AutoFixtures = {
  failureNote: void;
};

export const test = base.extend<AutoFixtures>({
  failureNote: [async ({}, use, testInfo) => {
    await use();

    if (testInfo.status !== testInfo.expectedStatus) {
      await testInfo.attach('failure-context', {
        body: Buffer.from(`retry=${testInfo.retry}`),
        contentType: 'text/plain',
      });
    }
  }, { auto: true }],
});

export { expect };

The void fixture calls use() without a value. It attaches small diagnostic text only after an unexpected outcome. Avoid secrets, full environment dumps, or unlimited logs. The supported testInfo.attach API copies attachment data for reporters.

Do not make every override automatic. Automatic fixtures add work to all tests in the specialized test object and can hide setup from function signatures. Reserve them for cross-cutting instrumentation or mandatory policy. Domain page objects and data factories should remain lazy so a test receives only what it declares.

Changing a fixture from test to worker scope changes isolation and teardown timing. Never do that only to reduce setup duration. Prove the value is safe to reuse after tests mutate the application and after a worker handles a failure.

10. Handle Teardown, Errors, and Ownership Correctly

Ownership answers who closes, deletes, or resets a resource. If an override wraps an inherited page or context, the inherited fixture owns it. The override may undo modifications it added, but it should not close the underlying object. If the override calls browser.newContext, creates a temporary directory, or provisions a record, the override owns and cleans that resource.

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

type RecordFixtures = {
  recordId: string;
};

export const test = base.extend<RecordFixtures>({
  recordId: async ({ request }, use) => {
    const create = await request.post('https://example.com/api/test-records', {
      data: { name: `pw-${Date.now()}` },
    });
    if (!create.ok()) throw new Error(`Record setup failed: ${create.status()}`);

    const { id } = await create.json() as { id: string };

    try {
      await use(id);
    } finally {
      const remove = await request.delete(`https://example.com/api/test-records/${id}`);
      if (!remove.ok() && remove.status() !== 404) {
        console.warn(`Record cleanup failed: ${remove.status()}`);
      }
    }
  },
});

export { expect };

The endpoint is illustrative and must exist in the target. Setup checks response status before exposing an ID. Teardown tolerates an already-deleted record but reports other cleanup failures. In a sensitive system, send diagnostics to a safe logger rather than printing response bodies.

Cleanup failure policy is contextual. Throwing can fail an otherwise passed test, which is correct when leaked data threatens future reliability. Logging may be appropriate when a separate janitor guarantees removal. Decide deliberately and monitor leaks. Use unique identifiers stronger than Date.now() when parallel processes could collide, such as a UUID from the platform or a server-generated ID.

11. Compose Overrides Without Import Confusion

The specialized test object is the carrier of fixture behavior. Importing expect from another module is usually harmless because expect does not carry fixtures, but importing test directly from @playwright/test bypasses all custom definitions. Standardize imports.

A simple repository layout is:

fixtures/
  base-test.ts
  member-test.ts
  admin-test.ts
tests/
  member-profile.spec.ts
  admin-console.spec.ts

Keep base-test.ts free of application actions at module load time. Fixture setup runs through the runner, but top-level code runs during test discovery and can execute more often than expected. Do not fetch tokens, open databases, or read mutable remote state while importing the module. Put async work inside a fixture factory.

When two fixture modules need composition, prefer a deliberate shared base or supported fixture merging pattern used consistently by the repository. Avoid copying definitions because later fixes will diverge. Do not create circular imports between test exports. Type-check the entire suite after changing fixture generics because a narrow focused test may not import every affected module.

A lightweight lint rule can forbid direct Playwright test imports below folders that require the custom base. Code review should also confirm that setup, configuration, and test files import the intended layer. Playwright fixture typing for QA teams provides more TypeScript organization patterns.

12. Review Playwright test fixtures override Before Adoption

Review an override with this checklist:

  1. State whether it wraps an inherited fixture or creates a replacement.
  2. Confirm dependencies are minimal and correctly typed.
  3. Call await use exactly once.
  4. Put owned-resource cleanup after use, protected by finally where required.
  5. Do not close an inherited page or context.
  6. Preserve project options unless replacement is intentional and documented.
  7. Choose test, worker, or automatic scope from isolation needs, not speed alone.
  8. Ensure every consumer imports the specialized test.
  9. Prevent secrets from entering logs, storage-state files, traces, or attachments.
  10. Test setup failure, body failure, teardown failure, retry, and parallel execution.
  11. Run TypeScript checking, lint, and a focused suite.
  12. Prefer a named domain fixture when overriding a built-in would surprise most tests.

Also measure conceptual cost. A three-line fixture that silently navigates every page can make hundreds of tests shorter, but it can also hide starting state and block alternate journeys. An explicit homePage fixture may be slightly more verbose and much clearer.

Use the narrowest specialized test export that provides a coherent contract. If nearly every suite needs the same stable behavior, promote it to the repository base. If only one feature needs it, keep it close to that feature and avoid globally redefining a familiar built-in.

Interview Questions and Answers

Q: How do you override a Playwright fixture?

Create a new test object with base.extend and define the same fixture name. Perform setup, call await use(value) exactly once, and clean owned resources afterward. Tests must import that new test object.

Q: What is the difference between wrapping and replacing page?

A wrapper depends on inherited page, adds behavior, and lets the built-in fixture own cleanup. A replacement depends on browser or another lower-level fixture, creates a page or context, and closes what it created. Replacement can bypass project context options.

Q: When would you override storageState?

When authentication state must be produced dynamically before the context is created. The override returns a valid storage-state object through use. I protect tokens, match the application's real cookie or origin model, and avoid sharing mutable accounts across parallel tests.

Q: Why not use beforeEach for all fixture behavior?

Fixtures are lazy, composable, typed, and colocate teardown with setup. A beforeEach hook runs for every test in its scope and exposes state through outer variables or repeated helper calls. Hooks remain useful for simple suite behavior, but resources with dependencies and cleanup fit fixtures better.

Q: Who closes an overridden context?

If I wrap the inherited context, Playwright's base fixture owns and closes it. If my override creates a new context through browser.newContext, my override closes it after use. Ownership follows creation.

Q: How are test.use and test.extend different?

test.use supplies option values to existing fixture implementations. test.extend defines new fixture lifecycle or overrides an implementation. I use configuration for static context options and an override for async setup, teardown, or a different object.

Q: What happens if a test imports test from @playwright/test?

It receives only the built-in base and bypasses custom fixtures and overrides. TypeScript may reveal missing custom fixture names, but a built-in override such as page can be silently missed. I standardize and lint imports.

Q: How do you test fixture teardown?

I verify success, assertion failure, setup failure after partial creation, retry, and worker interruption behavior where practical. I inspect the external system for leaked resources and make cleanup idempotent. Parallel runs prove one test cannot delete another's data.

Common Mistakes

  • Importing the base Playwright test in a file that expects an override.
  • Forgetting await use(value) or calling it more than once.
  • Closing an inherited page or context that the base fixture owns.
  • Replacing context and silently losing device, locale, storage, trace, or video options.
  • Doing network authentication at module import time instead of fixture setup time.
  • Making an expensive fixture automatic when only a few tests need it.
  • Moving mutable accounts to worker scope only to reduce runtime.
  • Logging storage state or session tokens in setup errors.
  • Using test.extend when a simple project use value is sufficient.
  • Hiding navigation in page when many tests need different starting routes.
  • Ignoring cleanup failures until leaked data makes later tests flaky.
  • Creating deep fixture inheritance chains with no obvious final contract.

Conclusion

Playwright test fixtures override is powerful because it changes a dependency at one controlled boundary. Wrap inherited resources when you only need setup around them, replace objects only when necessary, pass values through await use, and clean only what the override owns.

Start with one narrow specialized test export and a focused consumer. Prove typing, imports, parallel isolation, failure cleanup, and preserved project options before making the override a shared foundation for the suite.

Interview Questions and Answers

Describe the Playwright fixture override lifecycle.

Playwright resolves dependencies and runs setup before `await use`. The value passed to use is available to the test or dependent fixture. After the consumer finishes, teardown resumes, and dependencies tear down in reverse order.

How would you override page to start at baseURL?

I extend the base test, depend on inherited `page` and `baseURL`, validate that baseURL exists, navigate, and call `await use(page)`. I do not close page because the inherited fixture owns it.

What risks come with replacing context?

A new context can omit project options such as device descriptors, storage state, permissions, locale, timezone, and recording policy. It also changes cleanup ownership. I prefer configuration or wrapping unless replacement is essential.

How do you make a dynamic auth override parallel-safe?

I allocate independent accounts or tokens according to test or worker scope, produce storage state without logging secrets, and make teardown or expiry reliable. One shared cookie does not guarantee server-side isolation.

When should a fixture be automatic?

Only for mandatory cross-cutting behavior such as failure diagnostics or policy enforcement. Domain helpers should remain lazy and visible in the test signature. Automatic setup adds cost and can hide dependencies.

How do fixture overrides support specialization?

A base test defines a typed contract, and a specialized test extends it with a compatible implementation, for example an admin role. Consumers import the specialized export. I keep layers shallow so engineers can find the effective fixture.

How would you prevent tests from bypassing custom fixtures?

I provide one obvious import module per suite area and add a lint restriction against direct `@playwright/test` test imports there. Type checking catches missing custom names, while review and lint catch silent built-in overrides.

What fixture failure paths do you verify?

I cover dependency failure, partial setup, test assertion failure, teardown failure, retry, and parallel cleanup. I check both the report and the external system for leaks. Cleanup is idempotent and never removes another test's resource.

Frequently Asked Questions

How do I override the Playwright page fixture?

Create an extended test and define `page: async ({ page }, use) => { ... }`. Add setup around the inherited page, call `await use(page)`, and let the built-in fixture close it.

Can I replace the Playwright page with a new page?

Yes. Depend on `browser`, create a context and page, pass the page to `use`, and close the context afterward. Remember that this can bypass project context options unless you reproduce them.

How do I override Playwright storageState?

Define a `storageState` fixture override that asynchronously obtains valid state and passes `{ cookies, origins }` or a supported value to `use`. The built-in context consumes it during creation.

Should an overridden fixture call use more than once?

No. A fixture factory must call and await `use` exactly once. Setup belongs before it and teardown belongs after it.

What is the difference between test.use and overriding a fixture?

`test.use` changes option values for existing fixture behavior. An override created with `test.extend` changes lifecycle, performs async setup or teardown, or supplies a different resource.

Why is my Playwright fixture override not running?

The test probably imported `test` from the wrong module, or the fixture is lazy and no consumer requested it. Import the specialized test export, and use `{ auto: true }` only when cross-cutting behavior must run for every test.

Who should close resources in a fixture override?

The layer that creates a resource owns its cleanup. Do not close inherited page or context objects, but do close a replacement context, temporary server, file, or record that the override created.

Can I override my own custom Playwright fixture?

Yes. Extend the custom test object and define the same fixture name with a compatible type. This is useful for specialized role or environment layers, provided the inheritance chain remains easy to follow.

Related Guides