Network Interception Lab
Every button on this page makes a real API call to your local server. Work through each step below, observe what actually happens, then learn how Playwright can intercept, mock, block, or monitor those same requests in your tests.
✓ Network Interception Complete
You successfully:
Replace a real server response with your own data — without changing the database.
Replace a real server response with your own data — without changing the database.
Current Execution
Live results from the request(s) above appear here.
Fetching products…
Request blocked or failed
This error state appears when route.abort() intercepts the request. In real life: server is down.
No products available
Your mock returned products: []. This is the edge case state you're testing.
0 products loaded
0 out of stock
✓ What you learned
✓ route.fulfill() replaces a real server response with data you choose.
✓ Verified the app's behavior with an empty product catalog.
✓ Verified the app's behavior when every product is out of stock.
✓ Confirmed the real, unmocked API response shape (10 products, 2 out of stock).
What you'll learn
You'll learn how Playwright can intercept a network request and swap the server's response with any data you choose. This is called response mocking, and it's used in almost every serious test suite.
🧪 Try it yourself
Click the button below
Ten products should appear with their names and prices. That's the real server responding.
Open DevTools → Network tab
Click the button again. Find /api/products.json in the list. Click it and read the response JSON.
That request is what Playwright will intercept
In your test, you can replace that exact JSON with an empty list, fake products, or anything else.
Request lifecycle for this step
🌍 Real-world example
Your product catalog always has items in the database. Your manager asks: "What does the website look like when there are zero products?"
You can't delete production data just to test one screen. Instead, Playwright intercepts the API response and returns an empty list. The website behaves exactly as if the server sent zero products.
Auth: None
Returns the full product catalog.
Example response
{ "products": [ { "id": 1, "name": "...", "category": "...", "price": 89.99, "inStock": true } ], "count": 10 }
200 OK on success. This endpoint has no error branch of its own in this app — the error/aborted state shown in this panel is produced by Playwright's route.abort(), not by the server.
Common mistake
Asserting response.ok() instead of the exact response shape. A mock can return 200 with the wrong body and a loose assertion will miss it.
Best practice
Prefer route.fulfill({ json: ... }) over manually building headers and stringifying JSON yourself — Playwright sets the content-type for you.
Interview question
“What's the difference between route.fulfill() and route.continue()?” — fulfill() supplies the entire response yourself (the real server may never even be contacted); continue() lets the real request proceed, optionally with a modified URL, headers, or body.
💡 Playwright method — route.fulfill()
Intercepts a request and returns any response you define — without the request ever reaching the server.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('mock an empty product catalog', async ({ page }) => {
// 1. Mock empty catalog → assert empty state
await page.route('/api/products.json', async route => {
await route.fulfill({ json: { products: [], count: 0 } });
});
await page.goto('/shop.html');
await page.locator('[data-testid="nlb-load-products-btn"]').click();
await expect(page.locator('[data-testid="nlb-products-empty"]')).toBeVisible();
});
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
test('mock all products as out-of-stock', async ({ page }) => {
await page.route('/api/products.json', async route => {
const res = await route.fetch();
const data = await res.json();
const body = { ...data, products: data.products.map(p => ({ ...p, inStock: false })) };
await route.fulfill({ json: body });
});
await expect(page.locator('[data-testid="nlb-out-of-stock-count"]')).toHaveText('10');
});
// Verify real (unmocked) data — 10 products, 2 out-of-stock
test('real catalog has 10 products, 2 out of stock', async ({ page }) => {
await page.locator('[data-testid="nlb-load-products-btn"]').click();
await expect(page.locator('[data-testid="nlb-products-count"]')).toHaveText('10');
await expect(page.locator('[data-testid="nlb-out-of-stock-count"]')).toHaveText('2');
});
Test whether your app correctly blocks users from accessing data that isn't theirs.
Test whether your app correctly blocks users from accessing data that isn't theirs.
Requires: You must be logged in — this step fetches your real order data.
Current Execution
Live results from the request(s) above appear here.
Fetching order details…
Login required
This panel loads a real order from your account. Log in first then return here.
No orders yet
Place an order on the shop page first. Then come back and click the button to see real order data.
Order Loaded ✓
403 — Access Denied
Order not found or you are not authorized to view this order.
✓ What you learned
✓ What IDOR (Insecure Direct Object Reference) is and why it's one of the most common web app vulnerabilities.
✓ Verified the app can correctly block access to another user's order.
✓ Used route.continue() to redirect a request without involving the real target endpoint.
✓ Confirmed a 403/not-authorized response is handled gracefully by the UI.
What you'll learn
You'll learn how to verify that your application properly protects private data. This type of security vulnerability — where one user can access another user's records — is called IDOR (Insecure Direct Object Reference). It's one of the most common bugs in web applications.
🧪 Try it yourself
Make sure you are logged in
This panel fetches a real order from your account. If you're not logged in, you'll see a prompt.
Click "Fetch Order Details"
Your most recent order appears — real data from the database, fetched via the API.
Notice the order ID in the response
In your Playwright test, you intercept that request and redirect it to a different endpoint. The "Access Denied" panel should appear, proving the UI handles security errors correctly.
Request lifecycle for this step
🌍 Real-world example
You're logged in and viewing your order at /order-details?id=PW-1234.
What if a hacker changes that ID to PW-5678 — someone else's order? A secure app should return an error. An insecure one exposes private data.
Playwright tests this by intercepting the request and redirecting it to an "access denied" endpoint — verifying the UI shows the right error.
Auth: Session cookie (pw_session_token)
Returns the logged-in user's own orders.
Example response
{ "orders": [ { "id": 4821, "total": 129.99, ... } ] }
200 OK if logged in. 401 Unauthorized if no valid session cookie is present.
Auth: Session cookie (pw_session_token)
Returns full details for one order — only if it belongs to the caller.
Example response
{ "order": { "id": 4821, "items": [...], "total": 129.99 } }
200 OK with the order if it belongs to the caller. 401 if not logged in. An order ID that exists but belongs to someone else, or doesn't exist at all, results in an error payload (no "order" key) that the UI renders as “403 — Access Denied” — this is the IDOR protection being exercised.
Common mistake
Only testing the “happy path” login-and-view-your-own-order flow, and never testing what happens when a caller requests someone else's ID.
Best practice
IDOR is one of OWASP's most frequently reported vulnerability classes. Always add at least one test where an authenticated user tries to access another user's resource by ID.
Interview question
“What is IDOR?” — Insecure Direct Object Reference: when an application exposes a direct reference to an internal object (like a numeric order ID) without verifying the caller is actually authorized to access that specific object.
💡 Playwright method — route.continue()
Lets a request continue — but with a different URL, headers, or body. The server never sees the original request.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('IDOR — redirect order lookup to the 403 endpoint', async ({ page }) => {
await page.route('/api/order-details.json*', route =>
route.continue({
url: 'http://localhost:3001/api/order-not-found.json' // must match baseURL
})
);
await page.goto('/network-lab.html');
await page.locator('[data-testid="nlb-load-order-btn"]').click();
await expect(page.locator('[data-testid="nlb-order-not-found"]')).toBeVisible();
await expect(page.locator('[data-testid="nlb-order-not-found-detail"]'))
.toContainText('not authorized');
});
// Wildcard pattern — intercepts ANY order ID, not just one
test('IDOR — wildcard intercepts every order lookup', async ({ page }) => {
await page.route('**/api/order-details.json*', route =>
route.continue({ url: 'http://localhost:3001/api/order-not-found.json' }) // must match baseURL
);
await page.goto('/network-lab.html');
await page.locator('[data-testid="nlb-load-order-btn"]').click();
await expect(page.locator('[data-testid="nlb-order-not-found"]')).toBeVisible();
});
Test how your application behaves when the server is completely unreachable.
Test how your application behaves when the server is completely unreachable.
Current Execution
Live results from the request(s) above appear here.
Fetching lab data…
Data loaded ✓
Server unavailable
The request was blocked before it reached the server. Looks like something went wrong — try again in 24 hours.
✓ What you learned
✓ route.abort() simulates a completely unreachable server — without stopping any real server.
✓ Verified the app shows a friendly error message instead of freezing or crashing.
✓ Distinguished a real 200 OK response from a request that was blocked before it left the browser.
✓ Learned how blocking static assets (CSS/images) can speed up tests that don't need visuals.
What you'll learn
You'll learn how to simulate a server going offline — without actually stopping any server. When a request is aborted, the browser never receives a response. A well-built UI should show a clear error message. A poorly-built one might freeze or crash.
🧪 Try it yourself
Click "Fetch Lab Data"
You'll see live data from the server — status, region, API version, and active users. This is the happy path.
Open DevTools → Network and find /api/lab-data.json
Notice the 200 OK response. The server responded normally.
In your Playwright test, call route.abort() before clicking
The fetch throws an error. The UI should show "Server unavailable" — the error state below.
Request lifecycle for this step
🌍 Real-world example
Real servers go down. Network connections drop. CDNs fail. What happens to your application when the API call fails completely?
If users see a blank screen or an unhandled crash — that's a bug. The right behavior is a friendly error message like "Something went wrong, please try again."
Playwright can simulate this without taking any server offline.
Auth: None
Returns live server/session diagnostic data.
Example response
{ "status": "ok", "data": { "serverRegion": "...", "apiVersion": "...", "activeUsers": 128, "timestamp": "..." } }
200 OK on success. The error state this panel demonstrates is produced entirely client-side by route.abort() — the real endpoint has no failure branch of its own.
Common mistake
Most test suites over-index on the happy path and never test what the UI does when a request fails outright — a blank screen or an unhandled crash ships to production.
Best practice
Combine route.abort() with different errorCode values ('failed', 'timedout', 'connectionrefused') to simulate distinct real-world failure modes, not just one generic error.
Interview question
“How would you test what your app does when its API is completely down, without taking any real server offline?” — route.abort().
💡 Playwright method — route.abort()
Cancels the network request entirely. The browser receives no response — exactly like a server going offline.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('lab-data outage shows the error state', async ({ page }) => {
await page.route('/api/lab-data.json', route => route.abort());
await page.goto('/network-lab.html');
await page.locator('[data-testid="nlb-abort-test-btn"]').click();
await expect(page.locator('[data-testid="nlb-abort-error"]')).toBeVisible();
await expect(page.locator('[data-testid="nlb-abort-success"]')).toBeHidden();
});
// Same idea, different endpoint — products outage
test('products outage shows the shop error state', async ({ page }) => {
await page.route('/api/products.json', route => route.abort());
await page.goto('/network-lab.html');
await page.locator('[data-testid="nlb-load-products-btn"]').click();
await expect(page.locator('[data-testid="nlb-products-error"]')).toBeVisible();
});
// Performance trick — block CSS/images so tests run faster
test('page stays functional with styling blocked', async ({ page }) => {
await page.route('**/*.css', route => route.abort());
await page.route('**/*.{png,jpg,gif,svg,ico}', route => route.abort());
await page.goto('/network-lab.html'); // HTML + JS still load, just unstyled
});
Watch every request and response as they happen — without intercepting anything.
Watch every request and response as they happen — without intercepting anything.
Current Execution
Live results from the request(s) above appear here.
✓ What you learned
✓ page.on('request') / page.on('response') passively observe traffic without changing it.
✓ Verified all 3 triggered API calls completed successfully.
✓ Counted exact request/response entries to catch missing or duplicate calls.
✓ Learned the difference between monitoring traffic and intercepting it.
What you'll learn
Sometimes you don't want to block or change requests — you just want to watch them. Playwright can listen to every network request and response in real time, letting you verify that the right API calls are made in the right order with the right status codes.
🧪 Try it yourself
Click "Trigger All 3 Requests"
The button fires 3 API calls simultaneously to: products, lab data, and payment cards.
Watch the traffic log fill up
Green entries are requests (leaving the browser). Blue entries are responses (arriving from the server). You should see exactly 6 entries total.
Check the status codes
Every response should show 200. In your Playwright test, you can assert that all 3 API calls succeeded.
Request lifecycle for this step
🌍 Real-world example
After a user clicks "Add to Cart", how do you verify the correct API was called?
Checking the database is slow and complex. Instead, Playwright listens to network traffic and lets you assert exactly which URLs were called, in what order, and what the server responded.
Think of it like opening the Network tab in DevTools — but automated in your test.
Auth: None
All 3 endpoints are called in parallel by a single button click.
Example response
Each endpoint returns its own 200 OK JSON body independently.
200 OK expected from all 3 under normal conditions — this panel is about observing that traffic, not mocking or blocking it.
Common mistake
Asserting only that “some” requests were logged instead of the exact count and status — a silently-failed or duplicated call can slip through a loose assertion.
Best practice
page.on() is read-only and never slows down or changes a request. Use it for verification; reach for page.route() only when you actually need to change behavior.
Interview question
“How do you verify a specific API call was made, without intercepting or mocking it?” — page.on('request') / page.on('response'), filtered by URL and/or method.
💡 Playwright method — page.on('request') / page.on('response')
Attaches a listener that fires for every network request and response — giving you full visibility into what the browser sends and receives.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('monitors all 3 requests and their responses', async ({ page }) => {
const log: { type: string; url: string; status?: number }[] = [];
page.on('request', req => log.push({ type: 'request', url: req.url() }));
page.on('response', res => log.push({ type: 'response', url: res.url(), status: res.status() }));
await page.goto('/network-lab.html');
// Trigger — fires 3 parallel fetch() calls
await page.locator('[data-testid="nlb-trigger-btn"]').click();
// Wait for all 3 responses to arrive
await expect(page.locator('[data-testid="nlb-res-count"]')).toHaveText('3');
// Assert log entries
expect(log).toHaveLength(6);
await expect(page.locator('[data-testid="nlb-log-entry"]')).toHaveCount(6);
expect(log.filter(e => e.type === 'response' && e.status === 200)).toHaveLength(3);
});
Go Deeper
Reference Center
Three professional patterns used in real Playwright test suites.
What problem does it solve?
Logging in through the UI before every test is slow and fragile. Instead, log in once, save the cookies and local storage to a file, then load that file instantly in every test that needs authentication.
How the session flows
🌍 Real-world example
Large projects can have hundreds of tests. Logging in before each one wastes minutes across a full run. Save the session once — every test starts already signed in.
🧪 Try it yourself
- Log in on the Login page with the demo credentials.
- Open DevTools → Application → Cookies.
- Find pw_session_token in the list.
- Refresh the page — you're still logged in.
DevTools can see this cookie even though it's httpOnly — that's the same reason Playwright's context.cookies() can read it too, even though your page's own JavaScript (document.cookie) cannot.
A refresh proves the cookie survives — but it can't show the real win: skipping login entirely in test #2, #3, and #100. That's what the code on the right does.
Benefits
- ✓ Faster test runs
- ✓ Fewer flaky login steps
- ✓ Cleaner test code
- ✓ Industry standard pattern
When should I use this?
Whenever multiple tests need to start already logged in.
⭐ Pro tip
"What is storageState in Playwright?"
It saves cookies and local storage after login to a file, so future tests can start already authenticated — no UI login step needed.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('reuse a logged-in session', async ({ browser }) => {
// Login once via API
const ctx = await browser.newContext();
const pg = await ctx.newPage();
await pg.goto('/'); // resolves against baseURL
await pg.request.post('/api/auth/login', {
data: { email: 'student@playwrightautomation.com', password: 'Password123' }
});
await ctx.storageState({ path: 'auth.json' });
});
// Reuse in every test that needs to start logged in
test('checkout as a logged-in user', async ({ browser }) => {
const ctx2 = await browser.newContext({ storageState: 'auth.json' });
const pg2 = await ctx2.newPage();
await pg2.goto('/checkout.html'); // already authenticated
});
What problem does it solve?
Sometimes you want to check what the server returns without opening a browser at all. Playwright can send HTTP requests directly, check the response shape, validate status codes, and verify data — in milliseconds, with no UI.
How it flows
🌍 Real-world example
Before testing the UI, you can confirm the API itself returns the right data — 10 products, correct prices, correct stock flags — all without a browser launching.
🧪 Try it yourself
- Open a new tab and visit /api/products.json directly.
- Read the raw JSON — no page styling, no buttons, just data.
- Count the products and check which ones show "inStock": false.
That's the entire idea. request.newContext() does exactly this — visits a URL and reads the response — just from inside a test instead of a browser tab. (You can do the same thing in Postman or curl, too.)
Benefits
- ✓ Much faster than browser tests
- ✓ Tests the API independently of the UI
- ✓ Great for setup/teardown in other tests
- ✓ Catches backend bugs earlier
When should I use this?
When you want to verify backend data or behavior directly, or prepare data (like a cart or account) before a UI test runs.
⭐ Pro tip
"How is API testing different from UI testing in Playwright?"
request.newContext() talks to the server directly — no browser, no rendering, no waiting for elements. It's faster and ideal for checking data contracts, not visual behavior.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('verify product catalog via API only', async ({ request }) => {
// request fixture already uses baseURL from config — no browser needed
const res = await request.get('/api/products.json');
expect(res.status()).toBe(200);
const { products } = await res.json();
expect(products).toHaveLength(10);
expect(products.filter(p => !p.inStock)).toHaveLength(2);
});
What problem does it solve?
Instead of filling out a login form, Playwright can inject your session cookie before the page even loads. The page opens already authenticated — in milliseconds. This works because this app's authentication is based on a cookie called pw_session_token.
Where the cookie comes from
🌍 Real-world example
A test needs to land directly on checkout.html as a logged-in user. Instead of filling the login form first, get a real token from the API and hand it straight to the browser context.
🧪 Try it yourself
- Log in normally on the Login page.
- Open DevTools → Application → Cookies and find pw_session_token.
- Delete that one cookie, then refresh the page.
- Notice you're logged out — that single cookie is your session.
Now log in again and open DevTools → Network → click the /api/auth/login request → look at its Response. The token is right there in the JSON body — that's the exact value that becomes the cookie.
Playwright skips the form entirely: call that same login API, grab the token from the response, and hand it to the browser as a cookie before the page loads.
Benefits
- ✓ Skips the login UI entirely
- ✓ Tests start in milliseconds, not seconds
- ✓ Isolates the test from login-form bugs
- ✓ Works well for one-off direct-to-page tests
When should I use this?
For a single test that needs to start authenticated, without the overhead of a full storageState file.
⭐ Pro tip
"What's the difference between addCookies() and storageState()?"
addCookies() injects specific cookies you choose, one at a time — good for a single test. storageState() captures the entire session (cookies + local storage) to reuse across many tests.
📖 View Automation Code
// playwright.config.ts → use: { baseURL: 'http://localhost:3001' }
import { test, expect } from '@playwright/test';
test('skip the login form via cookie injection', async ({ page, context, request }) => {
// Get a real JWT from the API
const res = await request.post('/api/auth/login', {
data: { email: 'student@playwrightautomation.com', password: 'Password123' }
});
const { token } = await res.json();
// Inject before navigation — no login form needed
await context.addCookies([{
name: 'pw_session_token', value: token,
url: 'http://localhost:3001' // match baseURL exactly
}]);
await page.goto('/checkout.html');
// Already authenticated ✓
});