A Playwright test stubs the forecast endpoint with page.route(), but the real API is still called. How do you find out why the handler never fired?
answer
- Look at the real URLs first
- Timing, then pattern, then scope
- A relative pattern inherits a base host
- Something else may answer the request
- Register the logger last to see everything
basics
~20 sLog the real URLs with a catch-all page.route that falls back, then compare them to your matcher. Usual causes: the route was registered after page.goto, the pattern misses the host or query string, or a Service Worker answers the request.
solid answer
~40 sWork from evidence, not from the pattern. Register a temporary catch-all last, so precedence consults it first, log `route.request().method()` and `route.request().url()`, then call `route.fallback()` so the suite behaves as before. With the real URLs in hand, walk the short list of causes: the handler was installed after the `page.goto()` that started the request; the glob did not begin with `**/` and never got past the host, or had no trailing `*` and a cache-busting query string broke it; a relative matcher was merged with `baseURL` and so only covered your own origin, not the third-party forecast host; a `{ times: 1 }` budget was already spent; a later registration shadowed the handler; or a Service Worker served the response from cache, which routes do not intercept unless the context sets `serviceWorkers: 'block'`.
code
typescript · 14 linesimport { test } from '@playwright/test';
test('forecast panel renders stubbed data', async ({ page }) => {
await page.route('**/v1/forecast*', route =>
route.fulfill({ json: { tempC: 21 } }));
// Registered last, so it is consulted first: log, then hand the request on.
await page.route('**/*', async route => {
console.log(route.request().method(), route.request().url());
await route.fallback();
});
await page.goto('/dashboard');
});go deeper
The first move is to look at the URL the app really requested and compare it with your pattern character by character. Most misses are a query string or a missing leading wildcard.
Explain the ordering rule that makes this debuggable: a catch-all registered last is consulted first, so it can log and then defer. Also know that routes must exist before the navigation that triggers the request.
Show a systematic sweep, timing then pattern then scope then budget then shadowing then Service Worker, instead of guessing, and say how you would make the same failure loud rather than silent next time.
Push the class of bug out of the suite: block Service Workers by default, fail a run that contacts an unstubbed third-party host, and standardise where route patterns live so they are reviewed like production code.
## Get evidence before you edit the pattern The instinct when a stub does not fire is to start mutating the glob. Do the opposite: find out what the page actually requested. Register a catch-all handler **last**, so precedence puts it first, and let it log and then step aside: ```ts await page.route('**/*', async route => { console.log(route.request().method(), route.request().url()); await route.fallback(); }); ``` `route.fallback()` hands the request to the next matching handler, so the suite behaves exactly as before while you read the real URLs. Nine times out of ten the answer is visible in that log: a query string you did not allow for, a host you did not expect, or the request happening before your handler existed. ## The causes, in the order worth checking 1. **Timing.** The route was registered after the `page.goto()` (or the click) that started the request. Registration is not retroactive; the request has already gone. 2. **Pattern shape.** The glob does not begin with `**/`, so it never gets past the scheme and host, or it has no trailing `*` and a cache-busting query string breaks the match. 3. **baseURL merging.** A relative matcher such as `'/v1/forecast'` is merged with the context's `baseURL`, so it matches only your own origin — never the third-party forecast host. 4. **Scope.** The request comes from a popup or a page the test did not register on. Page-scoped handlers cover that page and its frames only; `context.route()` covers the whole context. 5. **Budget.** A `{ times: n }` handler already spent its allowance earlier in the same test. 6. **Shadowing.** A later registration matches the same URL and takes the request without falling back, so the handler you are watching is never consulted. 7. **Something else answered.** A Service Worker served the response from its cache, and routes do not intercept that unless the context sets `serviceWorkers: 'block'`. ## Pattern shape, concretely | Pattern | `https://api.weather.example/v1/forecast?city=oslo` | Why | |---|---|---| | `'/v1/forecast'` | no | merged with `baseURL`, so it means your own origin | | `'*/v1/forecast'` | no | a single `*` does not cross a `/` | | `'**/v1/forecast'` | no | the query string is not covered | | `'**/v1/forecast*'` | yes | host-agnostic and query-tolerant | | `/\/v1\/forecast\b/` | yes | unanchored regex test against the whole URL | Reading the table row by row against the logged URL is faster than another round of guessing, and it makes the fix obvious rather than lucky. ## The Service Worker case This one is worth recognising by its signature: the stub fires on a cold run and stops firing once the app has been loaded a few times, or it fires in one browser project and not another. A Service Worker sits between the page and the network, and a response it serves from cache never becomes a request the route can see. Setting `serviceWorkers: 'block'` in the project or context options removes the whole class of nondeterminism from an intercepting suite. ## Confirming the diagnosis - **Narrow to the smallest reproduction**: one test, one route, one navigation, and a `console.log` in the handler so you can see whether it is entered at all. - **Compare the logged URL and the pattern character by character** — trailing slash, port, protocol, encoded characters in the query. - **Move the registration up** above the navigation and rerun; if it starts working, the cause was timing, not the pattern. - **Swap the matcher for a predicate** that logs and returns `false`, to see exactly which URLs are being offered to the matcher. - **Check for a second registration** on the same endpoint elsewhere in the file or in a fixture the spec imports. ## Preventing the next one Make the failure loud instead of silent. A deny-all handler for the third-party host, registered before the specific stubs so they still win, turns "the real API was quietly called" into a failed request you can see in the report. Keep route patterns in one exported module so a rename cannot leave a stale string behind. Block Service Workers by default in any project that intercepts. And prefer asserting on the stubbed content — a temperature the real API would never return — so a stub that stops firing fails the assertion instead of passing on live data that happens to look plausible.
- How would you prove that a Service Worker, not your matcher, is eating the request?Set `serviceWorkers: 'block'` in the context or project options and rerun. If the stub suddenly fires, the worker was serving the response from cache and the route never saw a request. That option is worth making the default in any suite that intercepts, since worker caching makes routing nondeterministic between runs.
- A stub fires on the first run but not on a retry. Which routing causes would you suspect?State that outlives the request rather than the test: a times budget already spent by an earlier call, a handler unrouted mid-test, or an app that only calls the endpoint when its own cache is cold. Retries start from a fresh page and context, so per-test state is what to inspect first.
saying these in an interview costs you the question
- Adds a wait before the assertion instead of checking the URL.
- Assumes a route registered after page.goto still catches the load.
- Thinks a glob without leading wildcards matches any host.
- Forgets that a query string breaks an exact path pattern.
- Never considers a Service Worker answering from cache.
- Blames flakiness rather than reading the requested URL.