The Playwright Playbook — Part 2: Network Interception — The Complete Guide
"Your tests should own the network — not be at its mercy."
In Part 1, we fixed the foundation — proper selectors, storageState, POM, and a clean playwright.config.ts. If you haven't read that, start there. The project structure we built in Part 1 is what we're building on today.
Now we go to the feature that separates intermediate Playwright users from advanced ones.
Network interception.
Most people know page.route() exists. Most people use it once, mock one response, and move on.
But network interception is an entire testing strategy — not just a utility. When you fully understand it, you can:
- Test your frontend completely independently of the backend
- Simulate error states, timeouts, and edge cases in seconds
- Assert on exactly what API calls your app is making — and what it's sending
- Record real network traffic and replay it in tests without a live server
- Catch silent API failures that your UI swallows gracefully
This is the part where Playwright stops feeling like a UI testing tool and starts feeling like a full testing platform. 🚀
Let's build it. 🎯
🏗️ Where We Left Off
At the end of Part 1, our project looked like this:
playwright-playbook/
├── tests/
│ ├── auth/
│ │ └── login.spec.ts
│ └── tasks/
│ └── task-management.spec.ts
├── pages/
│ ├── LoginPage.ts
│ └── TaskPage.ts
├── .auth/
│ ├── admin.json
│ └── user.json
├── global-setup.ts
├── playwright.config.ts
├── .env
└── package.json
By the end of Part 2, we're adding a fixtures/ folder for mock data and a new network/ test folder:
playwright-playbook/
├── tests/
│ ├── auth/
│ ├── tasks/
│ └── network/ ← NEW
│ ├── api-mocking.spec.ts
│ ├── error-simulation.spec.ts
│ └── network-assertions.spec.ts
├── pages/
├── fixtures/ ← NEW
│ ├── tasks.json
│ └── empty-tasks.json
├── .auth/
├── global-setup.ts
├── playwright.config.ts
└── .env
🔍 How Playwright Network Interception Works
Before we write code — let's understand the architecture.
Every time your browser makes a network request, Playwright can intercept it at the network layer — before it ever reaches the server.
Browser Action (click "Load Tasks")
│
▼
Playwright Route Handler ← you control this
│
┌────┴────┐
│ │
▼ ▼
Fulfill Continue
(mock) (real server)
Three things you can do with an intercepted request:
- Fulfill — return a fake response you define (mock)
- Abort — kill the request entirely (simulate network failure)
- Continue — let it go through to the real server (passthrough, optionally modified)
That's the whole mental model. Everything we do in this part is a variation of those three options.
🧪 The Basic: page.route() — Your First Mock
Let's say our Task Manager app calls GET /api/tasks to load the task list. In most test setups, this requires a running backend with seeded data.
With network interception — we don't need that.
First, create your mock data file:
// fixtures/tasks.json
[
{ "id": 1, "title": "Write unit tests", "status": "pending", "assignee": "admin" },
{ "id": 2, "title": "Review pull request", "status": "completed", "assignee": "user" },
{ "id": 3, "title": "Fix flaky test in CI", "status": "pending", "assignee": "admin" }
]
Now intercept and mock the API call in your test:
// tests/network/api-mocking.spec.ts
import { test, expect } from '@playwright/test';
import tasks from '../../fixtures/tasks.json';
test('task list renders mocked data correctly', async ({ page }) => {
// Intercept BEFORE navigating to the page
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(tasks),
});
});
await page.goto('/tasks');
// Assert the UI rendered the mocked data
await expect(page.getByRole('listitem')).toHaveCount(3);
await expect(page.getByText('Write unit tests')).toBeVisible();
await expect(page.getByText('Review pull request')).toBeVisible();
});
Key rule: Always set up page.route() BEFORE page.goto(). The route handler needs to be registered before the page makes any requests. 🎯
❌ Simulating Error States
This is where network interception becomes genuinely powerful.
How do you test what happens when your API returns a 500? Or a 401? Or a timeout?
Without interception — you need to break your backend. With interception — one line.
// tests/network/error-simulation.spec.ts
import { test, expect } from '@playwright/test';
test('shows error message when API returns 500', async ({ page }) => {
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/tasks');
// Assert the app handles the error gracefully
await expect(page.getByTestId('error-banner')).toBeVisible();
await expect(page.getByText('Something went wrong')).toBeVisible();
// Task list should NOT render
await expect(page.getByTestId('task-list')).not.toBeVisible();
});
test('shows empty state when API returns 404', async ({ page }) => {
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Not found' }),
});
});
await page.goto('/tasks');
await expect(page.getByTestId('empty-state')).toBeVisible();
await expect(page.getByText('No tasks found')).toBeVisible();
});
test('shows loading skeleton when API is slow', async ({ page }) => {
await page.route('**/api/tasks', async route => {
// Delay the response by 3 seconds
await new Promise(resolve => setTimeout(resolve, 3000));
await route.continue();
});
await page.goto('/tasks');
// Loading skeleton should be visible while request is in flight
await expect(page.getByTestId('loading-skeleton')).toBeVisible();
});
test('handles network failure gracefully', async ({ page }) => {
await page.route('**/api/tasks', async route => {
// Completely abort the request — simulates no network
await route.abort('failed');
});
await page.goto('/tasks');
await expect(page.getByTestId('network-error')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
Think about what you just did. You tested four different failure scenarios — without touching a single line of backend code. Without spinning up a special error environment. Without asking a developer to temporarily break something. 💪
🔁 Modifying Requests on the Fly
Sometimes you don't want to fully mock a response. You want to intercept a real request, modify it slightly, and let it continue.
test('request modification — inject test headers', async ({ page }) => {
await page.route('**/api/tasks', async route => {
// Modify the request before it goes to the server
const headers = {
...route.request().headers(),
'x-test-mode': 'true',
'x-test-user': 'automation',
};
await route.continue({ headers });
});
await page.goto('/tasks');
// Test proceeds with modified headers going to real server
});
test('inject different query params', async ({ page }) => {
await page.route('**/api/tasks', async route => {
const url = new URL(route.request().url());
url.searchParams.set('status', 'completed');
await route.continue({ url: url.toString() });
});
await page.goto('/tasks');
// Only completed tasks should show — filtered at the API level
await expect(page.getByText('Review pull request')).toBeVisible();
await expect(page.getByText('Write unit tests')).not.toBeVisible();
});
📡 Asserting on Real Network Calls — waitForResponse
Everything so far has been about controlling the network. Now let's talk about observing it.
This is the pattern I use most in API-heavy applications — asserting that the correct API calls are made, with the correct payloads, at the correct times.
// tests/network/network-assertions.spec.ts
import { test, expect } from '@playwright/test';
import { TaskPage } from '../../pages/TaskPage';
test('creating a task makes the correct API call', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
// Set up the response listener BEFORE triggering the action
const createTaskResponse = page.waitForResponse(
response =>
response.url().includes('/api/tasks') &&
response.request().method() === 'POST' &&
response.status() === 201
);
// Trigger the action
await taskPage.createTask('Write integration tests');
// Wait for and capture the actual API response
const response = await createTaskResponse;
const responseBody = await response.json();
// Assert on the API response directly
expect(responseBody.title).toBe('Write integration tests');
expect(responseBody.status).toBe('pending');
expect(responseBody.id).toBeDefined();
// Also assert the UI updated
await expect(taskPage.getTaskLocator('Write integration tests')).toBeVisible();
});
test('deleting a task sends DELETE request with correct ID', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
// First create a task to get its ID
const createResponse = await page.waitForResponse(
resp => resp.url().includes('/api/tasks') && resp.request().method() === 'POST'
);
await taskPage.createTask('Task to be deleted');
const { id } = await (await createResponse).json();
// Now watch for the DELETE call
const deleteResponse = page.waitForResponse(
resp =>
resp.url().includes(`/api/tasks/${id}`) &&
resp.request().method() === 'DELETE'
);
await taskPage.deleteTask('Task to be deleted');
const deleted = await deleteResponse;
expect(deleted.status()).toBe(200);
});
test('assert request payload on task creation', async ({ page }) => {
const taskPage = new TaskPage(page);
await taskPage.goto();
let capturedRequestBody: Record<string, unknown> = {};
// Intercept to capture the request body
await page.route('**/api/tasks', async route => {
if (route.request().method() === 'POST') {
capturedRequestBody = JSON.parse(route.request().postData() || '{}');
}
await route.continue();
});
await taskPage.createTask('Validate request payload');
// Assert what was actually sent to the API
expect(capturedRequestBody.title).toBe('Validate request payload');
expect(capturedRequestBody.status).toBe('pending');
expect(capturedRequestBody.assignee).toBeDefined();
});
This pattern is invaluable when testing forms — you can assert the correct payload is being sent without depending on what the backend does with it. 🎯
🎬 HAR Files — Record Real Traffic, Replay It in Tests
HAR (HTTP Archive) is the most underused feature in Playwright's network toolkit.
The idea: Record real network traffic from your app once — save it to a file — replay it in tests without ever hitting the server again.
Perfect for:
- Third-party APIs you don't control (payment gateways, analytics, maps)
- Expensive API calls you don't want to make in every test run
- Flaky external dependencies you want to stabilize
Step 1 — Record the HAR file
// scripts/record-har.ts
import { chromium } from '@playwright/test';
(async () => {
const browser = await chromium.launch();
const context = await browser.newContext();
// Start recording
await context.routeFromHAR('./fixtures/tasks-har.har', { update: true });
const page = await context.newPage();
await page.goto('http://localhost:3000/tasks');
// Interact with the app — all network calls get recorded
await page.getByRole('button', { name: 'New Task' }).click();
await page.getByLabel('Task title').fill('Recorded task');
await page.getByRole('button', { name: 'Save task' }).click();
await browser.close();
console.log('HAR recorded to fixtures/tasks-har.har');
})();
Run this once: npx ts-node scripts/record-har.ts
Step 2 — Replay it in your tests
test('task flow using recorded HAR — no live server needed', async ({ page }) => {
// All network calls will be served from the HAR file
await page.routeFromHAR('./fixtures/tasks-har.har', {
// Strict mode — fail if a request isn't in the HAR
notFound: 'abort',
// Match by URL only (ignore method, headers)
url: '**/api/**',
});
await page.goto('/tasks');
// Test runs against recorded responses — fully deterministic
await expect(page.getByRole('listitem')).toHaveCount(3);
});
Your tests are now completely decoupled from any live server. CI runs in half the time. Zero flakiness from external APIs. ✅
🧩 Putting It All Together — A Real-World Scenario
Here's a scenario you'll actually face: testing a dashboard that loads data from three different APIs, one of which is a flaky third-party service.
test('dashboard renders correctly even when analytics API is down', async ({ page }) => {
// Let the core task API work normally
await page.route('**/api/tasks', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, title: 'Write unit tests', status: 'pending' },
{ id: 2, title: 'Deploy to staging', status: 'completed' },
]),
});
});
// Let the user API work normally
await page.route('**/api/users/me', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'Faizal', role: 'admin' }),
});
});
// Simulate the flaky third-party analytics API being down
await page.route('**/analytics.thirdparty.com/**', async route => {
await route.abort('failed');
});
await page.goto('/dashboard');
// Core content should still render
await expect(page.getByText('Write unit tests')).toBeVisible();
await expect(page.getByText('Hello, Faizal')).toBeVisible();
// Analytics widget should degrade gracefully — not crash the page
await expect(page.getByTestId('analytics-widget')).toContainText('Analytics unavailable');
// No error banner for the overall page
await expect(page.getByTestId('error-banner')).not.toBeVisible();
});
This is the kind of test that catches real production bugs. Most teams never write it — because without network interception, it's too hard to set up. With Playwright — it's 10 lines. 🔥
📁 Updated Project Structure After Part 2
playwright-playbook/
├── tests/
│ ├── auth/
│ │ └── login.spec.ts
│ ├── tasks/
│ │ └── task-management.spec.ts
│ └── network/ ← NEW
│ ├── api-mocking.spec.ts
│ ├── error-simulation.spec.ts
│ └── network-assertions.spec.ts
├── pages/
│ ├── LoginPage.ts
│ └── TaskPage.ts
├── fixtures/ ← NEW
│ ├── tasks.json
│ ├── empty-tasks.json
│ └── tasks-har.har
├── scripts/ ← NEW
│ └── record-har.ts
├── .auth/
│ ├── admin.json
│ └── user.json
├── global-setup.ts
├── playwright.config.ts
├── .env
└── package.json
🗺️ What's Coming in This Series
Part 1 — Stop Writing Tests Like a Beginner ✅ Done
Part 2 — Network Interception: The Complete Guide ← You are here
Part 3 — Multi-User, Multi-Tab & Context Testing
Part 4 — API Testing (The Underrated Superpower)
Part 5 — Visual Regression Testing
Part 6 — Debugging Like a Pro: Trace Viewer & Inspector
Part 7 — The CI/CD Setup Nobody Shows You
Part 8 — Playwright Meets AI: Agents, MCP & Self-Healing Tests
In Part 3, we tackle the scenarios most automation frameworks can't handle at all — testing with multiple users simultaneously, multi-tab flows, and real-time features like live notifications. Playwright's browser context architecture makes this surprisingly clean.
🔖 Before You Go
Network interception is the feature that makes Playwright genuinely powerful — not just as a UI testing tool, but as a full-stack testing platform.
Once you start owning the network layer in your tests:
- You stop depending on backend availability
- Your tests stop failing because of external APIs
- You start catching frontend bugs that only appear under specific API conditions
- Your test suite becomes fast, deterministic, and actually reliable
That's the goal. Not just tests that run — tests that you can trust. 💪
Follow me so you don't miss Part 3 — where we go deep on multi-user and multi-tab testing. Two browser contexts. One test. Real-time feature validation without a live websocket server.
Drop a comment below 👇
- Are you using
page.route()in your current test suite? - What's the most painful external dependency in your tests right now?
- Have you ever used HAR files before — or is that new to you?
Let's talk in the comments. 🙌
Faizal Shaikh | Senior Automation Engineer | Playwright & AI Testing
Connect with me on LinkedIn
Top comments (1)
Called it in Part 1 — you actually delivered waitForResponse in Part 2. 🔥 The HAR replay is the sleeper hit here. Gonna refactor our mock layer to this.