Practice API Testing Network monitoring
Lecture 3
Lecture 3 . Network monitoring + mocking

Observe the network before the UI lies to you

Reliable Playwright tests are network-aware tests. We will intercept with page.route(), fulfil deterministic payloads, modify requests in flight, abort to simulate offline, listen for real responses with page.waitForResponse(), capture every XHR / fetch, replay traffic with HAR files, and exercise two TTACart slow / error scenarios end-to-end.

01Why network matters in UI testsmental model

UI assertions answer what did the user see? Network assertions answer why did the user see it? A mature suite needs both. When the right endpoint is stubbed, the right payload asserted, and the right response traced, flaky UI checks become precise regression signals.

ApproachUse it whenTrade-off
Stub everythingDeterministic UI states, edge-case payloadsMisses integration drift
Observe onlyReal backend already trustedSlower, depends on data seeding
HybridReal auth, stub one slow / risky endpointBest blend - default for TTACart
flowchart LR
  T[test()] --> B[browser]
  B -->|navigates| APP[app.thetestingacademy.com]
  APP -->|fetch /api/products| API[backend]
  T -.intercepts.-> R[page.route]
  R -->|fulfill| MOCK[deterministic JSON]
  R -->|continue| APP2[real backend]
  R -->|abort| OFF[network failed]
  R -.listen.-> L[page.on response]
page.route sits between the browser and the real network. Four exit doors.
Rule of thumb. Mock the edges, never the core. Stub third-party APIs, analytics, slow reports. Let your own primary endpoints stay real in pre-prod and only stub them for failure-path tests in CI.

02page.route - the interceptorinterceptor

page.route(pattern, handler) registers an interceptor before navigation. When the browser fires a matching request the handler runs and decides whether to fulfill, continue, or abort. Routes are scoped per page (or per context via context.route).

page.route context.route route.fulfill route.continue route.abort
tests/route-basics.spec.ts
import { test, expect } from '@playwright/test';

test('every request flows through the handler', async ({ page }) => {
  await page.route('**/api/products*', async (route) => {
    console.log('intercepted', route.request().method(), route.request().url());
    await route.continue();
  });

  await page.goto('/products');
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

The handler runs once per matching request. Always exit through one of the three terminal calls (fulfill, continue, abort). If you forget, the request hangs until the test times out.

browser fetch page.route handler async (route) => { ... } must exit through one of: fulfill continue abort "forget the exit, the test hangs"
Always exit through fulfill / continue / abort. Three terminal calls, no other way out.

03Glob vs RegExp patternsmatching

The first argument to page.route is either a glob string or a JavaScript RegExp. Globs are short, regexes are precise. Pick the one that fails fast when an unrelated request slips through.

PatternMatchesDoes not match
**/api/*/api/products, /api/users/1/api/v2/products
**/api/**Anything under /api/Non-api paths
**/api/products?*/api/products?page=1/api/products (no query)
/\/api\/(products|orders)/Two named endpoints onlyAnything else
tests/patterns.spec.ts
// Glob - the common case
await page.route('**/api/products*', handler);

// Regex - precise, multi-endpoint
await page.route(/\/api\/(products|orders|cart)$/, handler);

// All same-origin XHR / fetch
await page.route(/\/api\//, handler);

// Catch everything in /api but exclude images
await page.route('**/api/**', async (route) => {
  if (route.request().resourceType() === 'image') return route.continue();
  await route.fulfill({ json: { mocked: true } });
});
Greedy globs bite. A pattern like **/* intercepts every request including HTML, CSS, fonts, and tracking pixels. Aim narrow first, broaden only when needed.

04route.fulfill - the mock responsestubbing

route.fulfill({ status, headers, body / json }) returns a fake response and the browser never reaches the real backend. Useful for deterministic UI states, edge-case payloads, and failure simulation.

tests/fulfill-products.spec.ts
import { test, expect } from '@playwright/test';

test('product list renders deterministic items', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      headers: { 'Cache-Control': 'no-store' },
      body: JSON.stringify([
        { id: 1, name: 'TTA Notebook',  price: 299 },
        { id: 2, name: 'TTA Mug',       price: 149 },
        { id: 3, name: 'TTA Sticker',   price:  49 },
      ]),
    });
  });

  await page.goto('/products');
  await expect(page.getByRole('listitem')).toHaveCount(3);
  await expect(page.getByText('TTA Mug')).toBeVisible();
});

The shape of fulfill options

FieldTypeDefaultPurpose
statusnumber200HTTP status code
contentTypestringnoneShortcut for Content-Type header
headersobject{}Response headers
bodystring / BufferemptyRaw body bytes
jsonobjectnoneShortcut - sets body + Content-Type
pathstringnoneReads from local fixture file
sequenceDiagram
  participant T as test
  participant P as page (browser)
  participant H as route handler
  participant S as real server

  T->>P: goto /products
  P->>H: GET /api/products
  alt fulfill (mock)
    H-->>P: 200 { fake products }
    Note over S: server never called
  else continue (real)
    H->>S: GET /api/products
    S-->>H: 200 { real products }
    H-->>P: 200 { real products }
  end
  P-->>T: products visible
fulfill short-circuits the network. continue lets the real call through.

05route.continue - modify in flightman-in-the-middle

route.continue({ url, headers, method, postData }) forwards the request to the real server but lets you swap the URL, add headers, or rewrite the body first. Perfect for injecting a tenant header, a trace id, or an auth token without touching the page code.

tests/continue-headers.spec.ts
test('inject a trace header on every API call', async ({ page }) => {
  await page.route('**/api/**', async (route) => {
    const headers = {
      ...route.request().headers(),
      'X-Trace-Id': 'tta-network-test',
      'X-Test-Run': 'pr-1234',
    };
    await route.continue({ headers });
  });

  await page.goto('/products');
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

Body rewrite example

tests/continue-body.spec.ts
// Force the API to always sort by price ascending, regardless of UI.
await page.route('**/api/products*', async (route) => {
  const url = new URL(route.request().url());
  url.searchParams.set('sort', 'price_asc');
  await route.continue({ url: url.toString() });
});
continue vs fulfill. Use continue when the response data must still come from the real backend. Use fulfill when the response data itself is what the test cares about.

06route.abort - simulate offlinefailure path

route.abort('failed' | 'timedout' | 'internetdisconnected') cancels the request with a chosen network error. Great for testing skeleton loaders, retry banners, and "you appear to be offline" UI.

tests/abort-offline.spec.ts
test('shows the offline banner when /api/products fails', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.abort('internetdisconnected');
  });

  await page.goto('/products');
  await expect(page.getByTestId('offline-banner')).toBeVisible();
  await expect(page.getByText(/check your connection/i)).toBeVisible();
});
Error codeUI effectWhen to use
failedGeneric error toastDefault failure path
timedoutLoading spinner staysTime-out behaviour
internetdisconnectedOffline bannerOffline UX testing
connectionrefusedServer unreachableMaintenance window UI
nameresolutionfailedDNS errorRegion failover testing

07waitForResponse / waitForRequestlisteners

Sometimes we keep the real network and just want to assert against the response. page.waitForResponse(predicate) and page.waitForRequest(predicate) return a promise that resolves with the captured object.

tests/wait-for-response.spec.ts
test('search returns at least one match', async ({ page }) => {
  await page.goto('/catalog');

  const responsePromise = page.waitForResponse(
    (r) => r.url().includes('/api/search') && r.status() === 200,
  );

  await page.getByPlaceholder('Search items').fill('notebook');
  await page.getByRole('button', { name: 'Search' }).click();

  const res = await responsePromise;
  const body = await res.json();
  expect(body.results.length).toBeGreaterThan(0);
});
Start the wait before the action. Set up waitForResponse then click. Reverse the order and the response may already be in flight before the listener attaches.

waitForRequest - assert what we send

tests/wait-for-request.spec.ts
test('add to cart POSTs the right body', async ({ page }) => {
  await page.goto('/products');

  const reqPromise = page.waitForRequest(
    (r) => r.url().includes('/api/cart') && r.method() === 'POST',
  );

  await page.getByRole('button', { name: 'Add to cart' }).first().click();
  const req = await reqPromise;
  const body = JSON.parse(req.postData() || '{}');
  expect(body).toMatchObject({ productId: 1, quantity: 1 });
});

08Capture every XHR / fetchobservability

For broad observability we listen to page.on('request') and page.on('response'). Filter by resourceType() to stay sane.

tests/capture-xhr.spec.ts
test('checkout fires exactly the calls we expect', async ({ page }) => {
  const calls: { method: string; url: string; status?: number }[] = [];

  page.on('request', (req) => {
    const t = req.resourceType();
    if (t === 'xhr' || t === 'fetch') {
      calls.push({ method: req.method(), url: req.url() });
    }
  });
  page.on('response', (res) => {
    const match = calls.find((c) => c.url === res.url() && c.status === undefined);
    if (match) match.status = res.status();
  });

  await page.goto('/cart');
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByRole('button', { name: 'Place order' }).click();

  const apis = calls.filter((c) => c.url.includes('/api/'));
  const urls = apis.map((c) => `${c.method} ${new URL(c.url).pathname}`);
  expect(urls).toEqual([
    'GET /api/cart',
    'POST /api/orders',
    'GET /api/orders/confirm',
  ]);
});
page.on('request') / ('response') - global listeners resourceType: xhr | fetch | document | stylesheet | script | image | font filter by url + resourceType narrow signal store + assert at end no listener leaks
Listen broad, filter narrow, assert at the end. Same recipe every time.

09TTACart - mock a slow /api/productsuse case

In the TTACart demo we want to prove that the product page shows a skeleton spinner while the API takes more than 200 ms. We do not need the real backend - we just need a slow stub.

tests/ttacart-slow.spec.ts
import { test, expect } from '@playwright/test';

test.use({ baseURL: 'http://localhost:5173/playwright/ttacart/' });

test('skeleton appears while products load slowly', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    // simulate a slow backend
    await new Promise((r) => setTimeout(r, 800));
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'TTA Notebook', price: 299 },
        { id: 2, name: 'TTA Mug',      price: 149 },
      ]),
    });
  });

  await page.goto('/index.html');

  // Skeleton first
  await expect(page.getByTestId('products-skeleton')).toBeVisible();

  // Real items second
  await expect(page.getByText('TTA Notebook')).toBeVisible({ timeout: 2000 });
  await expect(page.getByTestId('products-skeleton')).toBeHidden();
});

Two assertions, in order: the loading state is reached, then the loaded state is reached. The slow stub is the easiest way to test that transition without flake.

10TTACart - mock a 500 errorfailure UI

Now we want to prove that when /api/products returns 500 the page shows an error card with a retry button. Same stub recipe, different payload.

tests/ttacart-500.spec.ts
test('error card shown when product API fails', async ({ page }) => {
  let attempt = 0;

  await page.route('**/api/products', async (route) => {
    attempt += 1;
    if (attempt === 1) {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ message: 'internal error' }),
      });
    } else {
      // second attempt succeeds - we test retry recovery too
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([{ id: 1, name: 'TTA Notebook', price: 299 }]),
      });
    }
  });

  await page.goto('/index.html');

  await expect(page.getByRole('alert')).toContainText('Something went wrong');
  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByText('TTA Notebook')).toBeVisible();
  expect(attempt).toBe(2);
});
sequenceDiagram
  participant U as user (test)
  participant P as TTACart page
  participant R as route handler

  U->>P: open /index.html
  P->>R: GET /api/products
  R-->>P: 500 internal error
  P->>U: render error card
  U->>P: click Retry
  P->>R: GET /api/products (attempt #2)
  R-->>P: 200 { products }
  P->>U: render products list
Drive the error then drive the recovery - one route handler, two responses.
Counter trick. A simple closure counter in the handler lets you script different responses per attempt. No fancy mock library required.

11HAR record + replaypage.routeFromHAR

A HAR file is a JSON archive of every request and response in a session. Record one real run, save it, then replay the network on every subsequent run. Fast and deterministic without writing dozens of stub handlers.

Record once

tests/har-record.spec.ts
// Record - run once against the real backend
test('record HAR', async ({ page }) => {
  await page.routeFromHAR('fixtures/products.har', {
    update: true,
    url: '**/api/**',
  });
  await page.goto('/index.html');
  await expect(page.getByText('TTA Notebook')).toBeVisible();
});

Replay forever

tests/har-replay.spec.ts
// Replay - default mode, no real network needed
test('replay HAR offline', async ({ page }) => {
  await page.routeFromHAR('fixtures/products.har', {
    update: false,
    url: '**/api/**',
    notFound: 'abort',
  });
  await page.goto('/index.html');
  await expect(page.getByText('TTA Notebook')).toBeVisible();
});
OptionEffect
update: trueRecord the HAR (or refresh on re-run)
update: falseReplay only - no network calls
urlGlob filter for which requests to replay
notFound: 'abort'Abort any unmocked request - fail loud
notFound: 'fallback'Hit the real network if no HAR entry
HAR rot. When the API contract changes, the HAR ages out. Refresh it by re-running the recording test, or you will fossilise old responses into your suite.

12Common pitfallsdebug checklist

SymptomLikely causeFix
Route never firesRegistered after navigation startedCall page.route before page.goto
Test hangs at the endHandler forgot to fulfill / continue / abortAlways exit through one of the three
Wrong endpoint matchedGreedy glob - **/*Tighten to **/api/products*
waitForResponse times outListener attached after request firedSet the listener before the click
HAR mismatch errorsStale recording vs new contractRe-record with update: true
Real backend hit by mistakeroute.continue() still falling throughUse fulfill for true mocks
graph TB
  R[route registered] -->|before goto| OK[handler runs]
  R -->|after goto| MISS[request never intercepted]
  OK -->|fulfill| DONE[mocked response]
  OK -->|continue| REAL[real backend]
  OK -->|abort| FAIL[browser sees network error]
  OK -->|forgets to exit| HANG[test hangs]
The five exits from a route handler. Pick one every time.
Continue from here. Hands-on practice in the interception playground at network/intercept.html, then return for the practice drills.