惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LangChain Blog
N
Netflix TechBlog - Medium
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
V2EX
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Blog — PlanetScale
Blog — PlanetScale
Microsoft Security Blog
Microsoft Security Blog
D
Docker
WordPress大学
WordPress大学
罗磊的独立博客
J
Java Code Geeks
博客园 - 【当耐特】
博客园 - 司徒正美
雷峰网
雷峰网
H
Help Net Security
酷 壳 – CoolShell
酷 壳 – CoolShell
Last Week in AI
Last Week in AI
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
T
Tailwind CSS Blog
Google DeepMind News
Google DeepMind News
M
MIT News - Artificial intelligence
Recent Announcements
Recent Announcements
B
Blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
The Playwright Playbook — Part 2: Network Interception — ...
Faizal · 2026-06-16 · via DEV Community

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