Stop Flaky UI Tests: Mock APIs with Playwright Route Interception
Playwright's page.route() intercepts network requests at the browser layer, letting you mock APIs without external dependencies. This post shows a concrete pattern for reliable UI tests, common pitfalls with pattern matching and content-type, and how to verify mocks actually fired using traces.
05 Jul 2025, 03:25 UTC

The Problem: External APIs Make UI Tests Unreliable
Your end-to-end test suite passes locally but fails in CI. The culprit? A third-party payment API that times out, a staging environment that resets overnight, or a rate limit you hit during parallel runs. These failures aren't in your code—they're in the network.
Playwright's page.route() method lets you intercept outbound requests and return controlled responses. No test containers, no seed data, no flaky dependencies. The feature has been stable since Playwright 1.30 and works across Chromium, Firefox, and WebKit.
How Route Interception Works
When you call await page.route('**/api/**', handler), Playwright registers a listener for any request matching the glob pattern. The handler receives a Route object with request() and fulfill() methods. You can inspect headers, modify the response, or short-circuit the request entirely.
Key behavior: interception happens at the browser network layer, before the request leaves the process. This means zero latency, no proxy configuration, and no changes to your application code.
Worked Example: Mocking a User Profile Endpoint
Assume a React dashboard that fetches /api/v1/user/profile on mount. The test should verify the UI renders the user's name without hitting the real API.
// tests/profile.spec.ts
import { test, expect } from '@playwright/test';
test('displays user name from mocked profile API', async ({ page }) => {
// Intercept before navigation
await page.route('**/api/v1/user/profile', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 42, name: 'Ada Lovelace', role: 'admin' }),
});
});
await page.goto('/dashboard');
await expect(page.getByText('Welcome, Ada Lovelace')).toBeVisible();
});
Run with npx playwright test tests/profile.spec.ts from your project root. No special permissions needed beyond Node and Playwright installation. The test passes even if the API server is down.
Trade-offs and Limitations
- Pattern matching is literal.
**/api/**won't catch/api/v1/user/profileif your app calls/api/v1/user/profile/(trailing slash). Use**/api/**/*or a regex:/\/api\//. - Content-Type matters. If your app expects
application/json; charset=utf-8but you sendapplication/json, some frameworks reject the response. Match the exact header your backend returns. - Cross-test leakage. Routes persist for the page lifetime. In a test suite, wrap each test in a fixture that calls
page.unrouteAll()inafterEach, or usetest.use({ page: ... })with a fresh context per test. - No network timing simulation.
fulfill()returns instantly. If you need latency or throttling, addawait new Promise(r => setTimeout(r, 100))inside the handler—though this defeats the speed benefit.
Verifying the Mock Actually Ran
Two practical checks:
- Assert the mock was hit. Capture the route call count:
let hitCount = 0; await page.route('**/api/v1/user/profile', async (route) => { hitCount++; await route.fulfill({ /* ... */ }); }); await page.goto('/dashboard'); expect(hitCount).toBe(1); - Inspect the trace. Run
npx playwright test --trace on, open the generatedtrace.zipwithnpx playwright show-trace trace.zip. The Network tab shows "(from ServiceWorker)" for intercepted requests—confirming no outbound TCP connection occurred.
When to Use This vs. MSW or Nock
Playwright routes run in the browser process, so they work for any network stack (fetch, axios, graphql-ws). Mock Service Worker (MSW) runs in a ServiceWorker and offers richer request matching but requires a build step and doesn't intercept non-fetch requests (e.g., XMLHttpRequest in legacy code). Nock patches Node's http module—useless for browser tests. Choose Playwright routes when you want zero-config, browser-native interception with minimal test code.
Next Steps
Add a route fixture to your playwright.config.ts so every test gets a clean slate:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});
Then create a shared fixture file that calls page.unrouteAll() after each test. Your CI will thank you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.