This episode covers mocking API responses with route interception, testing offline mode and network failures, validating backend behavior through UI flows, as well as integration with API test tools and service mocks.

Modern applications almost always depend on APIs — and an unstable API is the main enemy of end-to-end tests. This episode 12 covers two complementary abilities: API testing directly via the request fixture, and network interception to control API responses at the browser level.
With route interception, you can make the application behave as if the server responds with specific data, fails entirely, or is slow — all without touching the real server. This unlocks scenarios that were previously impossible to test reliably: offline mode, error handling, loading states, and data contracts.
page.route intercepts requests matching a URL pattern before they hit the network. Inside the handler, you decide what happens: fulfill the request with fabricated data, continue to the server, or abort it.
import { test, expect } from '@playwright/test';
test('product list from mock', async ({ page }) => {
await page.route('**/api/products', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Laptop', price: 15000000 },
{ id: 2, name: 'Mouse', price: 250000 },
]),
});
});
await page.goto('/products');
await expect(page.getByText('Laptop')).toBeVisible();
await expect(page.getByText('Mouse')).toBeVisible();
});route.fulfill(...) returns a fabricated response without ever contacting the server. The test above verifies how the UI renders a product list with fully controlled data.
The URL pattern **/api/products uses glob syntax: ** matches any path segment. You can target a specific API precisely. To modify a real server response — for example, injecting a field — use route.continue():
await page.route('**/api/products', async (route) => {
const response = await route.fetch();
const data = await response.json();
data.push({ id: 99, name: 'Extra Product', price: 1000 });
await route.fulfill({
response,
body: JSON.stringify(data),
});
});route.fetch() fetches the original response from the server, then you modify the payload before route.fulfill. This pattern is useful for adding test data without changing the server contract.
To test how the application handles failures, route.abort makes requests fail as if the connection dropped:
await page.route('**/api/*', (route) => route.abort('failed'));
await page.goto('/home');
await expect(page.getByText('Failed to load data')).toBeVisible();
await expect(page.getByRole('button', { name: 'Try Again' })).toBeVisible();route.abort('failed') aborts all API requests with the failed reason. A good application shows an error message and a retry button — and that's what you verify.
Test how the UI shows a loading state before data arrives by delaying the fulfill:
await page.route('**/api/slow', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 2000));
await route.fulfill({ status: 200, body: '{}' });
});Delaying the response with setTimeout inside the handler gives you a chance to verify that a spinner or skeleton appears while waiting. Make sure the test also waits for the final data, not just checks the loading state.
To test full offline mode, use a context with an option that disables the network:
const context = await browser.newContext({
offline: true,
});offline: true makes the entire context run without a connection. Applications using service workers and caches will keep working — and that's what's worth testing for the offline experience.
Besides controlling responses, you can observe what the application sends to the server:
let requestBody;
await page.route('**/api/checkout', (route) => {
requestBody = route.request().postDataJSON();
route.continue();
});
await page.getByRole('button', { name: 'Pay' }).click();
expect(requestBody).toMatchObject({
email: 'user@example.com',
total: 15000000,
});route.request().postDataJSON() returns the request body as an object. This validates backend behavior through the UI — ensuring your clicks actually send the right data to the server.
A balanced approach: test the API contract directly with the request fixture, then test that the UI uses that API correctly via routes. If the API changes, the direct API test signals it fast without a browser; if the UI sends wrong data, the interception test catches it.
Playwright has a request fixture for HTTP testing without a browser — fast and well-suited for API smoke tests:
import { test, expect } from '@playwright/test';
test('API health check', async ({ request }) => {
const response = await request.get('/api/health');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('up');
});request.get('/api/health') sends a direct HTTP GET. Combine it with the baseURL in the configuration so relative paths work. Tests like this are cheap and can run very fast.
When test data is too complex to write inline, separate it into a JSON file and fulfill via route with that data:
import productData from '../fixtures/products.json';
await page.route('**/api/products', (route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(productData) });
});productData is loaded from a fixtures file — the same dataset can be used across many tests and is easy to update. This keeps the mock data and assertion expectations consistent.
npx playwright test tests/api.spec.tsEpisode 12 gave you full control over the network layer: mocking and modifying API responses via page.route, simulating offline mode and network failures to test error handling, validating the payloads the application sends, plus integration with the request fixture for fast API tests and centralized service mocks.
Key takeaways:
route.fulfill controls API responses without touching the server.route.abort('failed') simulates network failures.route.request().postDataJSON validates the payloads the application sends.request fixture gives fast API tests without a browser.In the next episode we'll discuss security and isolation — running tests in isolated browser contexts, handling secrets and environment variables safely, protecting test data and credentials, and preventing state leakage between tests.