skip to content

In Playwright, how do you stop a page.route() handler from applying for the rest of a test?

level: middleimportance: should knowfreq 42%

answer

  1. A handler can retire itself
  2. One option counts the matches served
  3. Removal needs the value you registered
  4. The bulk clear takes a behavior option
  5. Fresh page per test handles cleanup

basics

~10 s

Playwright removes handlers with page.unroute(matcher, handler) for one registration or page.unrouteAll() for all of them, and passing a times option to page.route() retires a handler automatically after that many matches.

solid answer

~40 s

There are three tools. `page.route(matcher, handler, { times: 1 })` retires the handler once it has served that many matching requests, which is the tidy way to make only the first forecast call behave differently. `page.unroute(matcher, handler)` removes a specific registration; the matcher must be the value you registered with, and omitting `handler` drops every handler for that matcher. `page.unrouteAll()` clears the page's handlers in one call and takes a `behavior` option: `'wait'` waits for in-flight handler calls to finish, `'ignoreErrors'` does not wait and swallows errors those handlers throw afterwards, and the default neither waits nor swallows. `context.unroute()` and `context.unrouteAll()` do the same at context level. With the default fixtures each test gets a fresh page, so unrouting is for changing behaviour mid-test, not for cleanup.

code

typescript · 19 lines
typescript
import { test, expect } from '@playwright/test';

test('refresh replaces the stale reading', async ({ page }) => {
  // Registered first, so it serves whatever the handler below does not.
  await page.route('**/v1/forecast*', route =>
    route.fulfill({ json: { tempC: 21 } }));

  // Registered last and capped at one match: it wins, once.
  await page.route('**/v1/forecast*', route =>
    route.fulfill({ json: { tempC: 4 } }), { times: 1 });

  await page.goto('/dashboard');
  await expect(page.getByTestId('temp')).toHaveText('4');

  await page.getByRole('button', { name: 'Refresh' }).click();
  await expect(page.getByTestId('temp')).toHaveText('21');

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

go deeper

for a junior

Remember that a route stays active until you remove it or the test ends, and that page.route takes a times option when you only want the stub to answer the first call.

for a middle

Explain the three removal paths, the times option, page.unroute for one registration and page.unrouteAll for the lot, and why what you pass to unroute must be what you registered.

for a senior

Know the unrouteAll behavior values and why they exist: a handler still running when its route disappears can throw into a finishing test and produce a flake nobody can reproduce.

for a principal

Decide whether the suite manipulates routes mid-test at all. Handlers that appear and vanish are hard to reason about, and one stable stub set per test buys readability at the cost of some setup duplication.

## A handler outlives the call that installed it `page.route()` registers a handler and returns; the handler stays live for the rest of the page's life, answering every matching request. That is usually what you want, and occasionally exactly what you do not: a test that wants the first forecast call to fail and the retry to succeed needs the stub to stop applying part-way through. Playwright gives three ways to arrange that, and they suit different situations. ## The times option: a self-retiring handler `page.route(matcher, handler, { times: 1 })` limits how many matching requests the handler will serve. Once the budget is spent, the registration is removed automatically and later requests fall through to whatever else matches — another handler, or the real network. The interaction with precedence matters. Because the most recently registered matching handler is consulted first, a limited handler must be registered **after** the general one it is meant to pre-empt: 1. Register the steady-state stub for the endpoint. 2. Register the `{ times: 1 }` handler for the same endpoint. 3. The limited handler serves the first call, retires itself, and every later call reaches the steady-state stub. Registered the other way round, the general handler is consulted first, always takes the request, and the limited one never runs at all. ## unroute: removing one registration `page.unroute(matcher, handler)` removes a registration made with `page.route()`. Two details decide whether it does anything: - The **matcher** must be the one you registered with. A predicate function is compared by identity, so an inline arrow written a second time is a different value and removes nothing. - The **handler** is optional. Omit it and every handler registered for that matcher goes; pass it and only that exact function reference is removed, which again means an inline arrow cannot be un-registered later. The practical advice is to keep patterns and handlers in named constants when you intend to remove them, and to reach for the bulk clear otherwise. `context.unroute()` does the same job for context-level registrations; a page-level call cannot remove a context-level handler. ## unrouteAll and its behavior option `page.unrouteAll()` removes all of the page's route handlers in one call, and `context.unrouteAll()` does the same for the context. The interesting part is the `behavior` option, which decides what happens to handler calls that are still in flight when the routes disappear. | `behavior` | Waits for running handlers? | Errors thrown afterwards | |---|---|---| | `'default'` | no | surfaced | | `'wait'` | yes | surfaced | | `'ignoreErrors'` | no | swallowed | That option exists because a handler in the middle of reading a fixture file or awaiting something slow can finish *after* its route has been removed and throw into a test that is already winding down — a flake with no obvious cause. Use `'wait'` when the handler's work matters, `'ignoreErrors'` when you are tearing down and genuinely do not care. ## Test isolation does most of the work With the built-in `page` fixture every test gets a fresh page in a fresh context, so route handlers never leak from one test into the next. Removing routes is therefore about **changing behaviour mid-test**, not about cleanup. A `test.afterEach` full of `unroute` calls is usually a sign that someone is sharing a context they did not need to share. ## Choosing between the three - Use `{ times: n }` when the shape of the test is "the first call behaves differently" — it is declarative and cannot be forgotten. - Use `page.unroute()` when a specific stub must go away at a specific moment and the rest must stay. - Use `page.unrouteAll()` when the test has finished with interception entirely and you want a clean slate, picking the `behavior` that matches whether in-flight handlers matter. - Prefer registering an **additional** handler over removing one: since the newest matching handler is consulted first, adding a stub is usually a smaller and more readable change than deleting one. ## A worked example A weather dashboard test wants to prove that a stale reading is replaced when the user hits Refresh. Register the fresh payload first, then a `{ times: 1 }` handler with the stale payload; assert the stale value on load, click Refresh, and assert the fresh one. Nothing is unrouted mid-test, the ordering is visible in five lines, and a final `page.unrouteAll({ behavior: 'ignoreErrors' })` is optional rather than load-bearing.

  • Why does page.unroute() sometimes fail to remove the handler you meant?
    Removal is keyed on what you pass. A predicate matcher is compared by identity, and if you also pass a handler it must be the identical function reference, so an inline arrow written a second time removes nothing. Keep matchers and handlers in named constants, or clear everything with `page.unrouteAll()`.
  • When is the 'wait' behavior of page.unrouteAll() worth paying for?
    When a handler is mid-flight and its work matters, for example while it reads a fixture file. `{ behavior: 'wait' }` lets it finish before the routes disappear. Use `'ignoreErrors'` when you are tearing down and genuinely do not care that a running handler throws once its route is gone.

saying these in an interview costs you the question

  • Thinks every test must unroute its handlers at the end.
  • Assumes times limits total requests rather than handler invocations.
  • Expects page.unroute to remove a context.route registration.
  • Thinks unrouteAll waits for running handlers by default.
  • Believes an inline arrow can be unrouted by re-declaring it.
  • Registers a capped handler before the general one it should pre-empt.