Core Playwright Concepts
Network request interception and mocking
Network interception allows you to intercept, modify, or block outgoing HTTP requests initiated by your application during tests. By controlling the data flowing in and out of the browser, you decouple your test suite from volatile external dependencies and backend state. Use this technique to create deterministic test environments, simulate error conditions, and drastically speed up execution by avoiding actual network latency.
The Fundamental Mechanism of Interception
At its core, interception works by hooking into the browser's network layer before the request is dispatched to the server. When a request matches defined criteria, Playwright halts the outgoing packet, giving you total control over its lifecycle. The reason this is powerful lies in the ability to intervene before the server ever sees the request, allowing you to manipulate headers, modify query parameters, or even serve custom content directly from your test runner. By managing the request flow, you ensure that the application under test behaves exactly as expected, regardless of the real state of your production API. This prevents test flakiness caused by external rate limiting, authentication issues, or unexpected data changes in your staging environment. Understanding this capture mechanism allows you to reason about any scenario where your application communicates with an outside source, as you now act as a man-in-the-middle to enforce desired outcomes.
import { test, expect } from '@playwright/test';
test('intercept a request', async ({ page }) => {
// Register a listener for requests to the API
await page.route('**/api/v1/user', route => {
console.log('Intercepted request to user API');
// Always fulfill or abort the request to prevent hanging
route.continue();
});
await page.goto('https://example.com');
});Mocking API Responses
Mocking is the process of providing a static response to a network request, effectively bypassing the real backend entirely. This is essential for isolated testing where the server might be slow, unavailable, or difficult to configure into a specific state. When you fulfill a request, Playwright stops the request from traveling to the network and immediately returns your defined payload. This works because the browser receives the response as if it came from the genuine source. By crafting precise JSON responses, you can simulate edge cases, such as an empty list of items or an unusual character set, without modifying your actual server code. This approach is highly performant because it eliminates round-trip times, making your tests significantly faster. Always ensure your mock matches the structure expected by the application, as inconsistencies will lead to parsing errors or logic bugs that do not exist in production code.
test('mock an API response', async ({ page }) => {
// Override the response for this specific endpoint
await page.route('**/api/v1/config', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ theme: 'dark', enabled: true }),
});
});
await page.goto('https://example.com');
});Simulating Network Errors
Testing how your application handles failure is just as critical as verifying success, yet it is often difficult to trigger real backend errors on demand. By using the abort method, you can force a request to fail entirely, allowing you to verify that your error boundaries, toast notifications, or retry logic function correctly. The browser perceives this as a DNS failure or a connectivity loss. This capability is vital for building resilient user experiences. You can also return non-200 status codes, such as 403 Forbidden or 500 Internal Server Error, to simulate backend crashes or authentication denials. Reasoning about these failures enables you to write better defensive code, ensuring the user interface remains responsive and informative even when the network is unstable. Always verify that your application gracefully degrades or provides helpful feedback when these simulated outages occur, rather than simply freezing or displaying a cryptic technical error.
test('simulate a network failure', async ({ page }) => {
// Force the request to fail entirely
await page.route('**/api/v1/profile', route => {
route.abort('failed'); // Simulates network failure
});
await page.goto('https://example.com');
// Verify UI displays error message
await expect(page.locator('.error-banner')).toBeVisible();
});Modifying Requests Before Dispatch
Beyond simple mocking, you can use the interception pattern to alter a request on its way out. By continuing a request with modified parameters, you can inject custom headers, append query strings, or update the POST body before it reaches the backend. This is particularly useful for injecting authorization tokens, adding debug flags, or forcing the server to return specific datasets based on modified request parameters. Because you are intercepting before the browser sends the payload, the application remains unaware that you have meddled with the request. This allows for advanced testing scenarios like A/B testing variations or simulating different user roles by dynamically updating header keys during the execution of a test suite. Being able to reason about the request lifecycle ensures you only modify the aspects necessary for the test, maintaining high fidelity between your testing environment and your real-world production application.
test('modify a request header', async ({ page }) => {
await page.route('**/api/v1/data', route => {
const headers = { ...route.request().headers(), 'x-test-mode': 'true' };
// Continue with modified headers
route.continue({ headers });
});
await page.goto('https://example.com');
});Advanced Request Pattern Matching
To build robust test suites, you must master the art of precise request matching. Playwright allows for wildcard patterns, glob matching, and even custom function predicates to identify which requests should be intercepted. This flexibility ensures that your mocks do not leak into other unrelated requests, preventing accidental interference with assets like images, scripts, or CSS files. By focusing your interceptions on specific methods, such as POST or PUT, you can build specialized mocks that only trigger for data submission while allowing GET requests to fetch real assets. Understanding the hierarchy of route registration—where later routes take precedence—enables you to layer logic, allowing for global defaults and specific test-case overrides. Proper pattern matching is the foundation of clean, maintainable test architecture, as it prevents your interception logic from becoming overly broad or prone to matching unintended network activity during your test runs.
test('filter by request method', async ({ page }) => {
// Only intercept POST requests to this specific resource
await page.route(url => url.includes('submit') && request.method() === 'POST', route => {
route.fulfill({ status: 201, body: 'Created' });
});
await page.goto('https://example.com');
});Key points
- Interception happens before the browser sends the request to the network.
- Mocking allows you to return static data instead of waiting for a live server.
- Aborting requests is the most reliable way to test application error handling.
- You can modify outgoing headers to simulate different user roles and test environments.
- Routes are evaluated in reverse order, meaning the last defined route takes precedence.
- Always call continue, fulfill, or abort to ensure the network request is properly resolved.
- Pattern matching enables you to isolate specific endpoints without affecting browser assets.
- Decoupling tests from the network reduces flakiness and increases total execution speed.
Common mistakes
- Mistake: Intercepting a request but forgetting to fulfill or abort it. Why it's wrong: The request will hang indefinitely, causing the test to time out. Fix: Always ensure that every intercepted request is either fulfilled with a response, aborted, or allowed to continue.
- Mistake: Using page.route() after the page has already performed the network action. Why it's wrong: Playwright needs the routing rules to be active before the request is initiated to successfully intercept it. Fix: Define routes before triggering the action that initiates the network request.
- Mistake: Hardcoding full URLs in the pattern matcher. Why it's wrong: It makes tests brittle because they break if the base URL or environment changes. Fix: Use regex patterns or relative paths to make routing rules environment-agnostic.
- Mistake: Over-relying on network mocking for every test. Why it's wrong: Excessive mocking masks real integration issues with the actual backend API. Fix: Use mocking for edge cases and slow dependencies, but use real network calls for critical paths.
- Mistake: Modifying the original request object directly in the handler. Why it's wrong: Request objects are immutable or have specific methods for modification; direct assignment has no effect. Fix: Use the provided route methods like route.continue({ postData: ... }) to modify request payloads.
Interview questions
What is the basic purpose of intercepting network requests in Playwright?
The primary purpose of intercepting network requests in Playwright is to decouple your test execution from the actual state or availability of backend services. By using page.route(), you can intercept requests before they reach the network and respond with predefined data. This is crucial for testing edge cases, like handling 500 server errors or specific API responses, without needing to manipulate the actual database or wait for unreliable third-party APIs.
How do you mock a simple JSON response for an API endpoint using Playwright?
To mock a JSON response, you use the page.route() method targeting a specific URL pattern. Within the handler function, you call route.fulfill() and pass in the status code, content type, and the body. For example: await page.route('**/api/user', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ name: 'Playwright User' }) })). This ensures that every time your application requests that endpoint during the test, it receives the exact data you defined, making your test deterministic and fast.
How can you use network interception to simulate a slow network or a failing server?
You can simulate unstable network conditions by introducing delays or errors within the route handler. To simulate a slow network, you can add an await new Promise(f => setTimeout(f, 3000)) before calling route.fulfill(). To simulate a failure, you call route.abort('failed') or route.fulfill({ status: 500 }). This is vital for verifying that your frontend UI correctly displays loading spinners or error messages to the user when the backend is struggling or unreachable, ensuring robustness.
What is the difference between page.route() and page.routeFromHAR() in Playwright?
The main difference is the source of the mocked data. page.route() requires you to manually define the request handler and the exact response content, providing full programmatic control for dynamic scenarios. In contrast, page.routeFromHAR() uses a recorded HTTP Archive file to automatically handle requests. Use page.route() when you need specific, computed, or modified responses, and use page.routeFromHAR() when you want to quickly replicate a large set of real network traffic without writing individual handlers for every single API call.
How do you modify an existing request before allowing it to proceed to the server?
You can modify a request by intercepting it and using route.continue() with an 'overrides' object. This allows you to change headers, the HTTP method, or even the request body before the request hits the server. For example: await page.route('**/api/data', route => route.continue({ headers: { ...route.request().headers(), 'x-custom-header': 'value' } })). This is powerful for testing scenarios where your frontend needs to send specific authentication tokens or headers that are normally generated by complex middleware, allowing you to bypass or override those layers during automated testing.
Explain how you would handle multiple requests to the same endpoint but with different expected outcomes during a single test.
To handle multiple requests with different outcomes, you should use stateful logic within your route handler. You can define a counter or a flag outside the scope of the route and increment it each time the handler is called. Depending on the current count, you return different status codes or JSON bodies in your route.fulfill() call. This allows you to test complex UI flows, such as a paginated list where the first request returns data and the second request returns an empty list, ensuring the UI handles sequential state changes correctly.
Check yourself
1. When using page.route() to mock an API response, what is the best way to ensure the mock only applies to a specific API endpoint while ignoring static assets?
- A.Use a string pattern that matches the exact URL of the API endpoint.
- B.Use a glob pattern that matches the specific API path structure.
- C.Use a function that returns true only if the URL contains the specific API path.
- D.Include all URL patterns in a single route call.
Show answer
C. Use a function that returns true only if the URL contains the specific API path.
A function in page.route() provides the most flexibility, allowing you to filter by URL, method, or headers. String matches are too rigid for dynamic environments, globs are better for files but less precise for API logic, and putting all patterns in one call is less maintainable.
2. If you want to simulate a slow network for a specific request using page.route(), how should you implement the handler?
- A.Use route.continue() and wait for a fixed amount of time.
- B.Use a custom timeout in the browser context configuration.
- C.Use route.fulfill() after a programmatic delay inside the handler.
- D.Use the page.waitForTimeout() method before the route is called.
Show answer
C. Use route.fulfill() after a programmatic delay inside the handler.
route.fulfill() allows you to control exactly when the response is returned. By introducing a delay inside the handler before calling fulfill, you simulate latency. The other options either block the whole test execution or fail to specifically target the mocked response.
3. Why would you choose route.abort() instead of route.fulfill() for a specific request in your test?
- A.To speed up the test by skipping the network round trip.
- B.To simulate a network failure or a 403 Forbidden error effectively.
- C.To bypass security checks that might trigger during a standard fulfill.
- D.To allow the request to proceed to the server without modification.
Show answer
B. To simulate a network failure or a 403 Forbidden error effectively.
route.abort() is the standard way to test how your application handles failed network requests or connection errors. route.fulfill() returns a successful (or customized) response, not an error state; speeding up the test is not the primary purpose, and it does not pass through to the server.
4. How do you modify the body of a request before it reaches the server using page.route()?
- A.By calling route.continue({ postData: modifiedData }).
- B.By calling route.fulfill({ body: modifiedData }).
- C.By changing the page.request object before the route handler finishes.
- D.By setting the request header with the new data.
Show answer
A. By calling route.continue({ postData: modifiedData }).
route.continue() accepts an options object that allows you to override request attributes like headers or postData. route.fulfill() sends a response back to the client, it does not send data to the server. Request objects are not directly mutable.
5. What happens if you have two route handlers registered that both match the same request URL?
- A.Both handlers execute sequentially.
- B.The first handler registered is the only one that executes.
- C.The last handler registered is the only one that executes.
- D.An error is thrown indicating duplicate handlers.
Show answer
C. The last handler registered is the only one that executes.
In Playwright, the last handler registered takes precedence for a matching URL. This allows for clean overrides in setup logic. The other options describe behaviors that contradict Playwright's route precedence rules.