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.
Approach
Use it when
Trade-off
Stub everything
Deterministic UI states, edge-case payloads
Misses integration drift
Observe only
Real backend already trusted
Slower, depends on data seeding
Hybrid
Real auth, stub one slow / risky endpoint
Best 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).
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.
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.
Pattern
Matches
Does 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 only
Anything 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.
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.
// 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.
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 waitForResponsethen click. Reverse the order and the response may already be in flight
before the listener attaches.
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.
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.
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();
});
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
Symptom
Likely cause
Fix
Route never fires
Registered after navigation started
Call page.route before page.goto
Test hangs at the end
Handler forgot to fulfill / continue / abort
Always exit through one of the three
Wrong endpoint matched
Greedy glob - **/*
Tighten to **/api/products*
waitForResponse times out
Listener attached after request fired
Set the listener before the click
HAR mismatch errors
Stale recording vs new contract
Re-record with update: true
Real backend hit by mistake
route.continue() still falling through
Use 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.
DDrills - ten network exercises
All drills target the TTACart demo at
http://localhost:5173/playwright/ttacart/index.html. Try each on your own
first, then peek at the hint.
1Fulfil a 3-item products list
Register a route on **/api/products that fulfils with three TTA
items. Assert exactly three <li> elements render.
Hint
Use route.fulfill({ json: [...] }) as a shortcut - it sets Content-Type for you.
2Empty list, empty state
Stub the same endpoint with an empty array. Assert the page shows the empty-state
message and a "Browse categories" link.
Hint
Two assertions: toBeVisible() on the empty card, toHaveCount(0) on the list.
3Glob vs regex
Write the same intercept twice: once with a glob, once with a regex. Assert both
variants intercept the same number of requests in a session.
Hint
Use a counter in each handler and compare at the end with expect(globCount).toBe(regexCount).
4Inject a trace header
Use route.continue({ headers }) to add X-Trace-Id to
every /api/ request. Verify the header reaches the server with
waitForRequest.
Hint
Spread the existing headers first: { ...route.request().headers(), 'X-Trace-Id': 'x' }.
5Abort one image, keep the rest
Abort only requests to /static/hero.png with
'failed'. Assert the fallback image is shown instead.
Hint
Filter inside the handler: if (!route.request().url().includes('hero.png')) return route.continue().
6Slow API + skeleton
Delay the products response by 700 ms. Assert the skeleton component is visible
before the data, and hidden after.
Hint
Wait inside the handler with await new Promise(r => setTimeout(r, 700)).
7500 + retry recovery
Return 500 on the first attempt and 200 on the second. Assert that the Retry
button works and that the error banner disappears.
Hint
Closure counter inside the handler - increment, branch on attempt === 1.
8Capture all XHR calls
Walk the cart-to-checkout flow and capture every XHR / fetch with
page.on('request'). Assert the exact ordered list of API paths.
Hint
Filter on resourceType() equals 'xhr' or 'fetch'.
9Record + replay HAR
Record one HAR for the products page, then re-run the test with
update: false. Confirm the run still passes with no real network.
Hint
Use notFound: 'abort' on replay - any missing URL will fail loudly.
10Assert no real backend call
Set up a full mock for products, then attach a page.on('request')
listener and fail the test if any request escapes to https://api.real.
Hint
expect(realCalls).toEqual([]) at the end of the test.
SSolutions - end-to-end network spec
The complete recipe in three languages. TypeScript is canonical; the Java and Python
snippets show the same flow with their respective Playwright clients.