Mocking Network Requests
Intercept and mock API calls for faster, more reliable tests
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
waitForRequestandwaitForResponse - 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!