skip to content

How would you structure shared Playwright route stubs so individual tests can still override one endpoint?

level: principalimportance: should knowfreq 33%

answer

  1. Layer defaults under per-test overrides
  2. An auto fixture gives every test the baseline
  3. Precedence replaces unregistering
  4. Broad handlers are registered first
  5. One module owns the URL patterns

basics

~20 s

Register broad defaults with context.route in an auto fixture, and let a test override one endpoint with page.route, which is consulted first. Keep the URL patterns in one shared module so a default and its override cannot drift apart.

solid answer

~40 s

Lean on the precedence rule rather than on flags. Put the suite-wide stubs in an **auto fixture** that calls `context.route()` once per test, so every test starts from a known network baseline, including a deny-all guard for the third-party host that blocks anything the suite did not mean to contact. A test needing different data calls `page.route()` for that one endpoint: page scope is consulted before context scope, so the override wins without unregistering anything. Inside the baseline, register the broad deny-all **first** and the specific stubs after it, since later registrations are consulted first. Export the URL patterns from one module so a default and its override are the same string. The cost is invisible setup, so name the fixture for what it does and log anything the guard blocks.

code

typescript · 19 lines
typescript
import { test as base } from '@playwright/test';

export const FORECAST = '**/v1/forecast*';

export const test = base.extend<{ networkBaseline: void }>({
  networkBaseline: [async ({ context }, use) => {
    // Registered first: the deny-all fallback for the whole third-party host.
    await context.route('**/api.weather.example/**', async route => {
      console.log('blocked', route.request().url());
      await route.abort('blockedbyclient');
    });

    // Registered later, so it is consulted first for the forecast endpoint.
    await context.route(FORECAST, route =>
      route.fulfill({ json: { tempC: 21 } }));

    await use();
  }, { auto: true }],
});

go deeper

for a junior

The takeaway is that shared setup may already be stubbing the network for you, so read the fixtures before concluding that a request is unstubbed.

for a middle

Explain how an override works without removing anything: page-level registration is consulted before context-level, and later beats earlier, so a per-test stub simply outranks the shared one.

for a senior

Argue for the ordering inside the baseline itself, with the deny-all first so specific stubs registered after it still win, and for making blocked traffic visible rather than silently aborted.

for a principal

Own the tradeoff: a shared baseline buys determinism and one place to change an endpoint, and costs discoverability, since a failing test is answered by code its author never opened. Decide how much of the network the default layer owns.

## The mechanism the design rests on Everything here follows from one rule: when several handlers match a request, Playwright consults page-scoped handlers before context-scoped ones, and within a scope the most recently registered handler first. Nothing ranks patterns by specificity. That gives you a layering primitive for free — a broad default registered early is overridden by a narrow stub registered later, and by any page-scoped stub at all. ## Where the baseline lives Put the shared stubs in an **auto fixture** so no test has to remember to call anything: ```ts export const test = base.extend<{ networkBaseline: void }>({ networkBaseline: [async ({ context }, use) => { await context.route('**/api.weather.example/**', route => route.abort('blockedbyclient')); await context.route('**/v1/forecast*', route => route.fulfill({ json: { tempC: 21 } })); await use(); }, { auto: true }], }); ``` Two properties make this work. It is on the **context**, so per-test `page.route()` overrides outrank it by scope rather than by luck of ordering. And it is **auto**, so every test in the project starts from the same known network baseline instead of an ad-hoc mix of stubbed and live traffic. ## Ordering inside the baseline The deny-all goes **first** and the specific stubs after it, because later registrations are consulted first. Get that backwards and the deny-all shadows every stub you wrote below it — the failure mode looks like "all my stubs stopped working", with no error to explain it. | Registered | Consulted | Effect | |---|---|---| | deny-all for the host, then the forecast stub | forecast stub first | endpoint stubbed, everything else on that host blocked | | forecast stub, then deny-all for the host | deny-all first | every request blocked, including the stubbed endpoint | ## How a test overrides A test that needs different data registers `page.route()` for that one endpoint in its own body. It wins on scope, it does not unregister anything, and it disappears with the page at the end of the test. Three things keep that clean: - **Share the pattern, not a copy of it.** Export the URL pattern from one module so the default and the override are the same string and a rename cannot desynchronise them. - **Override the endpoint, not the fixture.** Adding a payload parameter to the baseline fixture makes every test carry a knob it does not use; a local `page.route()` keeps the special case local. - **Do not reach for `unroute`.** Removing the default in order to replace it is a bigger, order-sensitive change than simply registering a handler that outranks it. ## The costs you are accepting - **Invisible setup.** Someone debugging a failing test sees traffic answered by code they never opened. Name the fixture for what it does and keep it in a path the spec already imports. - **Silent blocking.** A deny-all that aborts without a word turns a missing stub into a confusing render failure. Log the blocked URL before aborting. - **Baseline drift.** Once every test depends on one payload, changing it becomes a cross-suite edit; keep the default minimal and let tests state the fields they actually assert on. - **Over-broad matching.** A baseline pattern wide enough to be convenient will eventually swallow an endpoint someone meant to exercise for real. ## Conventions worth writing down 1. One module owns the URL patterns; nothing else writes a route string literal. 2. Defaults are registered on the context in an auto fixture; overrides are registered on the page inside the test. 3. Broad handlers are registered before narrow ones, and any catch-all either logs and falls back or blocks loudly. 4. A project that is meant to talk to a real service opts out of the baseline explicitly, rather than being covered by it silently. ## The judgment call The real decision is how much of the network the default layer owns. A thin baseline — block the third-party host, stub the one endpoint every page needs — keeps tests readable and failures attributable. A thick one buys determinism at the cost of a suite whose behaviour lives somewhere nobody reads. Scope it to a project, review it like production code, and revisit it whenever a test's failure output stops explaining itself.

  • What is the downside of overriding a shared context.route() stub with another context.route() call?
    Nothing breaks, but the override now competes on registration order alone: it wins only because it ran later, so it is hostage to fixture ordering, and it applies to every page in the context including popups. Using `page.route()` makes the intent explicit and wins by scope instead.
  • How do you make a shared route baseline visible to whoever debugs a failing test?
    Name the fixture after what it does, keep the patterns in one exported module the spec already imports, and have the deny-all handler log the URL before it aborts. The goal is that any stubbed or blocked response is traceable to a line of code from the failure output alone.

saying these in an interview costs you the question

  • Unregisters the shared stub instead of registering an override.
  • Puts default stubs in a helper every test must remember to call.
  • Registers a catch-all last so it shadows every specific stub.
  • Assumes a narrower pattern automatically beats the shared default.
  • Lets a deny-all abort silently so blocked calls look like app bugs.