TechByteByByte

Part 19: Network Handling

Observe, mock, modify and control network traffic in browser tests.

Network requests are messages between the page and servers. Playwright can observe or temporarily replace them.

page request → route or server → response → page update

Series: Playwright Zero to Expert | Demo app used throughout this series: saucedemo.com

Every test so far has let the browser talk to the real server, unmodified. This part introduces something genuinely powerful, and genuinely double-edged: intercepting network traffic before it reaches the browser, and deciding, in your test code, exactly what response the browser actually receives — including responses that don’t reflect reality at all.


The Network Lifecycle, and page.route

Recall Part 0.3’s request-response chain. page.route() inserts your test directly into the middle of that chain, for requests matching a pattern you specify, letting you inspect, modify, block, or completely replace what the browser actually receives — before the browser ever sees the real server’s response, or in some cases, before the real request is even sent at all.

await page.route("**/api/products", async (route) => {
  console.log("Intercepted:", route.request().url());
  await route.continue(); // let the real request proceed unmodified
});

await page.goto("/inventory.html");

route.continue() is the simplest possible use — observe the request, then let it proceed normally. The real power shows up once you start doing something other than just continuing.

Analogy: The Corporate Mailroom Clerk Think of page.route as inserting an active mailroom clerk into a business:

  • Continue (route.continue): The clerk opens the mail sack, notes down that a letter to the Sales team has arrived, and places it back in the tray to be delivered normally.
  • Fulfill/Stub (route.fulfill): The clerk sees a letter addressed to the Finance team. Instead of letting it go to the real post office, they immediately pull out a pre-written sheet from their desk, place it in a new envelope, and hand it to the recipient. The original destination is never contacted.
  • Tamper/Modify (route.fetch + route.fulfill): The clerk intercepts an invoice arriving from a contractor. They let the invoice travel to the contractor to get the real numbers (route.fetch), open it, change the price from $1,000 to $0.01, seal it back up, and hand it to the Billing desk.

📊 Visual Flowchart: Intercepting and Tampering with Network Responses

Here is the sequence of events when Playwright intercepts and alters live API data:

graph LR
    Browser["1. Browser fires API request"] --> Route["2. Intercept via page.route"]
    Route --> Fetch["3. route.fetch()<br>(Gets real server response)"]
    Fetch --> Modify["4. Alter body in memory<br>(e.g. Change price to $0.01)"]
    Modify --> Fulfill["5. route.fulfill()<br>(Sends tampered payload to browser)"]
    Fulfill --> Render["6. Browser renders tampered data in UI"]

Mocking and Stubbing Responses

await page.route("**/api/products", async (route) => {
  await route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify({
      products: [{ id: 1, name: "Test Product", price: 9.99 }],
    }),
  });
});

await page.goto("/inventory.html");
await expect(page.getByText("Test Product")).toBeVisible();

route.fulfill() completely replaces the response — the real server is never even contacted at all for this request; Playwright hands the browser exactly the fake response you constructed instead. This is genuinely powerful for a specific, honest reason: it lets you test how the frontend behaves in response to data it can’t reliably obtain by simply using the real, live application.

What does the UI look like with exactly one product? With zero products? With a product missing its price entirely? Manufacturing these exact conditions through the real backend and database might be difficult, slow, or in some cases genuinely impossible on demand — mocking makes it trivial.

Simulating Failures and Delays

This is where mocking becomes something a real, live application often can’t let you test at all, on demand:

// Simulate the API failing entirely
await page.route("**/api/products", async (route) => {
  await route.fulfill({ status: 500, body: "Internal Server Error" });
});

await page.goto("/inventory.html");
await expect(
  page.getByText("Something went wrong. Please try again."),
).toBeVisible();
// Simulate a slow network
await page.route("**/api/products", async (route) => {
  await new Promise((resolve) => setTimeout(resolve, 3000)); // artificial delay
  await route.continue();
});

await page.goto("/inventory.html");
await expect(page.getByText("Loading...")).toBeVisible(); // does a loading state actually show?
// Simulate offline / request blocked entirely
await page.route("**/api/products", (route) => route.abort());

Think about what these actually let you verify, and why it matters. Does the application show a sensible, user-friendly error message when the backend genuinely fails — or does it silently show a blank, broken page? Does a loading spinner actually appear during a slow request, or does the UI just look frozen and unresponsive for those three seconds?

These are genuinely important user-facing behaviors, and testing them against a real backend would require actually breaking that real backend on demand — awkward at best, and often simply not something you’re able to do safely against a shared environment other people also depend on.

Modifying Requests and Responses

// Add a header to every outgoing request matching this pattern
await page.route("**/api/**", async (route) => {
  const headers = { ...route.request().headers(), "X-Test-Run": "true" };
  await route.continue({ headers });
});
// Modify a real response before it reaches the browser, rather than replacing it entirely
await page.route("**/api/products", async (route) => {
  const response = await route.fetch(); // actually call the real server
  const json = await response.json();
  json.products[0].price = 0.01; // tamper with just one field
  await route.fulfill({ response, json });
});

This second pattern — route.fetch() followed by a modified route.fulfill() — is worth noting specifically, because it’s meaningfully different from full mocking: it lets the real request genuinely happen, and only alters a specific piece of the real response afterward. This is useful for testing an edge case (like a suspiciously low or negative price) without needing to fully fabricate an entire, otherwise-realistic response from scratch.


When Mocking Helps, and When It Misleads

This is the single most important judgment call in this entire part, and it’s worth being completely honest about, because it’s a genuine, ongoing debate in real QA teams, not a settled question with one universally correct answer.

Mocking genuinely helps when you’re testing the frontend’s own behavior in response to a given condition — error handling, loading states, edge-case data rendering — situations where the specific backend response is really just an input you need to control precisely, and the actual point of the test has nothing to do with whether the real backend genuinely produces that input under real conditions.

Mocking genuinely misleads when it’s used as a substitute for actually verifying that the frontend and the real backend work correctly together.

Imagine a test suite where every single test mocks every API call — every test might pass consistently, forever, while the real, actual integration between frontend and backend is silently broken, completely undetected, because no test in the entire suite ever once talks to the real backend at all.

This is a genuinely real, common trap: an “over-mocked” suite that provides strong, confident-feeling coverage of the frontend in complete isolation, while offering zero actual assurance that the whole, real, connected system genuinely works.

The healthy balance, worth stating plainly: use real, unmocked requests as your default, for tests that are genuinely meant to verify true end-to-end behavior (this is exactly what Part 0’s E2E layer of the testing pyramid is for). Reach for mocking specifically and deliberately for testing frontend behavior in conditions that are difficult, slow, unsafe, or genuinely impossible to reliably produce through the real backend — never as a default, blanket habit applied to an entire suite simply because it makes tests run faster or feel more predictable.


How It Works in a Real Test Run

Routing places test code between the page and matching network requests. The handler can continue unchanged, modify the request, fulfill a synthetic response, or abort it. Register routes before navigation or action so early requests are not missed.

Mock only the dependency behavior the scenario intends to control. Keep separate integration tests against real services, because a perfectly shaped mock can drift away from the real API and create false confidence.

Interview Questions

Q: What is the fundamental difference between route.continue() and route.fulfill()?

Ans: route.continue() lets the intercepted request proceed to the real server essentially unmodified (optionally with minor adjustments, like added headers), and the real response is what the browser ultimately receives. route.fulfill() completely replaces the response — the real server is never contacted for that specific request at all, and the browser receives exactly whatever response your test constructs instead.

Q: Give a concrete example of application behavior that’s genuinely difficult to test without mocking network responses.

Ans: Testing how the UI behaves when the backend returns a server error (like a 500 status) is a strong example — reliably forcing a real backend to fail on demand, safely, in a shared environment, is often impractical or unsafe. Mocking a 500 response lets you directly and reliably verify the frontend shows an appropriate, user-friendly error message, without needing to actually break anything real.

Q: What is the risk of a test suite where every single API call is mocked, with no tests ever hitting the real backend?

Ans: Such a suite can pass consistently and confidently while the actual, real integration between the frontend and backend is silently broken — since nothing in the suite ever verifies that the real backend genuinely behaves the way the mocked responses assumed it would. This creates a false sense of security: strong-looking coverage of frontend behavior in isolation, without any real assurance that the connected, end-to-end system actually works.

Q: When would you choose to mock a network response rather than test against the real backend?

Ans: When testing frontend behavior for a condition that’s difficult, slow, unsafe, or practically impossible to reliably reproduce through the real backend on demand — a server error, an unusually slow response triggering a loading state, or a specific, unusual edge-case data shape. For tests genuinely meant to verify real end-to-end correctness across frontend and backend together, using the real, unmocked backend remains the appropriate default.

Q: What’s the difference between using route.abort() and route.fulfill({ status: 500, ... }) to simulate a failure, and when might you use one over the other?

Ans: route.abort() simulates the request failing to complete at the network level entirely — closer to a genuinely offline connection or a request that never reaches any server at all. route.fulfill({ status: 500 }) simulates the request successfully reaching a server that then responds with an error, which is a meaningfully different scenario for the frontend to potentially need to distinguish and handle differently. Choosing between them depends on which specific real-world failure mode you’re actually trying to verify the application handles correctly.

Q: A teammate mocks every API call in a new feature’s entire test suite, arguing it makes the tests faster and more reliable. What concern would you raise?

Ans: I’d raise that while mocking every call does make the suite faster and removes backend-related flakiness, it also means the suite provides no actual verification that the feature genuinely works against the real backend — a real integration bug between frontend and backend could exist and go completely undetected. I’d suggest keeping a smaller set of genuine, unmocked end-to-end tests covering the feature’s core, critical paths, while reserving mocking specifically for testing frontend behavior in edge cases that are hard to produce with the real backend — rather than mocking the entire suite by default.


Exercises — Part 19

Understand: Explain, in your own words, the risk of a test suite that mocks every single network request, even though each individual test might have a completely valid, well-reasoned justification for mocking in isolation.

Simple Practice: Write a Playwright test against any application you have access to (or SauceDemo, mocking one of its underlying API calls if discoverable via the Network tab from Part 1) that uses page.route() to simulate a 500 error, and assert on how the application actually responds — does it show a sensible message, or fail silently?

Real-World Scenario: Design a test verifying that a page shows a loading spinner while data is being fetched, and that the spinner correctly disappears once the data arrives. Use page.route() to introduce an artificial delay, and write the assertions needed to verify both states — the spinner’s visibility during the delay, and its disappearance once the (mocked or real) response completes.

Challenge: For a feature you’ve tested earlier in this series (like SauceDemo’s login or inventory page), write down, in your own words, a short list of which specific tests should genuinely use the real backend, and which specific edge cases would be reasonable to test using mocked responses instead — justifying each choice using the “helps vs. misleads” reasoning from this part.


Next: Part 20 — Page Object Model

— the problem POM actually solves, building a scalable POM architecture, common anti-patterns, and when POM is genuinely the wrong tool for the job.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed