Intercept outgoing network requests in Playwright to mock API responses, assert on request payloads, simulate slow or failed networks, and test offline behaviour.
E2E tests that depend on a real backend API are slow and fragile — they fail when the API is slow, when test data changes, or when the staging environment is down. Network interception gives you the best of both worlds: the test exercises the real browser rendering and JavaScript, but the network responses are controlled and predictable.
Playwright's page.route() intercepts requests before they leave the browser. You can return mock responses, assert on what the browser sent, simulate failures, or add latency. This lesson covers every network interception pattern you will use in production E2E suites.
1import { test, expect } from '@playwright/test';
2
3test('displays course list from mocked API', async ({ page }) => {
4 // Intercept before navigating
5 await page.route('**/api/courses', async (route) => {
6 await route.fulfill({
7 status: 200,
8 contentType: 'application/json',
9 body: JSON.stringify({
10 data: [
11 { id: '1', title: 'TypeScript Fundamentals', price: 49.99, level: 'BEGINNER' },
12 { id: '2', title: 'React Advanced', price: 99.99, level: 'ADVANCED' },
13 ],
14 total: 2,
15 page: 1,
16 }),
17 });
18 });
19
20 await page.goto('/courses');
21
22 // UI renders based on mocked data
23 await expect(page.getByText('TypeScript Fundamentals')).toBeVisible();
24 await expect(page.getByText('React Advanced')).toBeVisible();
25 await expect(page.getByRole('article')).toHaveCount(2);
26});
27
28test('shows empty state when API returns no courses', async ({ page }) => {
29 await page.route('**/api/courses', (route) =>
30 route.fulfill({
31 status: 200,
32 body: JSON.stringify({ data: [], total: 0, page: 1 }),
33 })
34 );
35
36 await page.goto('/courses');
37 await expect(page.getByText('No courses available')).toBeVisible();
38 await expect(page.getByRole('article')).toHaveCount(0);
39});
40
41test('shows error state when API returns 500', async ({ page }) => {
42 await page.route('**/api/courses', (route) =>
43 route.fulfill({
44 status: 500,
45 body: JSON.stringify({ error: 'Internal server error' }),
46 })
47 );
48
49 await page.goto('/courses');
50 await expect(page.getByRole('alert')).toContainText('Failed to load courses');
51 await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
52});
53
54// URL patterns for route():
55await page.route('**/api/courses', handler); // glob pattern
56await page.route('https://api.example.com/**', handler); // domain + wildcard
57await page.route(/\/api\/courses/, handler); // regex
58await page.route((url) => url.includes('/api/'), handler); // predicate functionUse page.waitForRequest() to capture what the browser sent and assert on URL, method, headers, and body.
1test('search sends correct query parameters', async ({ page }) => {
2 // Set up mock before navigating
3 await page.route('**/api/courses/search*', (route) =>
4 route.fulfill({ body: JSON.stringify({ data: [], total: 0 }) })
5 );
6
7 await page.goto('/courses');
8
9 // Start waiting for the request before triggering it
10 const searchRequestPromise = page.waitForRequest(
11 (req) => req.url().includes('/api/courses/search')
12 );
13
14 await page.getByRole('searchbox').fill('TypeScript');
15 await page.getByRole('button', { name: 'Search' }).click();
16
17 const request = await searchRequestPromise;
18
19 // Assert on the outbound request
20 expect(request.method()).toBe('GET');
21 const url = new URL(request.url());
22 expect(url.searchParams.get('q')).toBe('TypeScript');
23});
24
25test('enrolment POST sends correct payload and auth header', async ({ page }) => {
26 await page.route('**/api/enrollments', async (route) => {
27 const request = route.request();
28
29 // Assert inside the interceptor
30 expect(request.method()).toBe('POST');
31 const body = JSON.parse(request.postData() ?? '{}');
32 expect(body.courseId).toBe('course-001');
33
34 // Assert auth header is present
35 expect(request.headers()['authorization']).toMatch(/^Bearer /);
36
37 await route.fulfill({
38 status: 201,
39 body: JSON.stringify({ enrollmentId: 'enr-001', status: 'active' }),
40 });
41 });
42
43 await page.goto('/courses/course-001');
44 await page.getByRole('button', { name: 'Enrol Now' }).click();
45 await expect(page.getByRole('status')).toContainText('enrolled');
46});
47
48// Wait for a response (not request) after an action:
49const responsePromise = page.waitForResponse(
50 (res) => res.url().includes('/api/checkout') && res.status() === 200
51);
52await page.getByRole('button', { name: 'Pay Now' }).click();
53const response = await responsePromise;
54const data = await response.json();
55expect(data.orderId).toBeDefined();1test('shows loading skeleton during slow API response', async ({ page }) => {
2 await page.route('**/api/courses', async (route) => {
3 // Delay response by 2 seconds
4 await route.fulfill({
5 status: 200,
6 body: JSON.stringify({ data: [{ id: '1', title: 'TypeScript' }] }),
7 delay: 2000,
8 });
9 });
10
11 await page.goto('/courses');
12
13 // Loading skeleton should be visible during the delay
14 await expect(page.getByTestId('course-skeleton')).toBeVisible();
15
16 // After load, skeleton disappears and content appears
17 await expect(page.getByText('TypeScript')).toBeVisible({ timeout: 5000 });
18 await expect(page.getByTestId('course-skeleton')).not.toBeVisible();
19});
20
21test('shows retry button on network failure', async ({ page }) => {
22 await page.route('**/api/courses', (route) => route.abort('failed'));
23 // abort reasons: 'aborted', 'accessdenied', 'failed', 'timedout'
24
25 await page.goto('/courses');
26 await expect(page.getByRole('alert')).toContainText('Connection error');
27 await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
28});
29
30test('retry button re-fetches successfully after transient failure', async ({ page }) => {
31 let requestCount = 0;
32
33 await page.route('**/api/courses', async (route) => {
34 requestCount++;
35 if (requestCount === 1) {
36 // First request fails
37 await route.abort('failed');
38 } else {
39 // Subsequent requests succeed
40 await route.fulfill({
41 body: JSON.stringify({ data: [{ id: '1', title: 'TypeScript' }] }),
42 });
43 }
44 });
45
46 await page.goto('/courses');
47 await expect(page.getByRole('alert')).toBeVisible();
48
49 await page.getByRole('button', { name: 'Try again' }).click();
50 await expect(page.getByText('TypeScript')).toBeVisible();
51 expect(requestCount).toBe(2);
52});
53
54// Offline simulation:
55test('shows offline banner when network is unavailable', async ({ page, context }) => {
56 await page.goto('/courses');
57 await context.setOffline(true);
58 await page.reload();
59 await expect(page.getByText('You are offline')).toBeVisible();
60 await context.setOffline(false);
61});Mock only specific endpoints while allowing all other requests to reach the real server.
1test('uses real courses API but mocks payment', async ({ page }) => {
2 // Only intercept payment requests
3 await page.route('**/api/payments/**', async (route) => {
4 const url = route.request().url();
5 if (url.includes('/create-intent')) {
6 await route.fulfill({
7 body: JSON.stringify({ clientSecret: 'pi_test_secret' }),
8 });
9 } else {
10 // Pass all other payment routes through
11 await route.continue();
12 }
13 });
14
15 // Courses data is real (from the real API)
16 await page.goto('/courses'); // real API call
17 await page.getByRole('article').first().getByRole('button', { name: 'Enrol' }).click();
18 // Payment intent creation is mocked
19});
20
21// Modify real responses (add/remove fields):
22await page.route('**/api/user/profile', async (route) => {
23 const response = await route.fetch(); // fetch the real response
24 const json = await response.json();
25
26 // Add or modify fields in the real response
27 await route.fulfill({
28 response,
29 body: JSON.stringify({ ...json, betaFeatures: ['ai-assistant'] }),
30 });
31});
32
33// Unregister a route:
34const handler = (route: Route) => route.fulfill({ body: '{}' });
35await page.route('**/api/courses', handler);
36// ... test ...
37await page.unroute('**/api/courses', handler); // remove this specific handler
38// OR:
39await page.unrouteAll(); // remove all handlers1// WebSocket interception:
2test('real-time notification appears when WebSocket message arrives', async ({ page }) => {
3 await page.goto('/dashboard');
4
5 // Listen for WebSocket connection
6 const wsPromise = page.waitForEvent('websocket');
7 const ws = await wsPromise;
8
9 // Simulate a message from the server
10 ws.on('framereceived', (event) => {
11 // You can observe messages the server sent
12 console.log('WS received:', event.payload);
13 });
14
15 // Trigger a WebSocket message from the test server
16 // (depends on your test setup — you need a way to send a message)
17 await page.evaluate(() => {
18 // Use exposed test helper to trigger server-side WS message
19 (window as any).__testHelpers.triggerNotification({ type: 'enrollment', courseId: '1' });
20 });
21
22 await expect(page.getByRole('status')).toContainText('New notification');
23});
24
25// Server-Sent Events — just route the SSE endpoint:
26await page.route('**/api/events', async (route) => {
27 await route.fulfill({
28 status: 200,
29 headers: {
30 'Content-Type': 'text/event-stream',
31 'Cache-Control': 'no-cache',
32 },
33 body: 'data: {"type":"course_update","courseId":"1"}\n\n',
34 });
35});A complete checkout E2E test using page.route() to mock Stripe API, assert on request payloads, simulate payment failure, and test the retry flow.
1// e2e/checkout.spec.ts
2import { test, expect, type Route, type Request } from '@playwright/test';
3
4const COURSE = { id: 'course-ts-101', title: 'TypeScript Fundamentals', price: 49.99 };
5
6test.describe('Checkout Flow', () => {
7 test.beforeEach(async ({ page }) => {
8 // Mock the course detail API
9 await page.route(`**/api/courses/${COURSE.id}`, (route) =>
10 route.fulfill({
11 body: JSON.stringify({ ...COURSE, published: true, instructorName: 'Alice Chen' }),
12 })
13 );
14 });
15
16 test('successful checkout redirects to confirmation page', async ({ page }) => {
17 await page.route('**/api/payments/create-intent', (route) =>
18 route.fulfill({ body: JSON.stringify({ clientSecret: 'pi_test_secret_123' }) })
19 );
20 await page.route('**/api/payments/confirm', (route) =>
21 route.fulfill({
22 status: 200,
23 body: JSON.stringify({ orderId: 'ord-001', status: 'succeeded' }),
24 })
25 );
26
27 await page.goto(`/courses/${COURSE.id}`);
28 await page.getByRole('button', { name: 'Enrol Now — $49.99' }).click();
29 await expect(page).toHaveURL('/checkout');
30
31 await page.getByLabel('Card number').fill('4242424242424242');
32 await page.getByLabel('Expiry').fill('12/28');
33 await page.getByLabel('CVC').fill('123');
34
35 const confirmRequestPromise = page.waitForRequest(
36 (req) => req.url().includes('/api/payments/confirm')
37 );
38 await page.getByRole('button', { name: 'Pay $49.99' }).click();
39
40 const confirmReq = await confirmRequestPromise;
41 const body = JSON.parse(confirmReq.postData() ?? '{}');
42 expect(body.courseId).toBe(COURSE.id);
43 expect(body.clientSecret).toBe('pi_test_secret_123');
44
45 await expect(page).toHaveURL('/checkout/success');
46 await expect(page.getByRole('heading')).toContainText('Payment successful');
47 await expect(page.getByText('ord-001')).toBeVisible();
48 });
49
50 test('card declined shows error and allows retry', async ({ page }) => {
51 await page.route('**/api/payments/create-intent', (route) =>
52 route.fulfill({ body: JSON.stringify({ clientSecret: 'pi_test_decline' }) })
53 );
54
55 let attemptCount = 0;
56 await page.route('**/api/payments/confirm', (route) => {
57 attemptCount++;
58 if (attemptCount === 1) {
59 return route.fulfill({
60 status: 402,
61 body: JSON.stringify({ error: 'Card declined', code: 'card_declined' }),
62 });
63 }
64 return route.fulfill({
65 status: 200,
66 body: JSON.stringify({ orderId: 'ord-002', status: 'succeeded' }),
67 });
68 });
69
70 await page.goto('/checkout');
71 await page.getByLabel('Card number').fill('4000000000000002'); // decline card
72 await page.getByLabel('Expiry').fill('12/28');
73 await page.getByLabel('CVC').fill('123');
74 await page.getByRole('button', { name: /pay/i }).click();
75
76 await expect(page.getByRole('alert')).toContainText('Card declined');
77 await expect(page.getByRole('button', { name: /pay/i })).toBeEnabled();
78
79 // Retry with a different card
80 await page.getByLabel('Card number').fill('4242424242424242');
81 await page.getByRole('button', { name: /pay/i }).click();
82
83 await expect(page).toHaveURL('/checkout/success');
84 expect(attemptCount).toBe(2);
85 });
86
87 test('shows loading state during payment processing', async ({ page }) => {
88 await page.route('**/api/payments/create-intent', (route) =>
89 route.fulfill({ body: JSON.stringify({ clientSecret: 'pi_test' }) })
90 );
91 await page.route('**/api/payments/confirm', async (route) => {
92 await route.fulfill({
93 body: JSON.stringify({ orderId: 'ord-003', status: 'succeeded' }),
94 delay: 2000,
95 });
96 });
97
98 await page.goto('/checkout');
99 await page.getByLabel('Card number').fill('4242424242424242');
100 await page.getByLabel('Expiry').fill('12/28');
101 await page.getByLabel('CVC').fill('123');
102
103 const payButton = page.getByRole('button', { name: /pay/i });
104 await payButton.click();
105
106 // Button should be disabled and show processing state during 2s delay
107 await expect(payButton).toBeDisabled();
108 await expect(payButton).toContainText('Processing...');
109
110 await expect(page).toHaveURL('/checkout/success', { timeout: 10_000 });
111 });
112});This suite intercepts three distinct endpoints: the course API (consistent across tests via beforeEach), payment intent creation, and payment confirmation. The successful test captures the outbound confirmation request and asserts on its body — verifying that the correct courseId and clientSecret were sent. The decline-and-retry test uses a counter to return failure on the first call and success on the second. The loading state test uses a delayed fulfill to simulate processing time and verifies the disabled button state during the wait.
The next lesson covers visual regression testing: taking full-page screenshots with Playwright, comparing against baselines, and managing snapshot updates in CI.
Your progress is saved in this browser, on this device only.