Playwright Network Mocking: Intercept and Mock API Calls in Tests

Playwright Network Mocking: Intercept and Mock API Calls in Tests

Network calls are one of the biggest sources of flakiness in end-to-end tests. When your tests depend on real APIs, they break whenever the backend is slow, returns unexpected data, or goes down entirely. Playwright's network interception lets you take control — mock responses, simulate failures, and test edge cases that would be impossible to reproduce with a real server.

How Playwright Network Interception Works

Playwright intercepts network requests at the browser level. You register route handlers that match URL patterns, and Playwright calls your handler for every matching request. From there you can:

  • Fulfill the request with a custom response (mocking)
  • Abort the request (simulating network failure)
  • Continue the request with modified headers or body
  • Pass through to the real server (default)

Basic Request Mocking

Use page.route() to mock a specific endpoint:

import { test, expect } from '@playwright/test';

test('shows products from API', async ({ page }) => {
  await page.route('**/api/products', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Widget Pro', price: 49.99 },
        { id: 2, name: 'Widget Lite', price: 19.99 },
      ]),
    });
  });

  await page.goto('/products');
  await expect(page.locator('[data-testid="product-name"]').first()).toHaveText('Widget Pro');
});

The route handler receives a Route object. Call route.fulfill() with a status code, content type, and body. The browser never sees the real server — it gets your mock response instantly.

URL Pattern Matching

Playwright supports glob patterns and regular expressions for route matching:

// Glob pattern — matches any path starting with /api/
await page.route('**/api/**', route => route.fulfill({ status: 200, body: '{}' }));

// Regex — matches exactly /api/users with optional query string
await page.route(/\/api\/users(\?.*)?$/, route => route.fulfill({
  status: 200,
  contentType: 'application/json',
  body: JSON.stringify([{ id: 1, name: 'Alice' }]),
}));

// Full URL with wildcard subdomain
await page.route('https://*.example.com/api/data', handler);

For most cases, glob patterns with **/ prefix work well and avoid matching issues caused by ports or subdomains.

Simulating Error States

Testing error handling is one of the best reasons to use network mocking. Real APIs rarely return errors on demand:

test('shows error message on API failure', async ({ page }) => {
  await page.route('**/api/products', route => route.fulfill({
    status: 500,
    contentType: 'application/json',
    body: JSON.stringify({ error: 'Internal server error' }),
  }));

  await page.goto('/products');
  await expect(page.locator('[data-testid="error-banner"]')).toBeVisible();
  await expect(page.locator('[data-testid="error-banner"]')).toContainText('Something went wrong');
});

test('handles network timeout gracefully', async ({ page }) => {
  await page.route('**/api/products', route => route.abort('timedout'));

  await page.goto('/products');
  await expect(page.locator('[data-testid="retry-button"]')).toBeVisible();
});

test('handles slow network', async ({ page }) => {
  await page.route('**/api/products', async route => {
    await new Promise(resolve => setTimeout(resolve, 3000)); // 3s delay
    await route.fulfill({ status: 200, body: '[]' });
  });

  await page.goto('/products');
  await expect(page.locator('[data-testid="loading-spinner"]')).toBeVisible();
});

Modifying Real Requests

Sometimes you want to intercept a real request and modify it rather than replace it entirely. Use route.continue() with overrides:

test('adds auth header to all API requests', async ({ page }) => {
  await page.route('**/api/**', async route => {
    await route.continue({
      headers: {
        ...route.request().headers(),
        'X-Test-User': 'test-user-123',
        'Authorization': 'Bearer test-token',
      },
    });
  });

  await page.goto('/dashboard');
  // All API calls will include the injected headers
});

test('modifies request body', async ({ page }) => {
  await page.route('**/api/orders', async route => {
    const postData = JSON.parse(route.request().postData() || '{}');
    await route.continue({
      postData: JSON.stringify({ ...postData, testMode: true }),
    });
  });

  await page.goto('/checkout');
  await page.click('[data-testid="submit-order"]');
});

Inspecting Requests

Use page.waitForRequest() and page.waitForResponse() to assert that specific network calls happen:

test('submits order and calls payment API', async ({ page }) => {
  await page.route('**/api/payments', route => route.fulfill({
    status: 200,
    body: JSON.stringify({ transactionId: 'txn_123' }),
  }));

  await page.goto('/checkout');
  await page.fill('[data-testid="card-number"]', '4242424242424242');
  
  const paymentRequestPromise = page.waitForRequest('**/api/payments');
  await page.click('[data-testid="pay-button"]');
  
  const paymentRequest = await paymentRequestPromise;
  const body = JSON.parse(paymentRequest.postData() || '{}');
  expect(body.amount).toBe(49.99);
});

test('makes exactly one analytics call per page view', async ({ page }) => {
  const analyticsCalls: string[] = [];
  await page.route('**/analytics/track', async route => {
    analyticsCalls.push(route.request().url());
    await route.fulfill({ status: 204, body: '' });
  });

  await page.goto('/products');
  expect(analyticsCalls).toHaveLength(1);
});

Reusable Mock Fixtures

For large test suites, extract common mocks into fixtures to avoid repetition:

// fixtures.ts
import { test as base, Page } from '@playwright/test';

interface Fixtures {
  mockApi: (path: string, response: object, status?: number) => Promise<void>;
}

export const test = base.extend<Fixtures>({
  mockApi: async ({ page }, use) => {
    const mockApi = async (path: string, response: object, status = 200) => {
      await page.route(`**${path}`, route => route.fulfill({
        status,
        contentType: 'application/json',
        body: JSON.stringify(response),
      }));
    };
    await use(mockApi);
  },
});

// product.spec.ts
import { test } from './fixtures';

test('shows empty state', async ({ page, mockApi }) => {
  await mockApi('/api/products', []);
  await page.goto('/products');
  await expect(page.locator('[data-testid="empty-state"]')).toBeVisible();
});

Conditional Mocking Based on Request

Route handlers receive the full request, letting you branch on method, headers, or body:

await page.route('**/api/users/*', async route => {
  const url = route.request().url();
  const userId = url.split('/').pop();

  if (userId === '404') {
    await route.fulfill({ status: 404, body: JSON.stringify({ error: 'Not found' }) });
  } else if (userId === '403') {
    await route.fulfill({ status: 403, body: JSON.stringify({ error: 'Forbidden' }) });
  } else {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ id: userId, name: 'Test User' }),
    });
  }
});

Removing Routes

Routes persist for the lifetime of the page by default. Remove specific routes when you need to restore real network calls mid-test:

test('loads from cache then refreshes from network', async ({ page }) => {
  // First load — mock a cached response
  await page.route('**/api/data', route => route.fulfill({
    status: 200,
    headers: { 'X-From-Cache': 'true' },
    body: JSON.stringify({ stale: true }),
  }));
  
  await page.goto('/dashboard');
  
  // Remove mock to allow real network call on refresh
  await page.unroute('**/api/data');
  await page.click('[data-testid="refresh-button"]');
  
  // Now the real API is called
  await expect(page.locator('[data-testid="data-freshness"]')).toContainText('Live');
});

HAR File Recording and Replay

For complex scenarios, record real network traffic into a HAR file and replay it in tests:

// Record (run once to capture real traffic)
test('record checkout flow', async ({ page }) => {
  await page.routeFromHAR('checkout.har', { update: true });
  await page.goto('/checkout');
  // Complete the real flow — all network calls are saved
});

// Replay (use in CI — no real network needed)
test('checkout flow from HAR', async ({ page }) => {
  await page.routeFromHAR('checkout.har', { update: false });
  await page.goto('/checkout');
  await page.click('[data-testid="pay-button"]');
  await expect(page.locator('[data-testid="success-message"]')).toBeVisible();
});

HAR replay is particularly useful for third-party APIs (payment processors, maps, analytics) where you can't easily control the response.

Best Practices

Mock at the right layer. Mock external APIs (payment, email, analytics) but test your own API integration with real calls. The goal is isolating third-party dependencies, not skipping your own code.

Keep mocks close to tests. Define mocks inside the test or in a per-file fixture. Global mock setup that affects all tests creates invisible dependencies and makes failures hard to diagnose.

Use realistic data. Mock responses should match the real API's shape, including edge cases like null fields, empty arrays, and pagination metadata. Tests that pass with simplified mocks often fail with real data.

Test the unhappy paths. Network errors, timeouts, 429 rate limits, and partial failures are hard to test against real APIs. Mocking makes these scenarios easy — use that power.

Validate what you send. Don't just mock responses. Assert that your application sends the correct request — right method, right headers, right body shape. Use waitForRequest() to capture and inspect outgoing calls.

Network mocking transforms flaky, environment-dependent tests into fast, reliable, deterministic ones. With Playwright's route API, you have precise control over every network call — making it one of the most valuable tools in your testing toolkit.

Read more