← Blog

"Playwright Series #6: Network Interception and API Mocking"

Test your UI without depending on a real backend. Intercept HTTP requests, mock API responses, simulate errors, test loading states, and stub third-party services.

reading now
views
comments

Series Navigation

Part 5: Page Object Model

Part 7: Authentication — Sessions, Storage State and Multi-User Testing


Why Mock the Network?

Real API calls in tests create problems:

  • Slow tests — real network calls take hundreds of milliseconds each
  • Flaky tests — external services can be down or slow
  • Unpredictable data — you can't control what the API returns
  • Test isolation — one test's data mutations can break another's

Network mocking solves all of these. You decide exactly what the API returns.


page.route() — The Core API

// Intercept all requests matching a URL pattern
await page.route('**/api/users', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([
      { id: 1, name: 'Srikanth', email: 'srikanth@example.com' },
      { id: 2, name: 'Priya',    email: 'priya@example.com' }
    ])
  });
});

await page.goto('/users');
// Page now shows our mocked users, no real API call made

URL patterns support glob syntax:

'**/api/users'          // matches any URL ending in /api/users
'**/api/**'             // matches any URL with /api/ in it
'https://api.example.com/v2/users'  // exact URL
/\/api\/users\/\d+/    // regex pattern

Mocking GET Responses

test('displays user list from API', async ({ page }) => {
  // Set up the mock before navigation
  await page.route('**/api/users', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice', role: 'admin' },
        { id: 2, name: 'Bob',   role: 'user' },
      ])
    });
  });

  await page.goto('/admin/users');

  // Assert the UI renders the mocked data
  await expect(page.getByRole('row', { name: /Alice/ })).toBeVisible();
  await expect(page.getByRole('row', { name: /Bob/ })).toBeVisible();
  await expect(page.getByRole('row')).toHaveCount(3); // 2 + header row
});

Simulating Error States

Test how your UI handles API failures:

test('shows error message when API fails', 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.getByRole('alert')).toContainText('Failed to load products');
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('shows network error message when offline', async ({ page }) => {
  await page.route('**/api/**', route => route.abort('failed'));

  await page.goto('/dashboard');

  await expect(page.getByText('No internet connection')).toBeVisible();
});

Simulating Loading States

Test skeleton screens and loading spinners:

test('shows loading state while fetching', async ({ page }) => {
  let resolveRequest: () => void;

  // Delay the response indefinitely until we choose to resolve it
  await page.route('**/api/products', route => {
    return new Promise<void>(resolve => {
      resolveRequest = () => {
        route.fulfill({
          status: 200,
          body: JSON.stringify([{ id: 1, name: 'Widget' }])
        });
        resolve();
      };
    });
  });

  await page.goto('/products');

  // Assert loading state is shown
  await expect(page.getByTestId('skeleton-loader')).toBeVisible();
  await expect(page.getByRole('table')).toBeHidden();

  // Now resolve the request
  resolveRequest!();

  // Assert data loaded
  await expect(page.getByRole('table')).toBeVisible();
  await expect(page.getByTestId('skeleton-loader')).toBeHidden();
});

Modifying Real Requests

Instead of fully replacing a response, you can fetch the real response and modify it:

await page.route('**/api/user/profile', async route => {
  // Make the real API call
  const response = await route.fetch();
  const body = await response.json();

  // Modify specific fields
  body.subscription = 'pro';
  body.trialDaysRemaining = 0;

  // Return the modified response
  await route.fulfill({
    response,
    body: JSON.stringify(body)
  });
});

Useful for testing edge cases that are hard to reproduce in a real environment.


Intercepting POST/PUT/DELETE Requests

test('form submission sends correct data', async ({ page }) => {
  let capturedRequest: any;

  await page.route('**/api/orders', async route => {
    // Capture what was sent
    capturedRequest = JSON.parse(route.request().postData() || '{}');

    // Respond with success
    await route.fulfill({
      status: 201,
      body: JSON.stringify({ id: 999, status: 'created' })
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('Name').fill('Srikanth');
  await page.getByRole('button', { name: 'Place Order' }).click();

  // Assert the UI responded to the mock response
  await expect(page.getByText('Order confirmed')).toBeVisible();

  // Assert the correct data was sent
  expect(capturedRequest.customerName).toBe('Srikanth');
  expect(capturedRequest.items).toHaveLength(1);
});

Mocking Third-Party Services

Stub external APIs your frontend calls:

test('payment form works with mocked Stripe', async ({ page }) => {
  // Mock the Stripe payment intent creation
  await page.route('https://api.stripe.com/**', route => {
    route.fulfill({
      status: 200,
      body: JSON.stringify({
        id: 'pi_test_mock',
        client_secret: 'pi_test_mock_secret',
        status: 'requires_payment_method'
      })
    });
  });

  // Mock your own payment API
  await page.route('**/api/create-payment-intent', route => {
    route.fulfill({
      status: 200,
      body: JSON.stringify({ clientSecret: 'pi_test_mock_secret' })
    });
  });

  await page.goto('/checkout');
  // Test the checkout UI without a real Stripe account
});

Route Handlers in Page Objects

Keep mocking logic organised with your page objects:

// tests/pages/ProductsPage.ts
export class ProductsPage extends BasePage {
  async mockProductsSuccess(products: Product[]) {
    await this.page.route('**/api/products', route =>
      route.fulfill({ status: 200, body: JSON.stringify(products) })
    );
  }

  async mockProductsError() {
    await this.page.route('**/api/products', route =>
      route.fulfill({ status: 500, body: '{"error":"Server error"}' })
    );
  }

  async goto() {
    await this.page.goto('/products');
  }
}

// In a test
test('shows products', async ({ page }) => {
  const productsPage = new ProductsPage(page);
  await productsPage.mockProductsSuccess([
    { id: 1, name: 'Widget A', price: 9.99 }
  ]);
  await productsPage.goto();
  await expect(page.getByText('Widget A')).toBeVisible();
});

request Context — Testing APIs Directly

Playwright can make raw HTTP requests without a browser page:

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

test('API returns correct user structure', async ({ request }) => {
  const response = await request.get('https://jsonplaceholder.typicode.com/users/1');

  expect(response.status()).toBe(200);

  const body = await response.json();
  expect(body).toMatchObject({
    id: 1,
    name: expect.any(String),
    email: expect.stringContaining('@')
  });
});

test('can create a resource via API', async ({ request }) => {
  const response = await request.post('https://jsonplaceholder.typicode.com/posts', {
    data: { title: 'Test Post', body: 'Content', userId: 1 }
  });

  expect(response.status()).toBe(201);
  const body = await response.json();
  expect(body.title).toBe('Test Post');
});

This is perfect for API smoke tests or setting up test data before UI tests run.


What's Next

In Part 7 we tackle authentication — the most important performance optimisation for test suites. Instead of logging in through the UI for every test, we capture the session state once and reuse it across hundreds of tests.

Discussion

Loading...

Leave a Comment

All comments are reviewed before appearing. No links please.

0 / 1000