How to fix Playwright route mocking that does not intercept requests?

Playwright

Route mocking fails silently when the URL pattern doesn't match what the browser actually requests, or when page.route() is registered after the navigation that triggers the requests. The most common symptom is that the real API is hit instead of the mock — the test still passes or fails based on the live service's response, making mock setup invisible failures. This is especially common with query strings, full origin specifications, or resource types that Playwright doesn't intercept by default.

Common mistake

test('shows filtered products', async ({ page }) => {
  // Route registered before goto — this part is correct
  await page.route('/api/products', (route) =>
    route.fulfill({ body: JSON.stringify([{ id: 1, name: 'Widget' }]) })
  );

  await page.goto('https://app.example.com/products');
  // But the app requests /api/products?category=all&page=1 — pattern doesn't match
  await expect(page.getByText('Widget')).toBeVisible();
});

Also broken when the base URL is missing from a pattern that includes the origin:

// Pattern includes full origin — but `page.route` uses glob patterns, not regex
await page.route('https://api.example.com/products', handler);
// Might not match if the app uses a trailing slash or query params

The fix

Use wildcard patterns that tolerate query strings and path variations, and confirm patterns before committing them:

import { test, expect } from '@playwright/test';

test('shows product list from mock', async ({ page }) => {
  // ** matches any path segment, * matches within a segment
  await page.route('**/api/products**', async (route) => {
    // Inspect the actual request for debugging
    const url = route.request().url();
    console.log('Intercepted:', url);

    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Widget A', price: 9.99 },
        { id: 2, name: 'Widget B', price: 14.99 },
      ]),
    });
  });

  await page.goto('https://app.example.com/products');
  await expect(page.getByRole('listitem')).toHaveCount(2);
  await expect(page.getByText('Widget A')).toBeVisible();
});

For selective interception using a predicate function:

await page.route(
  (url) => url.pathname === '/api/products' && url.searchParams.has('category'),
  async (route) => {
    await route.fulfill({
      status: 200,
      body: JSON.stringify([{ id: 1, name: 'Category Widget' }]),
    });
  }
);

For route handlers that should pass through most requests but intercept specific ones:

await page.route('**/*.json', async (route) => {
  if (route.request().url().includes('/api/config')) {
    await route.fulfill({ body: JSON.stringify({ featureFlag: true }) });
  } else {
    await route.continue();
  }
});

Why it works

Playwright's page.route() uses glob patterns where ** matches any sequence of characters including path separators, and * matches any sequence without path separators. Appending ** to the end of a pattern (**/api/products**) ensures query strings and trailing slashes don't prevent the match. The predicate function form gives full control for complex matching logic. The handler must always call one of route.fulfill(), route.continue(), or route.abort() — any code path that doesn't call one of these leaves the browser waiting for a response that never comes.

Tips

  • Use page.on('request', req => console.log(req.url())) during test development to see the exact URLs the browser requests — compare them against your route pattern to find mismatches.
  • Every route handler code path must call fulfill, continue, or abort — unhandled routes cause requests to hang and tests to timeout.
  • For global mocks that apply across all tests, define them in a beforeEach hook or as a fixture rather than inside individual tests.
  • Route handlers registered later take priority over earlier ones — use this to override specific endpoints in individual tests while keeping a baseline mock in beforeEach.