When I need a UI test to be reliable, I usually start by asking a simple question: do I really want to test this dependency right now? A lot of flaky test problems come from the same place, the browser is doing its job, but the test is waiting on an API, an auth provider, or a third-party service that is outside my control. That is where Playwright network interception becomes one of the most practical tools in my toolbox.

I use network interception to shape what the browser sees without changing the application code. That means I can stabilize tests, simulate edge cases, and keep a tight boundary around what the test is actually validating. If I want to test the login UI, I do not need a real OAuth server. If I want to test an empty state, I do not need the backend to return empty data on demand. If I want to see how the app behaves when a payment provider times out, I do not need a real outage.

In this tutorial, I will walk through how I use Playwright to intercept requests in Playwright, when I mock API responses in Playwright, and where I avoid overusing interception because it can hide real integration problems.

Why network interception matters in test automation

Network interception sits between the browser and the network stack. In Playwright, that usually means page.route() or browserContext.route(). From a testing point of view, this is valuable because it lets me control the exact response the page receives, inspect outbound requests, or even fail calls on purpose.

That matters for three common reasons:

  1. Auth flows are usually hard to control, especially if tokens expire, redirect chains vary, or a provider adds an extra challenge.
  2. Backend responses are often nondeterministic, for example timestamps, paging, feature flags, or race conditions around eventual consistency.
  3. Third-party dependencies are unreliable for tests, even if they are reliable in production.

A test that depends on an external system is not automatically bad, but it should be a conscious choice. Interception gives me the option to choose.

Before I get into examples, one practical note, network interception is not a replacement for backend tests or contract tests. It is a way to keep browser tests focused, fast, and deterministic. That is especially useful in CI/CD, where unstable tests have a habit of burning time and trust.

The basic Playwright route pattern

The most common interception pattern in Playwright is to define a route and decide what to do based on the request URL.

import { test, expect } from '@playwright/test';
test('mocks the users API', async ({ page }) => {
  await page.route('**/api/users', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Ada Lovelace' },
        { id: 2, name: 'Grace Hopper' }
      ])
    });
  });

await page.goto(‘http://localhost:3000/users’); await expect(page.getByText(‘Ada Lovelace’)).toBeVisible(); });

This is the core of most of my Playwright route tutorial work. The page makes a request, Playwright intercepts it, and I decide whether to fulfill, continue, or abort it.

The three main actions are:

  • route.fulfill(), return a response directly
  • route.continue(), send the request onward
  • route.abort(), stop the request completely

Each one is useful in a different situation.

When I mock API responses in Playwright

I usually mock API responses in Playwright when the UI logic is what I care about, not the real backend implementation. That includes cases like:

  • rendering lists, tables, or cards
  • showing empty states and error states
  • verifying form validation after a successful save
  • testing pagination, filters, and sorting
  • simulating data that is hard to create in a real environment

If the backend response is simple, stable, and not the subject of the test, mocking saves a lot of noise.

Example, success and error states

import { test, expect } from '@playwright/test';
test('shows an error message when the profile API fails', async ({ page }) => {
  await page.route('**/api/profile', async route => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ message: 'Server error' })
    });
  });

await page.goto(‘http://localhost:3000/profile’); await expect(page.getByRole(‘alert’)).toContainText(‘Server error’); });

This is a good example of a test that would be awkward to write against a live backend. I would have to force the server to fail, keep the failure isolated, and make sure it does not affect other tests. Interception is cleaner.

Example, dynamic responses based on request payload

Sometimes I need to inspect the request body and return a response that matches the scenario.

typescript

await page.route('**/api/search', async route => {
  const request = route.request();
  const postData = request.postDataJSON();

if (postData.query === ‘empty’) { await route.fulfill({ status: 200, contentType: ‘application/json’, body: JSON.stringify([]) }); return; }

await route.fulfill({ status: 200, contentType: ‘application/json’, body: JSON.stringify([{ id: 1, title: ‘Result’ }]) }); });

This pattern is useful when I want to test filtering, search, or form submission behavior without needing multiple backend fixtures.

Intercepting auth requests without fighting the identity provider

Auth testing is one of the strongest use cases for Playwright network interception. Real login flows often involve redirects, CSRF tokens, cookies, token exchange, refresh logic, and third-party identity providers. That is a lot of moving parts for a UI test if the actual login service is not the thing under test.

I usually split auth testing into two categories:

  1. Testing the login UI itself, including redirects, validation, and post-login behavior
  2. Testing authenticated application behavior, where the app should already have a valid session

For the second category, I often bypass the login screen completely by seeding storage state or stubbing the auth endpoints. For the first category, I may intercept the identity-related network calls and return controlled responses.

Example, stubbing an auth token exchange

typescript

await page.route('**/oauth/token', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      access_token: 'test-access-token',
      refresh_token: 'test-refresh-token',
      expires_in: 3600,
      token_type: 'Bearer'
    })
  });
});

That lets me exercise the front end logic without depending on a live identity system. If the app stores tokens in memory, local storage, or cookies, I can follow up with assertions that the correct post-login view appears.

Example, handling a failed login

typescript

await page.route('**/api/login', async route => {
  await route.fulfill({
    status: 401,
    contentType: 'application/json',
    body: JSON.stringify({ message: 'Invalid credentials' })
  });
});

This helps me verify that the app shows the right error message, disables repeated submits, or keeps the user on the correct page.

A practical warning about auth interception

I avoid mocking everything around authentication if the goal is to validate a real session boundary. For example, if I want to test that an expired token gets refreshed correctly, I may need a more realistic setup with one intercepted refresh endpoint and a real app session model. Over-mocking auth can hide bugs in cookie scope, redirect handling, or token refresh timing.

Intercepting third-party calls without making tests brittle

Third-party calls are often the source of the worst test flakes. Analytics, feature flag SDKs, chat widgets, maps, payment processors, email preview services, and CAPTCHA providers all tend to introduce noise.

For browser tests, I usually decide whether the dependency is:

  • essential to the feature under test
  • orthogonal to the feature under test
  • impossible to make deterministic in a CI environment

If it is orthogonal, I intercept it or block it.

Example, aborting a nonessential analytics request

typescript

await page.route('**/analytics/**', route => route.abort());

Sometimes I do this because the call is irrelevant. Sometimes I do it because the SDK retries aggressively and slows the test down. Either way, I am reducing noise.

Example, mocking a feature flag service

typescript

await page.route('**/api/flags', async route => {
  await route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify({
      newCheckout: true,
      betaSidebar: false
    })
  });
});

This is especially useful when I want to verify both code paths of a feature flag without waiting for a real rollout.

Page-level vs context-level interception

One detail that matters more than people expect is the scope of interception.

  • page.route() applies to a single page
  • browserContext.route() applies across all pages in that context

I use page-level routing when the mock is specific to one test case. I use context-level routing when the same dependency should be stubbed for every page in a suite or helper.

import { test } from '@playwright/test';
test('shared mock across pages', async ({ browser }) => {
  const context = await browser.newContext();

await context.route(‘**/api/session’, async route => { await route.fulfill({ status: 200, contentType: ‘application/json’, body: JSON.stringify({ user: { id: 42, name: ‘Test User’ } }) }); });

const page = await context.newPage(); await page.goto(‘http://localhost:3000’); });

My default is to keep routing as local as possible. Broad interception can become hard to reason about when tests grow.

Verifying the request, not just the response

A mistake I see in a lot of tests is that they only stub the response and never inspect the request. That misses a whole class of bugs, especially in forms and API integrations.

If I am testing a save action, I want to verify the payload too.

typescript

await page.route('**/api/todos', async route => {
  const body = route.request().postDataJSON();
  expect(body).toEqual({ title: 'Buy milk' });

await route.fulfill({ status: 201, contentType: ‘application/json’, body: JSON.stringify({ id: 123, title: ‘Buy milk’ }) }); });

This catches subtle issues like missing fields, wrong names, incorrect nested objects, or the wrong content type.

If a test never validates the request shape, it can pass even while the frontend sends broken data.

Common patterns I use in real suites

Here are the patterns I reach for most often.

1. Fixture-driven mocks

For stable endpoints, I keep JSON fixtures in files and load them in the route handler. This keeps test code short and makes the mock payload easier to review.

2. Inline mocks for one-off edge cases

For a single scenario, I define the mock inline so the test tells the story clearly.

3. Abort noisy dependencies

For noncritical third-party scripts, I abort requests instead of pretending they succeed.

4. Hybrid tests

For some tests, I keep the app real but intercept only one specific dependency, such as a payment provider or address lookup service.

This hybrid approach is often the sweet spot. It gives me confidence in the integration points that matter while keeping the test environment manageable.

What interception cannot solve

Network interception is powerful, but I do not use it as a shortcut for every test problem.

It cannot reliably prove:

  • the backend really stores data correctly
  • the auth provider really issues valid sessions
  • a third-party API actually works as expected
  • the production integration contract is correct end to end

That is why I still like a layered strategy, unit tests for logic, API tests for backend contracts, and a smaller number of browser tests with targeted interception. That structure matches the basic ideas of software testing, test automation, and continuous integration, where each layer catches different classes of failure.

Playwright interception in CI/CD

In CI/CD, network interception usually pays for itself quickly because it reduces test variance. Flaky tests are expensive not just because they fail, but because they make builds harder to trust.

A few habits help a lot:

  • keep mocks deterministic
  • avoid depending on wall-clock time in mocked responses
  • scope routes tightly to the test
  • clean up or isolate shared context state
  • prefer readable fixtures over overly clever dynamic handlers

When a test fails in CI, I want the failure to point to the application behavior, not to a random timeout from a payment widget or identity service.

Here is a simple GitHub Actions example showing where these tests usually live in a pipeline.

name: playwright-tests

on: [push, pull_request]

jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npm test

I would still keep a smaller set of true integration tests that hit real services, but I do not want every browser test tied to live infrastructure.

Debugging interception issues

When interception does not behave the way I expect, I usually check four things:

  1. Did the route pattern match the actual URL? Wildcards are powerful, but they can be too broad or too narrow.
  2. Was the route registered before navigation? If not, the request may already be gone.
  3. Is another route handler intercepting first? Order matters.
  4. Is the app calling a different host, protocol, or path than I assumed?

A small logging helper can save time.

typescript

await page.route('**/*', async route => {
  console.log(route.request().method(), route.request().url());
  await route.continue();
});

I would not leave a catch-all route in a normal suite, but it is very useful when diagnosing an unexpected request.

A few rules of thumb I follow

If I had to reduce my Playwright network interception approach to a short checklist, it would be this:

  • intercept only the dependencies that make the test flaky or hard to control
  • mock responses when the UI logic is the real subject
  • verify requests as well as responses
  • keep auth interception focused, not overbroad
  • block or abort nonessential third-party requests
  • retain a smaller set of end-to-end tests that hit real systems

The biggest mistake is treating interception as a way to make every test green. The real goal is to make the right tests trustworthy.

Final thoughts

I rely on Playwright network interception because it gives me a clean way to separate browser behavior from external volatility. It is one of the fastest ways I know to stabilize tests around auth, APIs, and third-party calls without turning the suite into a slow integration maze.

When I mock API responses in Playwright, I keep the test focused. When I intercept requests in Playwright, I can inspect requests, simulate failure modes, and control the environment in a way that is hard to achieve with live services. And when I use a Playwright route tutorial approach like the examples above, I usually end up with tests that are easier to debug, cheaper to run, and much less flaky.

If you are building browser automation for a real product, this is one of those techniques that pays off quickly, especially once your app depends on more than one backend, more than one auth path, or more than one third-party service.