Control the network

Block, mock, and modify network requests so tests stay fast and deterministic.

Real backends can be slow, flaky, and sometimes even down. Those are three things you don't want in a test. If you want to cut the network dependencies, Playwright lets you sit between the browser and the server and decide what happens to every request.

With network interception, you can:

  1. Observe: log every request/response or wait for a specific one.
  2. Mock: answer a request from your test instead of the server.
  3. Modify: let the request reach the server, then patch the response (or the request).
  4. Block: abort whole categories of requests (images, analytics, ads).

Observe traffic

Every request and response is an event you can subscribe to.

page.on("request", (request) =>
  console.log(">>", request.method(), request.url()),
);
page.on("response", (response) =>
  console.log("<<", response.status(), response.url()),
);

await page.goto("/");

request carries the URL, method, headers, post data, and resourceType() ('document', 'image', 'fetch', …). response carries status, headers, and the body.

Wait for a specific response

When the next assertion needs a network call to settle first, use page.waitForResponse(). Pass a glob, regex, or predicate.

const responsePromise = page.waitForResponse("**/api/news/");
await page.goto("/");
const response = await responsePromise;

Mock APIs with route.fulfill

page.route() registers a handler for matching URLs. Inside the handler, you decide what happens. The simplest option is route.fulfill(), which answers the request without ever hitting the server.

await page.route("**/api/products/", (route) =>
  route.fulfill({
    status: 200,
    contentType: "application/json",
    body: JSON.stringify([{ name: "A different product" }]),
  }),
);
await page.goto("/");
Note

URL patterns use glob syntax: * matches a path segment, ** matches across slashes, and you can pass a regular expression, too!

Inline exercise

Mock the news box

The yellow newsbox on the home page renders some API content.

  1. Use page.route() + route.fulfill() to return your own message.
  2. Assert the newsbox (data-testid="newsbox") shows your custom message.

Newsbox

▸ Show solution
test("mock the news box", async ({ page }) => {
  await page.route("**/api/news/", (route) =>
    route.fulfill({
      status: 200,
      contentType: "application/json",
      body: JSON.stringify({ message: "🚨 Test news!" }),
    }),
  );
  await page.goto("/");

  await expect(page.getByTestId("newsbox")).toContainText("🚨 Test news!");
});
Warning

Network handlers must be registered before the request fires. Put page.route() calls before page.goto(), or hoist them into a beforeEach hook or a custom fixture so they apply to every test.

Modify a real response

When you start mocking API responses, you'll run into one problem: mocks get outdated.

The server returns one shape, your mock returns another, and the test passes against data that doesn't exist in production. The middle ground is to let the request reach the server and change only the parts you care about.

Call route.fetch() to perform the real request, then route.fulfill({ response, ... }) to send a modified version to the browser.

await page.route("**/api/products/", async (route) => {  const response = await route.fetch();  const products = await response.json();  // mark the first product as out of stock  products[0].availableForSale = false;  await route.fulfill({ response, json: products });});

Passing the original response keeps headers, status, and response details in place. Only the JSON body is "a little bit" adjusted.

Use this approach when you want realistic data with a small twist: an empty list, a flipped flag, or an injected error.

You can override individual pieces, too. route.fulfill({ response, status: 500 }) keeps the body but replaces the status. That's handy for testing how the UI handles a server error without writing a fake payload.

Modify outgoing requests

The counterpart to route.fulfill is route.continue(), which lets the request through, optionally with overrides for url, method, headers, or postData.

// add a header to every API call before it leaves the browser
await page.route("**/api/**", (route) =>
  route.continue({
    headers: { ...route.request().headers(), "x-test": "playwright" },
  }),
);

Use continue when you want to change the request and not the response. This is handy for adding auth tokens or feature-flag headers, or for swapping a hostname.

Block requests to speed tests up

Some tests don't need analytics, fonts, or hero images. Drop these requests with route.abort().

await page.route("**/*.{png,jpg,jpeg,webp,svg}", (route) => route.abort());
await page.goto("/");

For finer control, branch on route.request().resourceType():

await page.route("**/*", (route) => {
  const type = route.request().resourceType();
  if (type === "image" || type === "media" || type === "font") {
    return route.abort();
  }
  return route.continue();
});
Inline exercise

Block the tracking script

Every page on this site loads /dummy-tracker.js, which logs "Watch out! I'm tracking you!" to the console.

Browser console showing the "Watch out! I'm tracking you!" message logged from dummy-tracker.js

Not great!

  1. Block any request whose URL ends in /dummy-tracker.js.
  2. Subscribe to page.on("console") and assert the tracking message never appears.
▸ Show solution
test("block the tracking script", async ({ page }) => {
  const messages: string[] = [];
  page.on("console", (msg) => messages.push(msg.text()));

  await page.route("**/dummy-tracker.js", (route) => route.abort());
  await page.goto("/");

  expect(messages).not.toContain("Watch out! I'm tracking you!");
});

Where should route handlers live?

There are three options, ordered by scope:

  • In the test: for one-off mocks specific to a single scenario.
  • In beforeEach: shared by every test in the file.
  • In a custom fixture: shared across files. Register the route in the fixture's setup, and Playwright tears it down with the page.

Pick the smallest scope that does the job. A test-local mock is easier to reason about than a global one that surprises you three files away.


Hands on

Practice controlling the network

Exercise 1 of 3

Pretend you're shopping from somewhere new

  1. The country pill in the navbar (data-testid="geo-location") reads from /api/geo/.
  2. Mock the endpoint to return a country code of your choice ("JP", "BR", "DE").
  3. Assert the pill shows the matching country code.
Exercise 2 of 3

Inject an error into a real response

  1. Use route.fetch() + route.fulfill({ response, status: 500 }) against /api/news/ to keep the real body but flip the status.
  2. Navigate to / and assert the newsbox falls back to "📭 No news today — check back later.".
Exercise 3 of 3

Block images by resource type

  1. Write a test that aborts every request where route.request().resourceType() === "image".
  2. Navigate to /search/ and confirm product names and prices still render. Only the images should be gone.
  3. Compare the test's runtime with and without the block.