Mocking Network Requests

Intercept and mock API calls for faster, more reliable tests

11 min readNode.js

Mocking Network Requests

In the previous tutorial, we mastered authentication. Now let's learn a technique that'll make your tests faster and way more reliable: mocking network requests.

Real APIs are slow, flaky, and sometimes expensive. They can fail randomly, have rate limits, or return different data every time. Mocking lets you control exactly what your app receives — making tests faster, more reliable, and able to cover edge cases that are hard to reproduce with real APIs.

Why Mock Network Requests?

Speed — No network latency. Mock responses are instant.

Reliability — No flaky third-party services. Tests pass consistently.

Edge cases — Test error states, empty responses, slow servers.

Isolation — No test data pollution. Your tests don't affect production data.

Cost — Don't burn API credits on every test run.

Offline — Tests work without network access.

Route Interception Basics

"How does mocking actually work?"

Playwright's page.route() intercepts requests matching a URL pattern and lets you handle them however you want.

Intercepting a Request

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

test('intercept API call', async ({ page }) => {
  // Intercept requests to /api/users
  await page.route('**/api/users', async (route) => {
    // Return a mock response
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
      ]),
    });
  });
  
  await page.goto('/users');
  
  // App displays the mocked data
  await expect(page.getByText('Alice')).toBeVisible();
  await expect(page.getByText('Bob')).toBeVisible();
});

The key: set up routes before navigating or triggering requests.

URL Patterns

You can match URLs in several ways:

// Exact URL
await page.route('https://api.example.com/users', handler);

// Glob pattern (most common)
await page.route('**/api/users', handler);        // Matches any origin
await page.route('**/api/users/*', handler);      // Matches /api/users/123
await page.route('**/api/**', handler);           // Matches all /api/* endpoints

// Regex
await page.route(/\/api\/users\/\d+/, handler);   // Matches /api/users/123

// Function (most flexible)
await page.route(
  (url) => url.pathname.startsWith('/api/') && url.searchParams.has('token'),
  handler
);

Mocking Responses

Simple JSON Response

test('mock products list', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        products: [
          { id: 1, name: 'Widget', price: 9.99 },
          { id: 2, name: 'Gadget', price: 19.99 },
        ],
        total: 2,
      }),
    });
  });
  
  await page.goto('/products');
  await expect(page.getByText('Widget')).toBeVisible();
  await expect(page.getByText('$9.99')).toBeVisible();
});

Response from File

For large responses, store them in files:

// tests/mocks/products.json
// {
//   "products": [
//     { "id": 1, "name": "Widget", "price": 9.99 },
//     ...
//   ]
// }

test('mock from file', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      path: 'tests/mocks/products.json', // Load from file
    });
  });
  
  await page.goto('/products');
});

Dynamic Responses

Generate responses based on the request:

test('dynamic mock based on request', async ({ page }) => {
  await page.route('**/api/products/*', async (route) => {
    const url = new URL(route.request().url());
    const productId = url.pathname.split('/').pop();
    
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        id: productId,
        name: `Product ${productId}`,
        price: 10 + Number(productId),
      }),
    });
  });
  
  await page.goto('/products/42');
  await expect(page.getByText('Product 42')).toBeVisible();
  await expect(page.getByText('$52')).toBeVisible();
});

Response Based on Request Body

test('respond based on POST body', async ({ page }) => {
  await page.route('**/api/search', async (route) => {
    const request = route.request();
    const postData = request.postDataJSON();
    
    // Different response based on search query
    if (postData?.query === 'shoes') {
      await route.fulfill({
        body: JSON.stringify({ results: [{ name: 'Running Shoes' }] }),
      });
    } else {
      await route.fulfill({
        body: JSON.stringify({ results: [] }),
      });
    }
  });
  
  await page.goto('/search');
  await page.getByLabel('Search').fill('shoes');
  await page.getByRole('button', { name: 'Search' }).click();
  
  await expect(page.getByText('Running Shoes')).toBeVisible();
});

Modifying Real Responses

"What if I want the real data but with a few tweaks?"

Sometimes you want the real API response but with a few changes:

test('modify real response', async ({ page }) => {
  await page.route('**/api/user/profile', async (route) => {
    // Fetch the real response
    const response = await route.fetch();
    const json = await response.json();
    
    // Modify it
    json.name = 'Test User';
    json.email = 'test@example.com';
    
    // Return modified response
    await route.fulfill({
      response,
      body: JSON.stringify(json),
    });
  });
  
  await page.goto('/profile');
  await expect(page.getByText('Test User')).toBeVisible();
});

This is great for:

  • Hiding sensitive data in tests
  • Adding specific fields
  • Testing edge cases with mostly real data

Simulating Errors

"How do I test what happens when an API fails?"

This is where mocking really shines. You can test error states that are hard to reproduce with real APIs.

HTTP Errors

test('handle 404 error', async ({ page }) => {
  await page.route('**/api/products/999', async (route) => {
    await route.fulfill({
      status: 404,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Product not found' }),
    });
  });
  
  await page.goto('/products/999');
  await expect(page.getByText('Product not found')).toBeVisible();
});

test('handle 500 server error', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    });
  });
  
  await page.goto('/products');
  await expect(page.getByText('Something went wrong')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('handle 401 unauthorized', async ({ page }) => {
  await page.route('**/api/protected', async (route) => {
    await route.fulfill({
      status: 401,
      body: JSON.stringify({ error: 'Unauthorized' }),
    });
  });
  
  await page.goto('/protected');
  await expect(page).toHaveURL('/login'); // Redirected to login
});

Network Errors

test('handle network failure', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.abort('failed'); // Simulate network failure
  });
  
  await page.goto('/products');
  await expect(page.getByText('Unable to connect')).toBeVisible();
});

test('handle timeout', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    await route.abort('timedout');
  });
  
  await page.goto('/products');
  await expect(page.getByText('Request timed out')).toBeVisible();
});

Abort reasons: 'aborted', 'accessdenied', 'addressunreachable', 'blockedbyclient', 'connectionclosed', 'connectionfailed', 'connectionrefused', 'connectionreset', 'failed', 'internetdisconnected', 'namenotresolved', 'timedout'.

Slow Responses

"Can I test loading states?"

Absolutely. Test loading states and timeouts:

test('show loading state for slow API', async ({ page }) => {
  await page.route('**/api/products', async (route) => {
    // Wait 2 seconds before responding
    await new Promise(resolve => setTimeout(resolve, 2000));
    
    await route.fulfill({
      body: JSON.stringify({ products: [] }),
    });
  });
  
  await page.goto('/products');
  
  // Loading indicator should appear
  await expect(page.getByText('Loading...')).toBeVisible();
  
  // Then disappear when data loads
  await expect(page.getByText('Loading...')).not.toBeVisible({ timeout: 5000 });
});

Asserting on Requests

Verify that your app makes the right API calls:

test('verify search request', async ({ page }) => {
  let searchRequest: any = null;
  
  await page.route('**/api/search', async (route) => {
    searchRequest = {
      method: route.request().method(),
      body: route.request().postDataJSON(),
      headers: route.request().headers(),
    };
    
    await route.fulfill({
      body: JSON.stringify({ results: [] }),
    });
  });
  
  await page.goto('/search');
  await page.getByLabel('Query').fill('test');
  await page.getByLabel('Category').selectOption('electronics');
  await page.getByRole('button', { name: 'Search' }).click();
  
  // Verify the request
  expect(searchRequest.method).toBe('POST');
  expect(searchRequest.body).toEqual({
    query: 'test',
    category: 'electronics',
  });
});

Using waitForRequest

test('wait for specific request', async ({ page }) => {
  await page.route('**/api/analytics', route => route.fulfill({ status: 200 }));
  
  // Start waiting before triggering the action
  const requestPromise = page.waitForRequest('**/api/analytics');
  
  await page.goto('/products');
  await page.getByRole('button', { name: 'Track' }).click();
  
  const request = await requestPromise;
  expect(request.postDataJSON()).toMatchObject({
    event: 'button_click',
    button: 'Track',
  });
});

Using waitForResponse

test('wait for response', async ({ page }) => {
  const responsePromise = page.waitForResponse('**/api/products');
  
  await page.goto('/products');
  
  const response = await responsePromise;
  expect(response.status()).toBe(200);
  
  const data = await response.json();
  expect(data.products.length).toBeGreaterThan(0);
});

Partial Mocking

Mock some APIs while letting others through:

test('mock payment, keep products real', async ({ page }) => {
  // Only mock the payment API
  await page.route('**/api/payments/**', async (route) => {
    const request = route.request();
    
    if (request.method() === 'POST') {
      await route.fulfill({
        body: JSON.stringify({
          success: true,
          transactionId: 'mock-tx-123',
        }),
      });
    } else {
      await route.continue(); // Let GET requests through
    }
  });
  
  // Product APIs hit the real server
  await page.goto('/products');
  // ... real products load
  
  // But payment is mocked
  await page.getByRole('button', { name: 'Buy Now' }).click();
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Pay' }).click();
  
  await expect(page.getByText('Payment successful')).toBeVisible();
});

Conditional Routing

test('mock based on conditions', async ({ page }) => {
  await page.route('**/api/**', async (route) => {
    const url = route.request().url();
    
    // Mock third-party APIs
    if (url.includes('stripe.com') || url.includes('analytics')) {
      await route.fulfill({ status: 200, body: '{}' });
      return;
    }
    
    // Let internal APIs through
    await route.continue();
  });
  
  await page.goto('/checkout');
});

Fixtures for Mocking

Create reusable mock setups with fixtures:

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

type MockFixtures = {
  mockProducts: (products: any[]) => Promise<void>;
  mockError: (url: string, status: number) => Promise<void>;
};

export const test = base.extend<MockFixtures>({
  mockProducts: async ({ page }, use) => {
    const mock = async (products: any[]) => {
      await page.route('**/api/products', route =>
        route.fulfill({
          body: JSON.stringify({ products }),
        })
      );
    };
    await use(mock);
  },
  
  mockError: async ({ page }, use) => {
    const mock = async (url: string, status: number) => {
      await page.route(url, route =>
        route.fulfill({
          status,
          body: JSON.stringify({ error: `HTTP ${status}` }),
        })
      );
    };
    await use(mock);
  },
});

export { expect } from '@playwright/test';

Use in tests:

import { test, expect } from './fixtures';

test('empty products', async ({ page, mockProducts }) => {
  await mockProducts([]);
  await page.goto('/products');
  await expect(page.getByText('No products found')).toBeVisible();
});

test('products load error', async ({ page, mockError }) => {
  await mockError('**/api/products', 500);
  await page.goto('/products');
  await expect(page.getByText('Failed to load')).toBeVisible();
});

HAR Files (Record & Replay)

"Can I just record real API responses and replay them?"

Yep! Record real API responses and replay them in tests. How cool is that?

Recording a HAR File

// playwright.config.ts
export default defineConfig({
  use: {
    // Record network to HAR file
    recordHar: {
      path: 'tests/mocks/api.har',
      urlFilter: '**/api/**',
    },
  },
});

Or record programmatically:

test('record HAR', async ({ page, context }) => {
  await context.routeFromHAR('tests/mocks/api.har', { update: true });
  
  await page.goto('/products');
  // ... interact with the page
  // HAR file is saved when context closes
});

Using a HAR File

test('replay from HAR', async ({ page, context }) => {
  // Use recorded responses
  await context.routeFromHAR('tests/mocks/api.har');
  
  await page.goto('/products');
  // Responses come from the HAR file
});

HAR files are JSON, so you can edit them manually if needed.

Real-World Example

Let's put it all together with an e-commerce checkout flow:

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

test.describe('checkout flow', () => {
  test.beforeEach(async ({ page }) => {
    // Mock inventory check — always in stock
    await page.route('**/api/inventory/*', route =>
      route.fulfill({
        body: JSON.stringify({ inStock: true, quantity: 10 }),
      })
    );
    
    // Mock payment processor — always succeeds
    await page.route('**/api/payments', route =>
      route.fulfill({
        body: JSON.stringify({
          success: true,
          transactionId: `tx-${Date.now()}`,
        }),
      })
    );
    
    // Mock shipping rates
    await page.route('**/api/shipping/rates', route =>
      route.fulfill({
        body: JSON.stringify({
          rates: [
            { id: 'standard', name: 'Standard', price: 5.99, days: 5 },
            { id: 'express', name: 'Express', price: 15.99, days: 2 },
          ],
        }),
      })
    );
  });
  
  test('complete purchase', async ({ page }) => {
    await page.goto('/products/awesome-widget');
    await page.getByRole('button', { name: 'Add to Cart' }).click();
    await page.getByRole('link', { name: 'Checkout' }).click();
    
    // Select shipping
    await page.getByLabel('Standard').check();
    await expect(page.getByText('$5.99')).toBeVisible();
    
    // Enter payment
    await page.getByLabel('Card number').fill('4242424242424242');
    await page.getByLabel('Expiry').fill('12/25');
    await page.getByLabel('CVC').fill('123');
    
    await page.getByRole('button', { name: 'Place Order' }).click();
    
    await expect(page.getByText('Order confirmed')).toBeVisible();
    await expect(page.getByText(/tx-\d+/)).toBeVisible();
  });
  
  test('out of stock shows error', async ({ page }) => {
    // Override mock for this test
    await page.route('**/api/inventory/*', route =>
      route.fulfill({
        body: JSON.stringify({ inStock: false, quantity: 0 }),
      })
    );
    
    await page.goto('/products/awesome-widget');
    await expect(page.getByText('Out of Stock')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeDisabled();
  });
  
  test('payment failure shows retry option', async ({ page }) => {
    // Override payment mock
    await page.route('**/api/payments', route =>
      route.fulfill({
        status: 400,
        body: JSON.stringify({ error: 'Card declined' }),
      })
    );
    
    await page.goto('/cart');
    await page.getByRole('button', { name: 'Checkout' }).click();
    await page.getByLabel('Card number').fill('4000000000000002');
    await page.getByRole('button', { name: 'Place Order' }).click();
    
    await expect(page.getByText('Card declined')).toBeVisible();
    await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible();
  });
});

What's Next?

You're now a network mocking master:

  • page.route() intercepts requests matching URL patterns
  • Mock responses with route.fulfill() — status, body, headers
  • Modify real responses by fetching then altering
  • Simulate errors with status codes or route.abort()
  • Verify requests with waitForRequest and waitForResponse
  • Partial mocking — mock some APIs, let others through
  • HAR files for recording and replaying real responses

Sometimes you need to test APIs directly, without a browser. Learn about API testing with Playwright. Let's go!