Skip to content
ExponaBLOG
PLAYWRIGHT & BROWSER AUTOMATION · DAY 016 / 100

Mocking APIs with page.route: what developers should know

Test UI states without a backend.

Expona Admin7 min read

Getting started with API mocking in tests

API mocking is one of the most underrated techniques in test automation. When you're writing Playwright tests that depend on backend services, you're one network timeout or database change away from a flaky test suite. Mocking APIs with page.route: what developers should know is a practical guide to intercepting and controlling network traffic in your tests—letting you test UI states without a backend, simulate error conditions, and keep your test suite fast and reliable.

In this article, we'll walk through how to intercept requests, mock error states, modify responses dynamically, and use HAR files to record and replay real traffic. By the end, you'll have a toolkit for building tests that are both realistic and resilient.

Intercept requests and return a fake response

The foundation of API mocking in Playwright is page.route(). It allows you to intercept network requests matching a URL pattern and respond with whatever data you want. This is invaluable when you want to test your UI in isolation from the backend.

Let's say you're testing a product listing page. Instead of waiting for a real API to return products, you can mock it:

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

test('should display products from mocked API', async ({ page }) => {
  // Intercept GET requests to the products endpoint
  await page.route('**/api/products', route => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      json: [
        { id: 1, name: 'Mug', price: 12.99 },
        { id: 2, name: 'T-Shirt', price: 24.99 }
      ]
    });
  });

  await page.goto('http://localhost:3000/products');
  
  // Verify the UI renders the mocked data
  const products = page.locator('[data-testid="product-card"]');
  await expect(products).toHaveCount(2);
  await expect(page.locator('text=Mug')).toBeVisible();
});

Notice that page.route() is called before navigation. Playwright's interception happens at the network layer, so you need to set up your routes before the page makes requests. The URL pattern supports wildcards—**/api/products matches any domain and protocol.

A few important details:

  • Timing matters: Register routes before the page loads to catch all requests.
  • Pattern specificity: More specific patterns take precedence. **/api/products/123 will match before **/api/products.
  • Multiple routes: You can chain multiple page.route() calls to mock different endpoints.

For endpoints that handle POST, PUT, or DELETE, you can also access the request body:

typescript
await page.route('**/api/products', route => {
  const request = route.request();
  
  if (request.method() === 'POST') {
    // Verify the request payload if needed
    const postData = request.postDataJSON();
    console.log('Received POST:', postData);
    
    route.fulfill({
      status: 201,
      json: { id: 3, ...postData }
    });
  }
});

Simulate error states with mock status codes

Testing happy paths is easy—the real value of mocking comes when you test error scenarios. Return a 500, 404, or timeout to verify that your UI handles failures gracefully. This is where many teams fall short: they test success but ignore what happens when the API breaks.

Here's how to mock an API error:

typescript
test('should display error message when API fails', async ({ page }) => {
  await page.route('**/api/products', route => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      json: { error: 'Internal server error' }
    });
  });

  await page.goto('http://localhost:3000/products');
  
  // Verify the error message is displayed
  await expect(page.locator('text=Something went wrong')).toBeVisible();
});

You can also simulate timeouts by aborting the request:

typescript
test('should handle API timeout', async ({ page }) => {
  await page.route('**/api/products', route => {
    route.abort('timedout');
  });

  await page.goto('http://localhost:3000/products');
  
  // Verify the timeout error is handled
  await expect(page.locator('text=Request timed out')).toBeVisible();
});

Common failure codes to test:

  • 400 — Bad request (invalid input)
  • 401 — Unauthorized (missing or invalid token)
  • 403 — Forbidden (user lacks permissions)
  • 404 — Not found (resource doesn't exist)
  • 429 — Too many requests (rate limiting)
  • 500 — Server error
  • timedout — Connection timeout

Building a test matrix around these scenarios ensures your frontend is resilient. Users will encounter these states in production, so your tests should verify the UI behavior.

Modify responses dynamically

Sometimes you want to start with real API data but tweak it for a specific test scenario. Instead of hardcoding a response, you can fetch the real one, modify it, and return it:

typescript
test('should highlight out-of-stock products', async ({ page }) => {
  await page.route('**/api/products', async route => {
    // Fetch the real response
    const response = await route.fetch();
    const json = await response.json();
    
    // Modify it
    const modifiedProducts = json.map((product, index) => ({
      ...product,
      inStock: index === 0 ? false : true // First product is out of stock
    }));
    
    // Return the modified response
    route.fulfill({
      response,
      json: modifiedProducts
    });
  });

  await page.goto('http://localhost:3000/products');
  
  // Verify out-of-stock state
  const firstProduct = page.locator('[data-testid="product-card"]').first();
  await expect(firstProduct.locator('text=Out of stock')).toBeVisible();
});

This approach is powerful because it:

  • Keeps tests maintainable—you're not hardcoding large response objects.
  • Stays close to reality—you're modifying real API structures, not invented ones.
  • Allows conditional logic—different modifications based on test needs.

You can also modify headers or status codes while keeping the body intact:

typescript
await page.route('**/api/products', async route => {
  const response = await route.fetch();
  
  route.fulfill({
    response,
    headers: {
      ...response.headers(),
      'X-Custom-Header': 'test-value'
    }
  });
});

Record and replay with HAR files

HAR files (HTTP Archive) let you record real API traffic once and replay it in all your tests. This combines the benefits of real data with the reliability of mocking: your tests run fast and don't depend on a live backend.

To record a HAR file:

typescript
import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const context = await browser.newContext({
  recordHar: { path: 'products.har' }
});

const page = await context.newPage();
await page.goto('http://localhost:3000/products');
// Interact with the page to generate network traffic
await page.click('[data-testid="filter-button"]');

await context.close();

Once recorded, use the HAR file in your tests:

typescript
test('should display products from HAR', async ({ browser }) => {
  const context = await browser.newContext({
    recordHar: { path: 'products.har', mode: 'minimal' }
  });
  
  const page = await context.newPage();
  await page.routeFromHAR('products.har', { url: '**/api/**' });
  
  await page.goto('http://localhost:3000/products');
  
  // Test proceeds with mocked responses from the HAR file
  await expect(page.locator('[data-testid="product-card"]')).toHaveCount(2);
  
  await context.close();
});

HAR benefits:

  • Version control: Commit HAR files to your repo. Team members use the same API responses.
  • Offline testing: Tests run without network access.
  • Realistic data: You capture real API responses, including nested objects and edge cases.
  • Performance: Faster than live API calls, especially for large payloads.

The tradeoff is that HAR files can become stale. If your API contract changes, you need to re-record. A good practice is to re-record HAR files weekly or when API schemas update.

Common mistakes

  • Setting up routes after navigation: page.route() must be called before the page loads. Always set routes first.
  • Hardcoding response structures: If your API changes, your mocks become invalid. Use real API structures or keep tests in sync with documentation.
  • Mocking everything: Sometimes a real API call is worth it. If you're testing authentication or third-party integrations, consider a test backend instead of mocks.
  • Ignoring request validation: Mocks hide bugs in request payloads. Occasionally log and inspect what the UI is actually sending.
  • Forgetting about async handlers: When you call route.fetch(), it's async. Use async in your route handler or you'll get promise rejections.

Key takeaways

  • page.route() intercepts network requests and lets you respond with mocked data, keeping tests fast and isolated from the backend.
  • Mock error states (500, 404, timeouts) to verify your UI handles failures gracefully—this is often missing from test suites.
  • Modify responses dynamically by calling route.fetch() first, changing fields, and returning the modified response; this keeps tests maintainable.
  • HAR files record real API traffic once and replay it across your test suite, providing both realism and reliability.
  • Always register routes before navigation, validate that your mocks match the actual API structure, and occasionally test with real backends to catch integration issues.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles