|

Modifying API Responses in Playwright with route.fulfill

Flaky end-to-end tests almost always trace back to one thing: a backend you do not control returning data you cannot predict. When you need to test how your UI handles an empty cart, a 500 error, or a user with exactly 0 unread notifications, waiting for the real API to cooperate is a losing battle. In this guide you will learn how to Playwright modify API response route.fulfill patterns let you intercept network traffic and return exactly the JSON your test needs, making your suite deterministic, fast, and able to cover edge cases that are nearly impossible to reproduce on a live server.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why Modify API Responses at All?

Your front-end is a function of the data it receives. If you can control that data at the network boundary, you can drive the UI into any state on demand without seeding databases, calling internal admin APIs, or hoping a teammate has not deleted your test fixtures. Playwright exposes this control through page.route(), which registers a handler that runs every time a matching request is about to fire. Inside that handler you decide the request’s fate: let it through, abort it, or fulfill it yourself.

  • Determinism — the same test produces the same result on every run, in CI and locally.
  • Edge cases — empty lists, pagination boundaries, error codes, and slow responses become trivial to reproduce.
  • Speed — no round trip to a real server means tests run in milliseconds.
  • Isolation — front-end bugs are no longer masked by, or blamed on, backend instability.

route.fulfill Basics: Returning a Stubbed JSON Body

The simplest use of route.fulfill() is to short-circuit a request entirely and hand back a body you wrote by hand. The request never reaches the network. You register the route before navigation so the handler is in place when the page loads and fires its XHR/fetch calls.

import { test, expect } from '@playwright/test';

test('shows a stubbed list of products', async ({ page }) => {
  // Intercept the API call BEFORE the page navigates.
  await page.route('**/api/products', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, name: 'Stubbed Widget', price: 9.99 },
        { id: 2, name: 'Stubbed Gadget', price: 19.5 },
      ]),
    });
  });

  await page.goto('/products');

  await expect(page.getByText('Stubbed Widget')).toBeVisible();
  await expect(page.getByText('Stubbed Gadget')).toBeVisible();
});

A few details matter here. The glob pattern **/api/products matches the path regardless of host, which keeps the test working across environments. Always set contentType to application/json so the browser parses the body correctly. And because body expects a string, you wrap your object in JSON.stringify(). Playwright also offers a shorthand: passing json instead of body serializes the object and sets the content type for you.

await page.route('**/api/products', async (route) => {
  // The `json` option stringifies and sets contentType automatically.
  await route.fulfill({
    json: [{ id: 1, name: 'Stubbed Widget', price: 9.99 }],
  });
});

Patching a Real Response Instead of Replacing It

Stubbing the whole body is great for greenfield tests, but it is brittle: if the real API adds a field, your hand-written fixture drifts out of sync. A more robust pattern is to let the real request go through with route.fetch(), read the genuine response, mutate only the fields you care about, and fulfill with the patched result. This keeps the bulk of the payload realistic while you surgically change one value.

test('patches the real response to force a premium user', async ({ page }) => {
  await page.route('**/api/me', async (route) => {
    // Fetch the genuine response from the server.
    const response = await route.fetch();
    const data = await response.json();

    // Mutate only the field under test; keep everything else real.
    data.plan = 'premium';
    data.features = { ...data.features, advancedReports: true };

    await route.fulfill({
      response,          // reuse the real status & headers
      json: data,        // override the body with our patched object
    });
  });

  await page.goto('/dashboard');
  await expect(page.getByTestId('premium-badge')).toBeVisible();
});

Passing the original response object to fulfill reuses its status code and headers, so you only override what you explicitly set. This is the closest you can get to a real backend while still controlling the one variable your test depends on. It is the technique I reach for most often when I want to Playwright modify API response data without maintaining a giant fixture file.

Simulating Errors, Empty States, and Slow Networks

Error handling is the most under-tested part of most front-ends precisely because errors are hard to trigger on demand. With route.fulfill() you can return any status code and body, so a 500, a 403, or a malformed payload is a one-liner.

test('renders an error banner when the API returns 500', async ({ page }) => {
  await page.route('**/api/orders', async (route) => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/orders');
  await expect(page.getByRole('alert')).toContainText('Something went wrong');
});

test('shows the empty state when there are no orders', async ({ page }) => {
  await page.route('**/api/orders', async (route) => {
    await route.fulfill({ json: [] });
  });

  await page.goto('/orders');
  await expect(page.getByText('You have no orders yet')).toBeVisible();
});

To test loading spinners and timeout behavior, combine a delay with the route. The cleanest way to simulate latency is to await a timer inside the handler before fulfilling, or to abort the request entirely to test the offline path. Note that for time-dependent UI such as countdowns or polling, Playwright’s page.clock API is the correct tool rather than real sleeps.

test('shows a spinner while the response is in flight', async ({ page }) => {
  await page.route('**/api/orders', async (route) => {
    // Simulate a slow backend before responding.
    await new Promise((resolve) => setTimeout(resolve, 1500));
    await route.fulfill({ json: [] });
  });

  await page.goto('/orders');
  await expect(page.getByTestId('spinner')).toBeVisible();
  await expect(page.getByText('You have no orders yet')).toBeVisible();
});

test('handles a hard network failure', async ({ page }) => {
  // abort() simulates a dropped connection, not an HTTP error.
  await page.route('**/api/orders', (route) => route.abort('failed'));

  await page.goto('/orders');
  await expect(page.getByText('Check your connection')).toBeVisible();
});

route.fulfill vs Other Interception Approaches

Playwright gives you several ways to deal with network traffic. Knowing when to fulfill versus continue versus replay from a HAR file saves you a lot of maintenance pain. The table below summarizes the trade-offs.

MethodWhat it doesBest for
route.fulfill()Returns a response you define; request never hits the network (unless you fetched it first).Stubbing, patching, error and empty-state simulation.
route.continue()Lets the request proceed, optionally overriding URL, method, headers, or post data.Rewriting requests, adding auth headers, redirecting to a mock server.
route.fetch()Performs the real request and returns its response for inspection.Reading then patching a genuine payload before fulfilling.
route.abort()Cancels the request with a network-level error.Testing offline and connection-failure paths.
page.routeFromHAR()Replays responses recorded in a HAR archive.Snapshot-style mocking of an entire flow with no hand-written fixtures.

Scaling Mocks Across a Suite

Repeating page.route() in every test gets old fast. Two patterns help. First, register routes at the context level with context.route() so every page in that context inherits them. Second, wrap your common mocks in a Playwright fixture so tests opt in by declaring a dependency.

import { test as base, expect } from '@playwright/test';

type Fixtures = { mockApi: void };

export const test = base.extend<Fixtures>({
  mockApi: [async ({ context }, use) => {
    // Applies to every page created in this context.
    await context.route('**/api/feature-flags', (route) =>
      route.fulfill({ json: { newCheckout: true, darkMode: false } })
    );
    await use();
  }, { auto: true }], // auto: true runs it for every test in the file
});

export { expect };

With auto: true, the feature-flag mock is active for every test that imports this custom test object, and individual tests can still add or override routes locally because the last matching handler registered wins. If you need to record real traffic once and replay it, generate a HAR file with the Playwright CLI and point page.routeFromHAR() at it; this scales mocking to entire user journeys without writing JSON by hand.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Common Pitfalls When You Playwright Modify API Response Bodies

  • Registering the route too late. Call page.route() before page.goto(), otherwise the request may fire before your handler exists.
  • Forgetting to fulfill or continue. Every route handler must terminate the request. If you neither fulfill, continue, nor abort, the request hangs until it times out.
  • Over-broad glob patterns. A pattern like **/api/** can swallow unrelated calls. Be specific, and use a regex with page.route(/\/api\/orders$/, ...) when globs are not precise enough.
  • Stale fixtures. Hand-written bodies drift from the real schema. Prefer the fetch-and-patch pattern for anything beyond trivial cases.
  • CORS confusion. Because fulfilled responses come from Playwright, not a cross-origin server, you generally avoid CORS issues, but copy real headers via the response option when the app reads them.

Conclusion

Learning to Playwright modify API response route.fulfill techniques turns your end-to-end suite from a fragile mirror of backend instability into a precise instrument for testing the front-end on its own terms. Start with simple JSON stubs, graduate to the fetch-and-patch pattern when realism matters, and reach for fixtures or HAR replay when you need to scale across many tests. Once you control the network boundary, every UI state, including the ones you could never reproduce against a live server, becomes a one-line route handler away.

FAQ

What is the difference between route.fulfill and route.continue in Playwright?

route.fulfill() returns a response that you define, so the request normally never reaches the real server. route.continue() lets the request proceed to the network, optionally rewriting its URL, method, headers, or body. Use fulfill when you want to mock the answer, and continue when you want the real backend to respond but with a modified request.

How do I modify only part of a real API response with route.fulfill?

Call route.fetch() inside the handler to perform the genuine request, read the body with await response.json(), change only the fields you need, then call route.fulfill({ response, json: data }). Passing the original response reuses its status and headers while your patched object overrides the body, keeping the rest of the payload realistic.

Why is my route.fulfill mock not being applied?

The most common cause is registering the route after navigation. Always call page.route() before page.goto(). Other causes include a glob pattern that does not match the actual URL (verify with page.on('request', ...)), or an earlier handler already consuming the request, since the most recently registered matching handler takes precedence.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.