Full mocking with route.fulfill is the right call when you want a synthetic response. But sometimes the real backend already has 95% of what you need — and you only want to nudge one field. A product whose price is now 0. A user with a name 200 characters long. A response that returns 1,000 items instead of 50, to test a pagination bug. Hand-rolling all the JSON for those scenarios is busy-work — and the moment the backend schema changes, your mock drifts. The middle path is response modification: let the real request go through, capture the real response, change the bits you care about, send the modified version to the app. This lesson is the route.fetch() + route.fulfill() pattern and the QA scenarios where it earns its keep.
The pattern at a glance
Instead of fulfilling with brand-new data, you fetch the real response first:
await page.route("**/api/products", async route => {
// 1. Get the real response from the server
const response = await route.fetch();
// 2. Read the response body
const json = await response.json();
// 3. Modify the data
json[0].price = 0;
json[0].name = "FREE: " + json[0].name;
// 4. Send the modified response to the app
await route.fulfill({ response, json });
});
await page.goto("/products");route.fetch() performs the HTTP request the browser would have made — same URL, same method, same body, same headers. It returns a Response object you can inspect. route.fulfill({ response, json }) then sends a response back to the app, with the original status and headers preserved but the body replaced by your modified JSON.
This is the pattern's superpower: you keep all the structural truth of the real backend (status code, headers, schema, ordering) and override only the values that matter for the test.
Editing one field — the simplest case
Your app shows a "free" badge when product.price === 0. The backend has no zero-priced products, so you can't easily test the badge end-to-end. Modify on the fly:
test("zero-priced product shows free badge", async ({ page }) => {
await page.route("**/api/products/wireless-headphones", async route => {
const response = await route.fetch();
const product = await response.json();
product.price = 0;
await route.fulfill({ response, json: product });
});
await page.goto("/products/wireless-headphones");
await expect(page.getByText(/free/i)).toBeVisible();
});The product detail page renders against a real backend response — except the price field is forced to zero. Every other detail (description, image URL, stock count, related products) comes through unchanged. If the backend later adds a currency field, your test still works; if you'd written a full mock, it would have to be updated.
Stress-testing the UI — duplicating items
You want to know how the products page renders with 1,000 items. The backend returns 50. Multiply the response:
test("page renders 1000 items without performance issues", async ({ page }) => {
await page.route("**/api/products", async route => {
const response = await route.fetch();
const products = await response.json();
// Duplicate the array 20x with unique IDs so React keys stay stable
const stretched = [];
for (let i = 0; i < 20; i++) {
for (const p of products) {
stretched.push({ ...p, id: `${p.id}-${i}` });
}
}
await route.fulfill({ response, json: stretched });
});
await page.goto("/products");
await expect(page.getByTestId("product-card")).toHaveCount(1000);
// Performance assertions: did the page render in < 5s? Did scroll stay smooth?
});This is the pattern for surfacing performance bugs in lists, virtualised tables, and infinite-scroll containers. The backend doesn't have 1,000 products, but your test does.
Removing fields — testing missing-data tolerance
What does the UI do when a product has no image_url? Possibly the answer is "renders a broken image icon" — which you'd want to find before the user does:
test("missing image_url falls back to placeholder", async ({ page }) => {
await page.route("**/api/products", async route => {
const response = await route.fetch();
const products = await response.json();
products.forEach(p => delete p.image_url);
await route.fulfill({ response, json: products });
});
await page.goto("/products");
// Every product card should show the placeholder, not a broken image
await expect(page.getByTestId("product-image-placeholder")).toHaveCount(50);
});Same trick for testing how the UI handles null, empty strings, missing optional booleans, malformed dates. Each one is a one-line change to the response.
Testing edge values — strings, numbers, dates
Pretend the backend returns a product with a 200-character name, a 12-character SKU, or a price with five decimal places. Each one might break a layout you didn't realise depended on a "normal" length:
test("very long product name truncates with ellipsis", async ({ page }) => {
await page.route("**/api/products/laptop", async route => {
const response = await route.fetch();
const product = await response.json();
product.name = "A".repeat(200) + " Laptop";
await route.fulfill({ response, json: product });
});
await page.goto("/products/laptop");
const heading = page.getByRole("heading", { level: 1 });
await expect(heading).toBeVisible();
// The CSS line-clamp should add an ellipsis; assert the rendered height is bounded
await expect(heading).toHaveCSS("text-overflow", "ellipsis");
});Modifying headers
Same route.fulfill({ response, ... }) shape, with a headers override:
await page.route("**/api/**", async route => {
const response = await route.fetch();
await route.fulfill({
response,
headers: {
...response.headers(),
"X-Test-Run": "playwright"
}
});
});Useful when the app's behaviour depends on a header that the real backend doesn't set in your test environment — e.g., a feature-flag header or a rate-limit signal.
Conditional modification
You only want to change the response for a specific subset of requests. Inspect the request inside the handler:
await page.route("**/api/products", async route => {
const url = route.request().url();
if (url.includes("category=electronics")) {
// Only modify electronics
const response = await route.fetch();
const products = await response.json();
products.forEach(p => (p.onSale = true));
await route.fulfill({ response, json: products });
} else {
// Other categories — pass through
await route.continue();
}
});This pattern lets one route handler serve different scenarios in the same test, or — combined with a counter — make the first call return one thing and subsequent calls return another (useful for testing retry-on-error UX).
The modification flow
Step 1 of 5
App fires request
page.goto('/products') triggers fetch('/api/products'). Playwright's route handler runs first.
A complete modification test
A typed test that mixes three modification patterns in one realistic scenario:
import { test, expect } from "@playwright/test";
test.describe("Product page — response modification", () => {
test("zero-stock product disables Add to cart", async ({ page }) => {
await page.route("**/api/products/laptop", async route => {
const response = await route.fetch();
const product = await response.json();
product.stock = 0;
await route.fulfill({ response, json: product });
});
await page.goto("/products/laptop");
await expect(page.getByRole("button", { name: "Add to cart" })).toBeDisabled();
await expect(page.getByText(/out of stock/i)).toBeVisible();
});
test("on-sale flag adds promo banner to electronics only", async ({ page }) => {
await page.route("**/api/products*", async route => {
const url = route.request().url();
const response = await route.fetch();
const products = await response.json();
if (url.includes("category=electronics")) {
products.forEach(p => (p.onSale = true));
}
await route.fulfill({ response, json: products });
});
await page.goto("/products?category=electronics");
await expect(page.getByTestId("promo-banner")).toBeVisible();
await page.goto("/products?category=clothing");
await expect(page.getByTestId("promo-banner")).toBeHidden();
});
test("very long description doesn't break layout", async ({ page }) => {
await page.route("**/api/products/laptop", async route => {
const response = await route.fetch();
const product = await response.json();
product.description = "A".repeat(5_000);
await route.fulfill({ response, json: product });
});
await page.goto("/products/laptop");
const desc = page.getByTestId("product-description");
await expect(desc).toBeVisible();
// Read More toggle should appear when description exceeds the clamp
await expect(page.getByRole("button", { name: /read more/i })).toBeVisible();
});
});Each test pinpoints one product-rendering edge case: zero stock, conditional sale flag, oversized description. None of them require backend changes; none of them break when the backend evolves a normal field.
When to use this vs full mocking
Reach for route.fulfill (full mock) when:
- You're testing an error path (500, 404, 403). The backend doesn't easily produce those.
- The test has no backend dependency at all (pure UI logic).
- You want to test pagination, sorting, or filtering behaviour with a fixed dataset.
Reach for route.fetch + route.fulfill (modification) when:
- The backend produces almost the right shape; you just want one or two values different.
- You want the test to surface schema drift — if the backend adds a field, your modified response includes it automatically.
- You're stress-testing the UI with synthetic-but-real-shaped data (1,000 items, very long strings).
The two patterns coexist; many real test suites use both depending on the assertion at hand.
Coming from Cypress?
The mappings:
cy.intercept('/api/products', (req) => { req.continue((res) => { res.body[0].price = 0 }) })→page.route('/api/products', async route => { const response = await route.fetch(); const json = await response.json(); json[0].price = 0; await route.fulfill({ response, json }); })
The Cypress equivalent is req.continue((res) => { res.body = ... }) inside cy.intercept. Playwright's two-step (route.fetch, then route.fulfill) is more verbose but maps cleanly to "do this, then do that" mental model. After three or four uses the pattern locks in.
⚠️ Common mistakes
- Calling
route.fetch()androute.continue()in the same handler. They're alternatives —fetchperforms the request and gives you the response;continuealso performs the request but lets the response go to the app directly. If you do both, the request fires twice and the app sees the second one. Pick one. - Modifying the response object instead of the parsed JSON.
response.body = newBodydoesn't work —Responseis immutable. The pattern is:const json = await response.json(), modifyjson, thenroute.fulfill({ response, json }). Theresponseparameter tofulfillonly carries status and headers; thejsonfield is the new body. - Forgetting that
route.fetch()is a real network call. It hits the actual server. If you have 50 tests each doingroute.fetch()against the same endpoint, that's 50 real requests — slower than full mocking and dependent on backend availability. For tests that run frequently, consider full mocking with a fixture instead.
🎯 Practice task
Build a response-modification spec against JSONPlaceholder. 25-30 minutes.
-
JSONPlaceholder (
https://jsonplaceholder.typicode.com) is a free public API that returns sample blog data — perfect for modification practice. -
Create
tests/response-modification.spec.ts:import { test, expect } from "@playwright/test"; test.describe("Response modification", () => { test("modify single field — change post title", async ({ page }) => { await page.route("**/posts/1", async route => { const response = await route.fetch(); const post = await response.json(); post.title = "MODIFIED PLAYWRIGHT TITLE"; await route.fulfill({ response, json: post }); }); await page.goto("https://jsonplaceholder.typicode.com/posts/1"); await expect(page.locator("body")).toContainText("MODIFIED PLAYWRIGHT TITLE"); }); test("duplicate items — stress test with 500 posts", async ({ page }) => { await page.route("**/posts", async route => { const response = await route.fetch(); const posts = await response.json(); // Duplicate to 500 const stretched = []; for (let i = 0; i < 5; i++) { for (const p of posts) { stretched.push({ ...p, id: `${p.id}-${i}` }); } } await route.fulfill({ response, json: stretched }); }); const responsePromise = page.waitForResponse("**/posts"); await page.goto("https://jsonplaceholder.typicode.com/posts"); await responsePromise; const text = await page.locator("body").textContent(); const count = (text?.match(/"id":/g) || []).length; expect(count).toBe(500); }); test("conditional — modify only specific user", async ({ page }) => { await page.route("**/users/*", async route => { const url = route.request().url(); const response = await route.fetch(); const user = await response.json(); if (url.endsWith("/users/1")) { user.name = "ALICE (TEST USER)"; } await route.fulfill({ response, json: user }); }); await page.goto("https://jsonplaceholder.typicode.com/users/1"); await expect(page.locator("body")).toContainText("ALICE (TEST USER)"); await page.goto("https://jsonplaceholder.typicode.com/users/2"); await expect(page.locator("body")).not.toContainText("ALICE"); }); test("delete a field — remove email from user", async ({ page }) => { await page.route("**/users/1", async route => { const response = await route.fetch(); const user = await response.json(); delete user.email; await route.fulfill({ response, json: user }); }); await page.goto("https://jsonplaceholder.typicode.com/users/1"); const text = await page.locator("body").textContent(); expect(text).not.toContain("@"); }); }); -
Run all four tests across all three browsers. They demonstrate the four modification verbs: edit, duplicate, conditional, delete.
-
Demonstrate the immutability gotcha. In the first test, replace
await route.fulfill({ response, json: post })withresponse.body = JSON.stringify(post); await route.fulfill({ response }). Run — Playwright errors because Response bodies are immutable. The right pattern is alwaysjson: modified. -
Stretch: chain
route.fetchandroute.fulfillinside a test that also useswaitForResponse. ThewaitForResponseshould observe the modified response (because Playwright records both the original and the modified version). Confirmresponse.json()returns your modified body. This is the bridge to chapter 9's debugging workflow — the trace viewer shows both real and modified responses, which is invaluable for diagnosing "the test mock didn't apply" bugs.
You can now choose precisely how synthetic each response is — fully real (continue), fully fake (fulfill), or somewhere in between (fetch + fulfill). The next lesson goes the other direction: testing the API itself, with no browser at all.