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:
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/123will 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:
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:
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:
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 errortimedout— 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:
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:
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:
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:
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. Useasyncin 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.






Comments
Loading comments…