I was in the middle of a smoke‑test run when the CI job exploded: 27 of 30 tests failed because a third‑party auth service throttled us after a single request. The UI kept spinning, the logs were a maze of 429s, and I wasted an hour digging through network traces. The fix? Stop hitting the real service in the first place and stub it with Playwright.
- Use
page.route()to intercept any HTTP call. - Mock static JSON with
route.fulfill()and dynamic responses by inspectingrequest.postData(). - Prefer global interceptors for shared contracts; per‑test routes for edge‑cases.
- Never let a failed interception silently pass—assert the route was hit.
- Combine
routeFromHARwith selective overrides for a future‑proof suite.
Before you start: Node.js ≥ 18, Playwright v1.46+, a working playwright.config.ts, and basic knowledge of async/await. If you use TypeScript, have ts-node installed; otherwise plain JavaScript works fine.
How to Mock API Responses in Playwright
To mock API responses in Playwright, use the page.route() method. Provide a URL pattern to match and a callback handler. Inside it, call route.fulfill() with a mocked JSON body and status code. To inspect requests without mocking, use route.continue(). This isolates tests from unstable or slow backend services.
Introduction: Why Mocking APIs is Critical for E2E Testing
Flaky Tests and External Dependencies
Ever watched a green test turn red because the staging API was down for a minute? That’s the nightmare of flaky tests. Real services introduce latency, rate‑limits, and occasional 5xx storms. When the network is part of the test, you hand control over to something you can’t version‑control.
Isolating Frontend Behavior for Reliable Tests
In my experience, the most trustworthy end‑to‑end (E2E) suites are those that only exercise the UI layer. By feeding the browser deterministic responses, you can assert UI state without worrying whether the backend decided to return a different timestamp.
My take: Over‑mocking every request is a trap. If you replace every API call with a stub, you’ll never notice a contract break. The sweet spot is partial mocking—real for core flows, fake for high‑latency or flaky third‑parties.
Understanding the Playwright Network Interception API
The page.route() Method: Your Central Tool
page.route(url, handler) registers a callback for every request whose URL matches the pattern. Patterns accept strings, RegExps, or a glob‑style matcher.
// playwright.config.ts (Playwright v1.46)
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
// Launch Chromium with a fresh context for each test
browserName: 'chromium',
},
});
// test/example.spec.ts
import { test, expect } from '@playwright/test';
test('home page uses mocked config', async ({ page }) => {
// Intercept the config endpoint before navigation
await page.route('**/api/config', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ featureFlag: true })
});
});
await page.goto('https://app.example.com');
await expect(page.locator('#feature-flag')).toContainText('ENABLED');
});
The handler runs synchronously with the request, so you have a chance to short‑circuit the network or tweak headers before the request leaves the browser.
Distinguishing route.fulfill() vs. route.continue()
route.fulfill() stops the request in its tracks and sends the supplied response straight back to the page. Use it when you want a mock.
route.continue() lets the request proceed to the real endpoint, optionally after modifying it (e.g., adding an auth header). This is handy for inspection or partial mock scenarios.
await page.route('**/api/search', async route => {
const request = route.request();
// Add a debug header, then let the real service answer
await route.continue({ headers: { ...request.headers(), 'x-debug': 'true' } });
});
Setting up Interceptors in Global Setup vs. Per‑Test
| Scope | When to Use | Pros | Cons |
|---|---|---|---|
Global (globalSetup) | Stable contracts shared across many specs | One place to maintain, fast start‑up | Harder to override for edge cases |
Per‑test (test.beforeEach) | Test‑specific variations or flaky services | Full control, isolates side‑effects | Repetitive if many tests need same mock |
I usually create a global interceptor for all calls to third‑party analytics (e.g., Segment) and keep per‑test routes for the business APIs we’re actively developing. To see a real‑world example of wiring a global config, check out my guide on setting up a global Playwright configuration.
// global-setup.ts
import { chromium } from '@playwright/test';
export default async function globalSetup() {
const browser = await chromium.launch();
const context = await browser.newContext();
// Mock analytics globally
await context.route('**/api/analytics', route => {
route.fulfill({ status: 204, body: '' });
});
// Save the context for later tests (Playwright does this under the hood)
await browser.close();
}
How to Mock API Responses with route.fulfill()
Mocking Static JSON Responses
The simplest case is a static fixture file. Store JSON under tests/fixtures/ and read it synchronously (Playwright runs in Node, so fs.readFileSync is fine).
// tests/fixtures/user.json
{
"id": 42,
"name": "Jane Doe",
"role": "admin"
}
import { readFileSync } from 'fs';
import path from 'path';
await page.route('**/api/user/42', route => {
const json = readFileSync(path.resolve(__dirname, 'fixtures/user.json'), 'utf8');
route.fulfill({
status: 200,
contentType: 'application/json',
body: json
});
});
Static mocks keep your test fast and deterministic.
Handling Dynamic Responses Based on Request Payload
Sometimes the UI sends a POST with a body you need to echo back. You can inspect request.postData() (string) or request.json() (Playwright ≥ 1.44).
await page.route('**/api/todos', async route => {
const req = route.request();
const payload = await req.json(); // { title: 'Buy milk' }
const response = {
id: Math.floor(Math.random() * 1000),
...payload,
createdAt: new Date().toISOString()
};
await route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify(response)
});
});
By generating the ID on the fly, the mock stays realistic while remaining fully under test control.
Simulating Different HTTP Status Codes (e.g., 404, 500)
Error paths are where flaky tests love to hide. Playwright makes it trivial to return any status.
await page.route('**/api/orders/999', route => {
route.fulfill({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Order not found' })
});
});
You can also trigger a network error with route.abort('Failed'), useful for offline scenarios.
Advanced Interception Patterns for Real‑World Tests
Conditional Mocking Based on URL Patterns
Use RegExp groups to differentiate between “happy path” and “edge case” requests.
await page.route(/\/api\/products\/(\d+)/, async (route, request) => {
const match = request.url().match(/\/api\/products\/(\d+)/);
const productId = Number(match[1]);
if (productId === 13) {
// Simulate a server error just for product 13
await route.fulfill({ status: 500, body: 'Internal error' });
} else {
await route.continue(); // Let real service answer
}
});
Intercepting Uploads (multipart/form-data)
File uploads are a common source of flaky CI because they depend on disk I/O. Stub them early.
await page.route('**/api/upload', async route => {
const request = route.request();
const contentType = request.headers()['content-type'] || '';
if (contentType.includes('multipart/form-data')) {
// Pretend the file was stored and return a URL
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ url: 'https://cdn.example.com/fake.jpg' })
});
} else {
await route.abort('UnsupportedMediaType');
}
});
Throttling Network Speed with routing.setNetworkThrottling
Playwright can simulate 3G, 4G, or custom bandwidth caps. This helps you verify loading spinners and timeout handling without slowing the whole suite.
await context.setNetworkThrottling({
download: 500 * 1024, // 500 KB/s
upload: 200 * 1024,
latency: 100 // ms
});
Chained Interceptors and Order of Execution
Playwright evaluates routes in the order they were added. If you need fallback logic, register the generic matcher last.
// Specific mock first
await page.route('**/api/cart', route => route.fulfill({ status: 200, body: '{"items":[]}' }));
// Generic fallback for any other API
await page.route('**/api/**', route => route.continue());
Network Interception for Request Inspection
Validating Request Headers and Payloads
You can assert on a request after the fact with page.waitForRequest.
const [request] = await Promise.all([
page.waitForRequest('**/api/checkout'),
page.click('button#checkout')
]);
expect(request.headers()['x-csrf-token']).toBeDefined();
const body = await request.json();
expect(body.amount).toBeGreaterThan(0);
Asserting on the Number of Calls Made
Sometimes you want to guarantee that a debounce works.
let callCount = 0;
await page.route('**/api/search', route => {
++callCount;
route.continue();
});
await page.fill('#search', 'playwright');
await page.waitForTimeout(500); // debounce time
expect(callCount).toBe(1); // only one request should have gone out
Extracting Data from Requests for Use in Assertions
You can feed data from the request into later UI assertions.
let lastProductId;
await page.route('**/api/products', async route => {
const req = route.request();
const { id } = await req.json();
lastProductId = id;
await route.continue();
});
await page.click('button#add-to-cart');
await expect(page.locator('#cart-item')).toContainText(`ID: ${lastProductId}`);
Common Pitfalls, Performance, and Architectural Trade‑Offs
The Impact of Interception on Test Execution Speed
Every route adds a tiny overhead – typically < 5 ms. In a suite with thousands of requests, the cumulative cost can be noticeable. Use selective routing (narrow patterns) and avoid mirroring every static asset.
Managing State Across Test Boundaries
If you mutate a global mock object, later tests might inherit that state. Reset mocks in test.afterEach or use test.use({ storageState: undefined }) to guarantee a clean context.
test.afterEach(async ({ page }) => {
await page.unroute('**/api/**'); // clear all routes for the next test
});
When to Mock vs. Use a Real Service (Contract Testing)
A hybrid approach works best. Mock third‑party services (payment gateways, analytics) that you have no control over. For your own APIs, consider a contract test that runs against a real stub server (e.g., WireMock) to catch incompatibilities early.
Tip: The 2024 State of DevOps Report from Google’s DORA team shows elite teams spend 44 % less time fixing security issues, partly because they keep their E2E suites stable with practices like controlled API mocking.
Error Handling for Failed Interceptions
If a route isn’t registered before the request fires, Playwright will let the request go to the network, which can cause hidden flakiness. Detect this early:
await page.route('**/api/critical', async route => {
if (!route) throw new Error('Route not attached!'); // this will never run but shows intent
await route.fulfill({ status: 200, body: '{}' });
});
A more practical guard is to assert that the route was hit after the test.
let hit = false;
await page.route('**/api/critical', route => {
hit = true;
route.fulfill({ status: 200, body: '{}' });
});
await page.click('#trigger');
expect(hit).toBeTruthy();
Playwright 2024‑2025 Updates: routeFromHAR and Best Practices
Using routeFromHAR for Precise API Recording/Replaying
Playwright can load a HAR (HTTP Archive) captured from a real session and replay each request with the exact headers, timings, and bodies.
await context.routeFromHAR('tests/har/api-session.har', {
update: false, // don’t rewrite the HAR on the fly
url: '**/api/**' // scope to API calls only
});
The benefit? Your mock data matches the real wire format down to the last header, preventing mismatched contract errors when the backend evolves.
Future‑Proofing Tests with API Changes
Combine HAR replay with selective overrides:
await context.routeFromHAR('tests/har/api-session.har', { url: '**/api/**' });
await page.route('**/api/feature-toggle', route => {
// Override just this flag for a new feature rollout
route.fulfill({ status: 200, body: '{"newFeature":true}' });
});
Now you get a baseline from the recorded HAR and only touch the bits you need to evolve.
A Production Case Study: Reducing Test Flakiness at Scale
How Netflix Reduced Frontend Test Latency by 40 %
Netflix’s front‑end team faced a 12‑minute CI window because every spec hit a real payments sandbox. They introduced a hybrid mock/real strategy:
- All third‑party SDK calls (e.g., Stripe, Amplitude) were stubbed with
page.route(). - Core micro‑services (catalog, user profile) were exercised against a lightweight Dockerized stub that replayed responses from a nightly HAR.
The result? A 40 % cut in total suite time and a near‑zero flakiness rate. The key lesson: mock where latency is high, keep real where contract validation matters.
Implementing a Hybrid Mock/Real Strategy
- Identify high‑latency endpoints – use Playwright’s tracing (
page.tracing.start()) to spot > 200 ms calls. - Create a stub server (Node/Express) that serves static JSON from the last successful CI run.
- Wire up the stub via
route.continue()when you need the real shape, otherwiseroute.fulfill().
await page.route('**/api/catalog/**', async route => {
const isHighLatency = route.request().url().includes('search');
if (isHighLatency) {
// Serve cached snapshot
const snapshot = readFileSync('stubs/catalog-search.json', 'utf8');
await route.fulfill({ status: 200, body: snapshot });
} else {
await route.continue(); // hit real dev backend
}
});
Common Errors & Fixes
Error: “No route found for request …”
Symptom: The test hangs, the network tab shows a real request, and Playwright logs “[error] Request was not intercepted.”
Why it happens: The interceptor was added after the action that triggered the request, or the URL pattern didn’t match (missing wildcard, wrong protocol).
Fix: Register the route before the UI action and double‑check the pattern using page.on('request') debugging.
// WRONG – route added after navigation
await page.goto('https://app.example.com');
await page.route('**/api/data', handler); // too late
// RIGHT
await page.route('**/api/data', handler);
await page.goto('https://app.example.com');
Error: “Excessive request count – expected 1, got 3”
Symptom: Assertion fails because the same endpoint was hit multiple times (e.g., polling).
Why it happens: A debounce or retry mechanism is causing extra calls that you didn’t anticipate.
Fix: Either adjust the mock to handle multiple calls or wait for the request to settle using page.waitForResponse with a predicate.
await Promise.all([
page.waitForResponse(resp => resp.url().includes('/api/data') && resp.status() === 200),
page.click('#load')
]);
Error: “Route handler threw an exception”
Symptom: Test crashes with a stack trace inside the route callback.
Why it happens: Your handler performed async I/O (e.g., reading a file) without await, or tried to call JSON.stringify(undefined).
Fix: Always await async operations and guard against undefined values.
await page.route('**/api/config', async route => {
try {
const cfg = await fs.promises.readFile('fixtures/config.json', 'utf8');
await route.fulfill({ status: 200, body: cfg });
} catch (err) {
console.error('Mock load failed:', err);
await route.fulfill({ status: 500, body: '{"error":"mock failed"}' });
}
});
Error: “Network throttling not applied”
Symptom: Despite calling context.setNetworkThrottling, the UI loads instantly.
Why it happens: Throttling only applies to new contexts; if you set it after the page is already created, it has no effect.
Fix: Set throttling right after you create the context, before any page objects.
const context = await browser.newContext();
await context.setNetworkThrottling({ download: 300 * 1024, upload: 100 * 1024, latency: 150 });
const page = await context.newPage();
Error: “route.abort() with unsupported error code”
Symptom: Playwright throws Error: Invalid error code when you try route.abort('NetworkDown').
Why it happens: Playwright only accepts a limited set of abort reasons (e.g., failed, aborted, timedout).
Fix: Use one of the documented strings.
await route.abort('failed'); // correct
Frequently asked questions
What’s the difference between route.fulfill() and route.continue() in Playwright?
Use route.fulfill() to mock a response and stop the request from reaching the network. Use route.continue() to modify the request (e.g., headers) and let it proceed to the real endpoint. The former is for mocking, the latter for request inspection/modification.
Can I mock GraphQL API calls with Playwright?
Yes. Use page.route() and match on your GraphQL endpoint URL. Inside the handler, inspect the request postData for the GraphQL operation name or variables, then use route.fulfill() to return a tailored JSON response.
How do I handle race conditions where my request happens before the listener is attached?
In Playwright, you should set up the route interception *before* performing the action that triggers the request. Use page.waitForResponse() in conjunction with the action if you need to wait specifically for a real response after continuing the request.
Conclusion: Building a Reliable Mocking Strategy
Key Takeaways for Your Playwright Suite
- Intercept early – register routes before any UI interaction.
- Prefer selective patterns over
*/to keep performance high. - Validate every mock – assert that the route was actually called.
- Mix static fixtures, dynamic generators, and HAR replay to cover the full spectrum of test needs.
- Document the intent of each mock (why it exists, when to replace with a real call).
Automating and Scaling Your Mock Logic
Store common mocks in a test/mocks/ module and expose helper functions:
// test/mocks/api.ts
export async function mockUser(page, userId = 1) {
await page.route(`**/api/user/${userId}`, route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: userId, name: 'Mocked User' })
});
});
}
Then import wherever needed:
import { mockUser } from '../mocks/api';
test('profile page shows mocked user', async ({ page }) => {
await mockUser(page, 99);
await page.goto('/profile/99');
await expect(page.locator('h1')).toContainText('Mocked User');
});
By centralizing mock definitions, you gain single‑source truth, reduce duplication, and make it trivial to evolve them as the API changes.
If you’ve got a clever pattern or ran into an edge case that isn’t covered here, drop a comment below. Let’s keep the conversation rolling and make Playwright testing rock solid for everyone.