Intercepting and Mocking Network Requests with route

9 min read

A test that runs only when the staging API is up isn't a test — it's an integration check that depends on a third party's reliability. The same goes for tests that need the database in a particular state, or tests of UI flows that only happen when the API returns a 500. Network interception is how you decouple your tests from the backend: Playwright sits between the browser and the network, and you decide whether each request goes through, returns mock data, or fails. This lesson is about page.route — Playwright's intercept-and-modify primitive — and the four core operations you'll use it for.

What page.route does

page.route(urlPattern, handler) registers a handler that runs before every matching request leaves the browser. The handler receives a Route object and decides what to do: continue, fulfill (with mock data), or abort. If no route is registered, requests go through untouched as normal.

await page.route("**/api/products", async route => {
  console.log("Intercepted:", route.request().method(), route.request().url());
  await route.continue();   // let it go through to the real server
});
 
await page.goto("/products");
// Every request to /api/products is logged before being sent

Three actions you can take inside the handler:

  • route.continue() — let the request proceed (optionally with modified headers/body).
  • route.fulfill(...) — short-circuit with a mock response. The real server is never called.
  • route.abort() — fail the request with a network error.

Plus a fourth, covered in the next lesson:

  • route.fetch() then route.fulfill(...) — let the request reach the real server, then modify the response before it gets to the app.

Spying — route.continue()

The simplest use case: log or assert on the requests your app makes, without changing them:

const requestUrls: string[] = [];
 
await page.route("**/api/**", async route => {
  requestUrls.push(route.request().url());
  await route.continue();
});
 
await page.goto("/dashboard");
await page.getByRole("button", { name: "Refresh" }).click();
 
// After the test runs, assert the app called the right endpoints
expect(requestUrls).toContain(expect.stringContaining("/api/orders"));
expect(requestUrls.length).toBeGreaterThan(2);

This is what the team would call "spying" — the requests still happen, but you observe them. Useful for catching N+1 query bugs, missing-endpoint regressions, or analytics events that should fire on specific actions.

Mocking — route.fulfill()

The headline use case: replace the real response with one you control. Now you can test edge cases the backend can't easily produce:

await page.route("**/api/products", async route => {
  await route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify([
      { id: 1, name: "Mock Wireless Headphones", price: 29.99 },
      { id: 2, name: "Mock USB Cable", price: 9.99 }
    ])
  });
});
 
await page.goto("/products");
await expect(page.getByText("Mock Wireless Headphones")).toBeVisible();

The cleaner shorthand for JSON: pass json directly:

await page.route("**/api/products", async route => {
  await route.fulfill({
    status: 200,
    json: [{ id: 1, name: "Mock product", price: 29.99 }]
  });
});

Mocking gives you complete control over the API surface — exact data, exact timing, exact status codes. The real server isn't called, so the test is fast, deterministic, and runs offline.

Mocking errors — testing the unhappy path

Most apps look polished when the API works. The bugs hide in the error states. Mock them:

test("shows a friendly error when products fail to load", async ({ page }) => {
  await page.route("**/api/products", async route => {
    await route.fulfill({
      status: 500,
      contentType: "application/json",
      json: { error: "Internal server error" }
    });
  });
 
  await page.goto("/products");
  await expect(page.getByRole("alert")).toContainText("Something went wrong");
  await expect(page.getByRole("button", { name: "Retry" })).toBeVisible();
});

The same trick works for 401 (auth expired), 403 (permission), 404 (not found), 429 (rate limited), 503 (maintenance). Each one lets you write a deterministic test for a state the backend can't easily trigger.

Simulating slow APIs

Sometimes the bug is a UX bug — "the spinner doesn't show because the API is too fast." Slow the response down:

test("shows loading spinner during slow API call", async ({ page }) => {
  await page.route("**/api/products", async route => {
    await new Promise(resolve => setTimeout(resolve, 3000)); // 3-second delay
    await route.fulfill({ status: 200, json: [] });
  });
 
  await page.goto("/products");
  await expect(page.getByTestId("loading-spinner")).toBeVisible();
  await expect(page.getByText("No products found")).toBeVisible({ timeout: 5_000 });
});

Combine this with route.fulfill({ status: 504 }) after a delay to test "spinner → timeout error" flows.

Aborting — testing offline scenarios

route.abort() makes the request fail with a network error, which is what happens when the user goes offline mid-flow:

test("handles network failure gracefully", async ({ page }) => {
  await page.route("**/api/orders", route => route.abort());
 
  await page.goto("/orders");
  await expect(page.getByRole("alert")).toContainText("Connection failed");
  await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
});

A common performance trick is aborting all images and fonts to make tests faster — the page logic runs identically without them:

await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,woff,woff2}", route => route.abort());

Use this in beforeEach or globally in playwright.config.ts for a 30-50% speedup on image-heavy pages, with no impact on functional assertions.

URL patterns — glob, regex, predicate

Three ways to match URLs:

// Glob — most readable, * = anything except /, ** = anything including /
await page.route("**/api/products", handler);
await page.route("**/api/users/*", handler);     // matches /api/users/42
await page.route("**/*.json", handler);
 
// Regex — for complex patterns
await page.route(/\/api\/products\?page=\d+/, handler);
 
// Predicate function — for conditional matching
await page.route(
  url => url.pathname.startsWith("/api/") && url.searchParams.get("admin") === "true",
  handler
);

Glob is the default. Regex when you need character-class precision. Predicate when the matching depends on parsed parts of the URL.

Three flavours of route handling

Continue, fulfill, abort — three things a Route can do

continue() — let it through

  • Real request goes to the server

  • Real response comes back to the app

  • Use for spying — log/assert on the requests

  • Or modify headers/body before forwarding

fulfill() — return mock data

  • Real server is NOT called

  • App receives whatever you put in the response

  • Test edge cases: empty arrays, errors, slow responses

  • Fully deterministic — runs offline

abort() — kill the request

  • Request fails with a network error

  • Use for: offline scenarios, blocking analytics, blocking images

  • Optional reason: 'connectionrefused', 'timedout'

  • Lets you test 'connection lost' UI without unplugging the network

Set the route BEFORE the action

The single most common mistake: registering a route after the request has already fired. Routes only intercept requests fired after the route is registered. So:

// ❌ Wrong — products are already loaded by the time the route is set
await page.goto("/products");
await page.route("**/api/products", route => route.fulfill({ json: [] }));
 
// ✅ Right — route first, then the action that triggers the request
await page.route("**/api/products", route => route.fulfill({ json: [] }));
await page.goto("/products");

Set every route before the goto (or before the click that triggers the request). For routes that should only apply to one specific action, set up the route just before that action.

Removing routes — page.unroute()

When a test needs different mocking behaviour after some point:

// First half of the test — empty list
await page.route("**/api/products", route => route.fulfill({ json: [] }));
await page.goto("/products");
await expect(page.getByText("No products")).toBeVisible();
 
// Now switch to populated list for the second half
await page.unroute("**/api/products");
await page.route("**/api/products", route => route.fulfill({ json: [{ id: 1, name: "Headphones" }] }));
await page.reload();
await expect(page.getByText("Headphones")).toBeVisible();

For most tests, you set routes once at the top and never unroute — but this escape hatch matters for tests that explicitly verify "the second call returns different data."

Coming from Cypress?

The mappings:

  • cy.intercept('GET', '/api/x', { fixture: 'x.json' })await page.route('**/api/x', route => route.fulfill({ json: fixtureData }))
  • cy.intercept('POST', '/api/x', { statusCode: 500 })await page.route('**/api/x', route => route.fulfill({ status: 500 }))
  • cy.intercept('/api/x').as('xx'); cy.wait('@xx')const responsePromise = page.waitForResponse('**/api/x'); /* trigger */; await responsePromise

The big difference: Cypress's cy.intercept matches by HTTP verb by default (GET, POST). Playwright's page.route matches all methods unless you check inside the handler:

await page.route("**/api/products", async route => {
  if (route.request().method() === "POST") {
    await route.fulfill({ status: 201, json: { id: 42 } });
  } else {
    await route.continue();
  }
});

For most teams the migration mainly means: set routes earlier (Cypress let you intercept after-the-fact in some cases; Playwright doesn't) and use route.fulfill instead of fixture aliases.

⚠️ Common mistakes

  • Setting the route after the request was already made. Routes only intercept future requests. If the page loaded products before you registered the route, the route never fires. Order is: route first, action second.
  • Forgetting to await inside the handler. page.route('...', route => route.fulfill({ ... })) (no async/await) leaves a floating promise. The handler returns synchronously, the framework warns, the test sometimes flakes. Mark the handler async and await route.fulfill(...).
  • Mocking everything, including auth and analytics, by accident. A wide pattern like page.route('**/api/**', ...) catches more than you mean. Be specific (**/api/products) or use a predicate to allow auth/analytics traffic through. The right policy is to mock only what the test depends on; let everything else go through.

🎯 Practice task

Build a route-aware spec that mocks four scenarios. 30 minutes.

  1. Pick any small app you control (or a public sandbox like JSONPlaceholder at https://jsonplaceholder.typicode.com). Set the baseURL accordingly.

  2. Create tests/route-mocking.spec.ts with four tests:

    import { test, expect } from "@playwright/test";
     
    test.describe("Network interception with route", () => {
      test("mock — empty list shows empty state", async ({ page }) => {
        await page.route("**/posts", route =>
          route.fulfill({ status: 200, json: [] })
        );
        await page.goto("https://jsonplaceholder.typicode.com/posts");
        const text = await page.locator("body").textContent();
        expect(text).toBe("[]");
      });
     
      test("mock — return one specific post", async ({ page }) => {
        await page.route("**/posts/1", route =>
          route.fulfill({
            status: 200,
            json: { id: 1, title: "MOCKED TITLE", body: "Hello from Playwright" }
          })
        );
        await page.goto("https://jsonplaceholder.typicode.com/posts/1");
        await expect(page.locator("body")).toContainText("MOCKED TITLE");
      });
     
      test("error — 500 response", async ({ page }) => {
        await page.route("**/posts/1", route =>
          route.fulfill({ status: 500, contentType: "text/plain", body: "Server error" })
        );
        const responsePromise = page.waitForResponse("**/posts/1");
        await page.goto("https://jsonplaceholder.typicode.com/posts/1");
        const response = await responsePromise;
        expect(response.status()).toBe(500);
      });
     
      test("abort — block image requests for speed", async ({ page }) => {
        const blocked: string[] = [];
        await page.route("**/*.{png,jpg,jpeg,gif,webp,svg}", route => {
          blocked.push(route.request().url());
          return route.abort();
        });
        await page.goto("https://www.saucedemo.com/v1/inventory.html", { waitUntil: "domcontentloaded" }).catch(() => {});
        // Even if the goto auth-fails, the abort still recorded any image attempts
        expect(blocked.length).toBeGreaterThanOrEqual(0);
      });
    });
  3. Run all four tests across all three browsers. The first three demonstrate fulfill for happy, custom, and error paths; the fourth demonstrates abort for performance.

  4. Demonstrate the late-route bug. In the second test, move the await page.route(...) line to after the goto. Run again — the mock no longer applies; you get the real response. Move it back. This is the muscle memory: route first, action second.

  5. Stretch: add a fifth test that uses a predicate matcher to only mock POST requests to /posts, leaving GETs alone. The handler should check route.request().method() and either fulfill (for POST) or continue (for GET). This is the pattern for testing form submission paths in real apps without affecting the read-only paths.

You now own three quarters of network mocking — continue, fulfill, abort. The next lesson covers the fourth: fetch + fulfill, the pattern for modifying a real response on its way through to the app. That's the one you'll reach for when "almost-real data" beats "fully synthetic data."

// tip to track lessons you complete and pick up where you left off across devices.