intercept-network-call.md 13 KB

Intercept Network Call Utility

Principle

Intercept network requests with a single declarative call that returns a Promise. Automatically parse JSON responses, support both spy (observe) and stub (mock) patterns, and use powerful glob pattern matching for URL filtering.

Rationale

Vanilla Playwright's network interception requires multiple steps:

  • page.route() to setup, page.waitForResponse() to capture
  • Manual JSON parsing
  • Verbose syntax for conditional handling
  • Complex filter predicates

The interceptNetworkCall utility provides:

  • Single declarative call: Setup and wait in one statement
  • Automatic JSON parsing: Response pre-parsed, strongly typed
  • Flexible URL patterns: Glob matching with picomatch
  • Spy or stub modes: Observe real traffic or mock responses
  • Concise API: Reduces boilerplate by 60-70%

Pattern Examples

Example 1: Spy on Network (Observe Real Traffic)

Context: Capture and inspect real API responses for validation.

Implementation:

import { test } from '@seontechnologies/playwright-utils/intercept-network-call/fixtures';

test('should spy on users API', async ({ page, interceptNetworkCall }) => {
  // Setup interception BEFORE navigation
  const usersCall = interceptNetworkCall({
    url: '**/api/users', // Glob pattern
  });

  await page.goto('/dashboard');

  // Wait for response and access parsed data
  const { responseJson, status } = await usersCall;

  expect(status).toBe(200);
  expect(responseJson).toHaveLength(10);
  expect(responseJson[0]).toHaveProperty('name');
});

Key Points:

  • Intercept before navigation (critical for race-free tests)
  • Returns Promise with { responseJson, status, requestBody }
  • Glob patterns (** matches any path segment)
  • JSON automatically parsed

Example 2: Stub Network (Mock Response)

Context: Mock API responses for testing UI behavior without backend.

Implementation:

test('should stub users API', async ({ page, interceptNetworkCall }) => {
  const mockUsers = [
    { id: 1, name: 'Test User 1' },
    { id: 2, name: 'Test User 2' },
  ];

  const usersCall = interceptNetworkCall({
    url: '**/api/users',
    fulfillResponse: {
      status: 200,
      body: mockUsers,
    },
  });

  await page.goto('/dashboard');
  await usersCall;

  // UI shows mocked data
  await expect(page.getByText('Test User 1')).toBeVisible();
  await expect(page.getByText('Test User 2')).toBeVisible();
});

Key Points:

  • fulfillResponse mocks the API
  • No backend needed
  • Test UI logic in isolation
  • Status code and body fully controllable

Example 3: Conditional Response Handling

Context: Different responses based on request method or parameters.

Implementation:

test('conditional mocking', async ({ page, interceptNetworkCall }) => {
  await interceptNetworkCall({
    url: '**/api/data',
    handler: async (route, request) => {
      if (request.method() === 'POST') {
        // Mock POST success
        await route.fulfill({
          status: 201,
          body: JSON.stringify({ id: 'new-id', success: true }),
        });
      } else if (request.method() === 'GET') {
        // Mock GET with data
        await route.fulfill({
          status: 200,
          body: JSON.stringify([{ id: 1, name: 'Item' }]),
        });
      } else {
        // Let other methods through
        await route.continue();
      }
    },
  });

  await page.goto('/data-page');
});

Key Points:

  • handler function for complex logic
  • Access full route and request objects
  • Can mock, continue, or abort
  • Flexible for advanced scenarios

Example 4: Error Simulation

Context: Testing error handling in UI when API fails.

Implementation:

test('should handle API errors gracefully', async ({ page, interceptNetworkCall }) => {
  // Simulate 500 error
  const errorCall = interceptNetworkCall({
    url: '**/api/users',
    fulfillResponse: {
      status: 500,
      body: { error: 'Internal Server Error' },
    },
  });

  await page.goto('/dashboard');
  await errorCall;

  // Verify UI shows error state
  await expect(page.getByText('Failed to load users')).toBeVisible();
  await expect(page.getByTestId('retry-button')).toBeVisible();
});

// Simulate network timeout
test('should handle timeout', async ({ page, interceptNetworkCall }) => {
  await interceptNetworkCall({
    url: '**/api/slow',
    handler: async (route) => {
      // Never respond - simulates timeout
      await new Promise(() => {});
    },
  });

  await page.goto('/slow-page');

  // UI should show timeout error
  await expect(page.getByText('Request timed out')).toBeVisible({ timeout: 10000 });
});

Key Points:

  • Mock error statuses (4xx, 5xx)
  • Test timeout scenarios
  • Validate error UI states
  • No real failures needed

Example 5: Order Matters - Intercept Before Navigate

Context: The interceptor must be set up before the network request occurs.

Implementation:

// INCORRECT - interceptor set up too late
await page.goto('https://example.com'); // Request already happened
const networkCall = interceptNetworkCall({ url: '**/api/data' });
await networkCall; // Will hang indefinitely!

// CORRECT - Set up interception first
const networkCall = interceptNetworkCall({ url: '**/api/data' });
await page.goto('https://example.com');
const result = await networkCall;

This pattern follows the classic test spy/stub pattern:

  1. Define the spy/stub (set up interception)
  2. Perform the action (trigger the network request)
  3. Assert on the spy/stub (await and verify the response)

Example 6: Multiple Intercepts

Context: Intercepting different endpoints in same test - setup order is critical.

Implementation:

test('multiple intercepts', async ({ page, interceptNetworkCall }) => {
  // Setup all intercepts BEFORE navigation
  const usersCall = interceptNetworkCall({ url: '**/api/users' });
  const productsCall = interceptNetworkCall({ url: '**/api/products' });
  const ordersCall = interceptNetworkCall({ url: '**/api/orders' });

  // THEN navigate
  await page.goto('/dashboard');

  // Wait for all (or specific ones)
  const [users, products] = await Promise.all([usersCall, productsCall]);

  expect(users.responseJson).toHaveLength(10);
  expect(products.responseJson).toHaveLength(50);
});

Key Points:

  • Setup all intercepts before triggering actions
  • Use Promise.all() to wait for multiple calls
  • Order: intercept -> navigate -> await
  • Prevents race conditions

Example 7: Capturing Multiple Requests to the Same Endpoint

Context: Each interceptNetworkCall captures only the first matching request.

Implementation:

// Capturing a known number of requests
const firstRequest = interceptNetworkCall({ url: '/api/data' });
const secondRequest = interceptNetworkCall({ url: '/api/data' });

await page.click('#load-data-button');

const firstResponse = await firstRequest;
const secondResponse = await secondRequest;

expect(firstResponse.status).toBe(200);
expect(secondResponse.status).toBe(200);

// Handling an unknown number of requests
const getDataRequestInterceptor = () =>
  interceptNetworkCall({
    url: '/api/data',
    timeout: 1000, // Short timeout to detect when no more requests are coming
  });

let currentInterceptor = getDataRequestInterceptor();
const allResponses = [];

await page.click('#load-multiple-data-button');

while (true) {
  try {
    const response = await currentInterceptor;
    allResponses.push(response);
    currentInterceptor = getDataRequestInterceptor();
  } catch (error) {
    // No more requests (timeout)
    break;
  }
}

console.log(`Captured ${allResponses.length} requests to /api/data`);

Example 8: Using Timeout

Context: Set a timeout for waiting on a network request.

Implementation:

const dataCall = interceptNetworkCall({
  method: 'GET',
  url: '/api/data-that-might-be-slow',
  timeout: 5000, // 5 seconds timeout
});

await page.goto('/data-page');

try {
  const { responseJson } = await dataCall;
  console.log('Data loaded successfully:', responseJson);
} catch (error) {
  if (error.message.includes('timeout')) {
    console.log('Request timed out as expected');
  } else {
    throw error;
  }
}

URL Pattern Matching

The utility uses picomatch for powerful glob pattern matching, dramatically simplifying URL targeting:

Supported glob patterns:

'**/api/users'; // Any path ending with /api/users
'/api/users'; // Exact match
'**/users/*'; // Any users sub-path
'**/api/{users,products}'; // Either users or products
'**/api/users?id=*'; // With query params

Comparison with vanilla Playwright:

// Vanilla Playwright - complex predicate
const predicate = (response) => {
  const url = response.url();
  return (
    url.endsWith('/api/users') ||
    url.match(/\/api\/users\/\d+/) ||
    (url.includes('/api/users/') && url.includes('/profile'))
  );
};
page.waitForResponse(predicate);

// With interceptNetworkCall - simple glob patterns
interceptNetworkCall({ url: '/api/users' }); // Exact endpoint
interceptNetworkCall({ url: '/api/users/*' }); // User by ID pattern
interceptNetworkCall({ url: '/api/users/*/profile' }); // Specific sub-paths
interceptNetworkCall({ url: '/api/users/**' }); // Match all

API Reference

interceptNetworkCall(options)

Parameter Type Description
page Page Required when using direct import (not needed with fixture)
method string Optional: HTTP method to match (e.g., 'GET', 'POST')
url string Optional: URL pattern to match (supports glob patterns via picomatch)
fulfillResponse object Optional: Response to use when mocking
handler function Optional: Custom handler function for the route
timeout number Optional: Timeout in milliseconds for the network request

fulfillResponse Object

Property Type Description
status number HTTP status code (default: 200)
headers Record<string, string> Response headers
body any Response body (will be JSON.stringified if an object)

Return Value

Returns a Promise<NetworkCallResult> with:

Property Type Description
request Request The intercepted request
response Response The response (null if mocked)
responseJson any Parsed JSON response (if available)
status number HTTP status code
requestJson any Parsed JSON request body (if available)

Comparison with Vanilla Playwright

Vanilla Playwright intercept-network-call
await page.route('/api/users', route => route.continue()) const call = interceptNetworkCall({ url: '**/api/users' })
const resp = await page.waitForResponse('/api/users') (Combined in single statement)
const json = await resp.json() const { responseJson } = await call
const status = resp.status() const { status } = await call
Complex filter predicates Simple glob patterns

Reduction: ~5-7 lines -> ~2-3 lines per interception

Related Fragments

  • network-first.md - Core pattern: intercept before navigate
  • network-recorder.md - HAR-based offline testing
  • overview.md - Fixture composition basics

Anti-Patterns

DON'T intercept after navigation:

await page.goto('/dashboard'); // Navigation starts
const usersCall = interceptNetworkCall({ url: '**/api/users' }); // Too late!

DO intercept before navigate:

const usersCall = interceptNetworkCall({ url: '**/api/users' }); // First
await page.goto('/dashboard'); // Then navigate
const { responseJson } = await usersCall; // Then await

DON'T ignore the returned Promise:

interceptNetworkCall({ url: '**/api/users' }); // Not awaited!
await page.goto('/dashboard');
// No deterministic wait - race condition

DO always await the intercept:

const usersCall = interceptNetworkCall({ url: '**/api/users' });
await page.goto('/dashboard');
await usersCall; // Deterministic wait