skip to content

In Playwright, how do you override the built-in page fixture without losing the original?

level: middleimportance: must knowfreq 57%

answer

  1. You do not need a new name
  2. Match an existing fixture key exactly
  3. The dependency is not recursive
  4. The base value arrives under the same name
  5. Pass the same object to use

basics

~20 s

Define a fixture with the same name in test.extend and destructure that same name in its first argument. Playwright passes the original value in, so you can configure it and then hand the very same object to use.

solid answer

~40 s

Overriding is by **name**: `base.extend({ page: async ({ page }, use) => { ... } })` replaces the built-in `page` for everything that imports the extended `test`. The `page` you destructure is not a new object and not a recursive reference to your own fixture — it is the value the fixture you are overriding would have provided. So an override is really a wrapper: configure or instrument the incoming value, `await use(page)` to hand the same object to the test, then undo your changes on the lines below. The same mechanic works for any fixture name, including option fixtures such as `storageState`. Overrides compose: `base.extend(a).extend(b)` gives `b` whatever `a` provided, so layers stack in declaration order. Nothing leaks to files that import the plain `@playwright/test` object.

code

typescript · 21 lines
typescript
import { test as base, expect } from '@playwright/test';

export const test = base.extend({
  page: async ({ page }, use) => {
    await page.route('**/admin/api/feature-flags', route =>
      route.fulfill({ json: { staffRolesV2: true } }),
    );
    await page.goto('/admin');

    await use(page);

    await page.unrouteAll({ behavior: 'ignoreErrors' });
  },
});

test('the staff list opens on the roles tab', async ({ page }) => {
  await expect(page.getByRole('tab', { name: 'Roles' })).toHaveAttribute(
    'aria-selected',
    'true',
  );
});

go deeper

for a junior

Recall that you override a built-in by defining a fixture with exactly the same name in test.extend, and that you must pass the value along to use or the tests get nothing at all.

for a middle

Explain that the override receives the base value under the same name in its first argument, so it wraps rather than replaces, and that chained extend calls stack in declaration order.

for a senior

Talk about blast radius: every file importing that test object now goes through your page. Keep the override cheap and predictable, and undo whatever it installs on the lines after use.

for a principal

Own the call about what deserves a global override versus an opt-in fixture, because an override applies to every test that names it and no test can decline it.

## Overriding is by name, not by wrapping There is no special API for replacing a built-in. You define a fixture whose **key matches the name of an existing fixture**, and from then on any test that resolves that name through your extended `test` object gets your version: ```typescript import { test as base } from '@playwright/test'; export const test = base.extend({ page: async ({ page }, use) => { // configure the incoming page await use(page); // undo it }, }); ``` Naming the fixture `adminPage` instead would **add** a fixture rather than override one, and tests asking for `page` would still get the untouched built-in. ## The base value arrives in the dependencies The `page` destructured in the first argument looks recursive and is not. When Playwright resolves a fixture's dependency by the same name as the fixture itself, it supplies **the value from the definition being overridden** — the built-in here, or the previous override in a chain. That is what makes an override a wrapper instead of a replacement: 1. The base definition runs and produces its value. 2. Your override receives it, does its own setup on it, and calls `await use(...)`. 3. Your teardown lines run first, then the base definition's teardown. ## Add versus override | | New fixture | Override | |---|---|---| | Key in `test.extend` | a name nobody uses yet | the exact name of an existing fixture | | Opt in per test | yes — only tests that name it | no — it applies to every test using that name | | Base value available | only if you destructure it by its own name | yes, under the same name | | Blast radius | the tests that ask for it | every file importing this `test` object | ## Hand back the same value, or hand back your own Most overrides call `await use(page)` with the object they were given, because the point is to *configure* it: install a route stub, set a default state, seed the console with a known signed-in role. You may also provide a different value entirely — a wrapper object, for instance — but then every test using that name gets your type, and the built-in one is gone from their reach. Points worth keeping in mind: - **Undo what you install.** Anything the override sets up on the incoming value belongs in the teardown half, below `use`. - **Keep it cheap.** Every test in every file that imports this `test` pays for the override, including tests that would rather not have it. - **Keep it unsurprising.** A `page` that silently navigates somewhere makes every test in the suite start from a place its own code never mentions. - **It is not declinable.** A test cannot ask for the un-overridden version; if some tests must opt out, add a separate opt-in fixture instead of overriding. ## Overriding an option fixture The same by-name rule covers configuration fixtures, not just objects. Overriding `storageState` in `test.extend` changes which saved session the contexts of those tests start from, in exactly the way overriding `page` changes which page they get. The mechanic is identical: same key, value provided through `use`. ## Chained extends stack `base.extend(A).extend(B)` produces one `test` whose `page` is B's, and B's `page` dependency is A's `page`, whose dependency is the built-in. Setup runs outermost-last (built-in, then A, then B); teardown unwinds in the reverse direction. That is what lets one package add tracing-ish instrumentation and another add a routing stub without either knowing about the other. ## In an internal admin console A useful override for a console with several staff roles is one that stubs the feature-flag endpoint so every test starts from a known flag set, then removes the route afterwards. It applies to the whole suite, costs one route registration per test, and does not change what any test's own code says.

  • Does overriding page also change the page that your other custom fixtures receive?
    Yes. Fixtures resolve their dependencies from the same graph, so any fixture in that extended `test` that destructures `page` receives the overridden value. That is usually what you want, and it is worth stating out loud when the override navigates or stubs anything.
  • What happens when two chained extend calls both override page?
    They layer. The outer `extend` receives the inner one's `page` as its dependency, so both setups run — inner first — and the teardowns unwind in reverse. Neither package has to know the other exists, which is what makes stacking safe.
  • How do you let some tests opt out of an override?
    You cannot decline an override; a test asking for that name gets it. If only part of the suite wants the behaviour, keep the built-in alone and add a separate opt-in fixture, or split the extended `test` object so only the relevant spec files import the wrapped one.

saying these in an interview costs you the question

  • Thinks overriding page means editing the Playwright package
  • Believes a renamed fixture such as myPage overrides page
  • Expects the same-name dependency to recurse infinitely
  • Forgets to pass the incoming value to use, so tests get nothing
  • Assumes an override reaches files importing the plain test object