DEV Community

Cover image for The Playwright Playbook — Part 2: Network Interception — The Complete Guide
Faizal
Faizal

Posted on

The Playwright Playbook — Part 2: Network Interception — The Complete Guide

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🔍 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)
Enter fullscreen mode Exit fullscreen mode

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" }
]
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

📡 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();
});
Enter fullscreen mode Exit fullscreen mode

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');
})();
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🗺️ 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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
xulingfeng profile image
xulingfeng

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.