QA How-To
How to Use Playwright route fulfill (2026)
Learn how to use Playwright route fulfill in 2026 to mock JSON, files, errors, and modified API responses with correct TypeScript tests and assertions.
19 min read | 2,677 words
TL;DR
Use `route.fulfill({ status, json })` inside `page.route()` or `browserContext.route()` to complete a matching browser request with a controlled response. Use `route.fetch()` first when you need the real response as a base, and use `route.abort()` for transport failures rather than HTTP error responses.
Key Takeaways
- Register the route before the action that triggers the request, then resolve it exactly once with route.fulfill().
- Prefer the json option for JSON payloads because Playwright sets the JSON content type when one is not supplied.
- Use status, headers, contentType, body, json, path, or response only when the scenario needs them.
- Pair route.fetch() with route.fulfill() when the real response should be patched instead of replaced.
- Model HTTP errors with fulfill and transport failures with route.abort(), since applications handle them differently.
- Assert the mocked response's effect in the UI and count route hits when interception is essential to the test.
Playwright route fulfill completes an intercepted browser request with a response that your test controls. In its simplest form, register page.route() before the request begins and call route.fulfill({ status: 200, json: data }) inside the matching handler.
This technique makes UI tests deterministic when a backend is unavailable, a rare state is hard to create, or an error response must be reproduced exactly. It can also patch a real response when combined with route.fetch(). This 2026 guide covers the supported options, correct TypeScript patterns, route scope, assertions, and the design choices that separate useful network mocks from misleading tests.
TL;DR
await page.route('**/api/users/42', async route => {
await route.fulfill({
status: 200,
json: { id: 42, name: 'Ada', plan: 'pro' },
});
});
| Response need | Recommended pattern |
|---|---|
| Return JSON | route.fulfill({ status, json }) |
| Return text or HTML | route.fulfill({ status, contentType, body }) |
| Serve a fixture file | route.fulfill({ path }) |
| Patch the real response | const response = await route.fetch(), then route.fulfill({ response, json }) |
| Simulate an HTTP 500 | route.fulfill({ status: 500, json }) |
| Simulate no HTTP response | route.abort('connectionrefused') |
1. What Playwright route fulfill does
A browser request that matches page.route() or browserContext.route() is paused before it reaches its normal destination. The callback receives a Route. Calling route.fulfill() tells Playwright to complete that request with the response options you provide. The page receives an ordinary HTTP response from its perspective, so application code still processes status, headers, body, and content type through the browser networking stack.
The supported fulfill options are status, headers, contentType, body, json, path, and response. Status defaults to 200. json accepts a serializable value and sets application/json when you do not supply a content type. path serves a file and infers its type from the extension. response accepts an APIResponse, usually returned by route.fetch(), whose fields can then be selectively overridden.
Fulfillment is terminal. Once the route is fulfilled, it cannot also be continued, aborted, or fulfilled again. Every route-handler branch must choose exactly one resolution. A missing resolution leaves the request paused, while two resolutions produce an error. Returning immediately after a route action makes that control flow obvious.
This is response substitution, not direct component stubbing. The page still makes the request and reacts through its normal API client. That provides more integration coverage than replacing a frontend function, but less backend coverage than calling the real service. State the boundary clearly in the test name.
2. Install and create a minimal network mock
A Playwright Test project needs the test runner and browser binaries. If the repository already contains playwright.config.ts, skip initialization and use its existing baseURL, projects, and web server settings.
npm init playwright@latest
npx playwright install
Create a route before navigation because many applications fetch initial data as soon as the document loads. The example below is runnable after replacing the illustrative base URL with an application that requests /api/account.
import { test, expect } from '@playwright/test';
test('renders a suspended account', async ({ page }) => {
await page.route('**/api/account', async route => {
await route.fulfill({
status: 200,
json: {
id: 'acct-17',
owner: 'Sam Rivera',
status: 'suspended',
},
});
});
await page.goto('https://app.example.test/account');
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
await expect(page.getByText('Suspended')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create order' })).toBeDisabled();
});
Use a precise route pattern. **/api/account captures that endpoint regardless of host, while **/* captures nearly every resource and makes accidental omissions easy. If the endpoint has a query string, use **/api/account?* or a predicate function and inspect the URL.
The user-facing assertions are intentional. A test that only counts a successful mock says little about whether the interface handled the data. Validate the behavior that motivated the mock, such as disabled actions, error messages, or empty-state controls. For more focused variants, see Playwright route fulfill examples.
3. Playwright route fulfill JSON responses
Use the json option for API objects and arrays. It avoids manual JSON.stringify() and sets the JSON content type unless an explicit content type is already supplied. The payload must be serializable, so do not pass functions, cyclic objects, or class instances that depend on prototypes.
A route callback can inspect the incoming request and select a fixture by method or payload. Keep fixture selection explicit and return in every branch.
import { test, expect } from '@playwright/test';
test('shows search results for the requested term', async ({ page }) => {
let hits = 0;
await page.route('**/api/search', async route => {
const request = route.request();
if (request.method() !== 'POST') {
await route.fallback();
return;
}
const body = request.postDataJSON() as { query: string };
expect(body.query).toBe('playwright');
hits += 1;
await route.fulfill({
status: 200,
headers: { 'cache-control': 'no-store' },
json: {
items: [
{ id: 1, title: 'Playwright Network Guide' },
{ id: 2, title: 'Stable Browser Tests' },
],
total: 2,
},
});
});
await page.goto('https://app.example.test/search');
await page.getByRole('searchbox').fill('playwright');
await page.getByRole('button', { name: 'Search' }).click();
await expect(page.getByRole('listitem')).toHaveCount(2);
expect(hits).toBe(1);
});
Counting hits proves the route was actually used. This matters when a service worker, changed endpoint, browser cache, or frontend refactor bypasses the matcher. A false positive can occur if stale page data already satisfies the assertion.
Headers passed to fulfill() are response headers, not request headers. Add only values required by the scenario. Playwright handles the body length, and the json option handles content type. Avoid copying production headers mechanically because security and caching headers can create test-only browser behavior.
4. Return text, HTML, binary content, or a fixture file
body accepts a string or Buffer. Combine it with contentType when the browser or application needs to interpret the format. This is appropriate for plain text endpoints, HTML fragments, CSV exports, images generated in memory, or any controlled byte sequence.
await page.route('**/api/health', route =>
route.fulfill({
status: 200,
contentType: 'text/plain; charset=utf-8',
body: 'healthy',
})
);
For an existing file, path is simpler. Relative paths are resolved from the current working directory, which can be surprising in monorepos or when a command runs from another folder. Resolve an absolute path from a stable project location. In ESM TypeScript, import.meta.url plus fileURLToPath() avoids relying on __dirname.
import { test, expect } from '@playwright/test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const catalogFixture = path.join(currentDir, 'fixtures', 'catalog.json');
test('serves catalog fixture from disk', async ({ page }) => {
await page.route('**/api/catalog', route =>
route.fulfill({
status: 200,
path: catalogFixture,
})
);
await page.goto('https://app.example.test/catalog');
await expect(page.getByRole('heading', { name: 'Catalog' })).toBeVisible();
});
Do not pass json and body together. They are alternative body sources, and mixing them makes intent unclear. Likewise, choose either path or an in-memory body. If content negotiation matters, inspect the request accept header and return a supported representation. That turns the mock into an executable contract rather than an unconditional shortcut.
For large fixture collections, keep files small and scenario-named. A 500-line snapshot of production data hides which fields drive the UI and becomes expensive to maintain. Prefer minimal valid payloads that include every field the contract requires and every value the scenario exercises.
5. Model HTTP errors, empty states, and edge responses
One of route fulfillment's strongest uses is creating states that are costly or dangerous to reproduce in a shared environment. A 404, 409, 422, 429, or 500 can be returned without corrupting data or coordinating a backend toggle. Match the real service's error schema so the frontend follows its production parsing path.
import { test, expect } from '@playwright/test';
test('offers retry after a server error', async ({ page }) => {
let attempt = 0;
await page.route('**/api/reports/quarterly', async route => {
attempt += 1;
if (attempt === 1) {
await route.fulfill({
status: 503,
headers: { 'retry-after': '1' },
json: {
code: 'REPORT_TEMPORARILY_UNAVAILABLE',
message: 'Report generation is temporarily unavailable',
},
});
return;
}
await route.fulfill({
status: 200,
json: { period: 'Q2', revenue: 42000 },
});
});
await page.goto('https://app.example.test/reports');
await expect(page.getByRole('alert')).toContainText('temporarily unavailable');
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('Q2')).toBeVisible();
expect(attempt).toBe(2);
});
This small stateful handler is safe because the variable belongs to one isolated test. Do not put the counter at module scope, where parallel workers or retries can share unexpected state. The UI click should cause the second request. Avoid hidden timers or arbitrary waits that make the scenario slower and less clear.
An empty result is usually a successful 200 with an empty collection, not a 404. A validation failure is often a 400 or 422, not a network abort. Model the API contract rather than selecting a status merely because it displays the desired UI. For status semantics, HTTP 409 Conflict testing is a useful companion guide.
6. Distinguish HTTP failures from network failures
route.fulfill({ status: 500 }) returns an HTTP response. The browser completed the request and the application can inspect status, headers, and body. route.abort('connectionrefused') returns no HTTP response. Fetch clients typically reject the operation, and the UI may show an offline or connectivity message. These are different failure classes.
| Scenario | Route API | What application code observes |
|---|---|---|
| Validation rejected | fulfill({ status: 422, json }) |
Resolved HTTP response with status 422 |
| Server crashed after accepting connection | fulfill({ status: 500, json }) |
Resolved HTTP response with status 500 |
| Rate limited | fulfill({ status: 429, headers, json }) |
HTTP response with retry metadata |
| DNS lookup failed | abort('namenotresolved') |
Rejected network operation |
| Connection refused | abort('connectionrefused') |
Rejected network operation |
| Client cancels request | abort('aborted') |
Aborted network operation |
Testing only a 500 does not cover offline handling, and aborting every error does not cover server-generated messages. Identify the production branch and choose the corresponding network behavior.
Timeout behavior deserves similar precision. Fulfillment itself does not provide a delay option. If the requirement is loading-state behavior, assert the spinner while a controlled promise holds the route, then resolve it promptly. Do not add multi-second sleeps in every test. A local test server can model streaming or complex timing more faithfully when those protocol details matter.
Also separate response mocking from request mutation. If you need the real server to receive a changed request, use the patterns in route continue with overrides.
7. Patch a real response with route.fetch and fulfill
Sometimes the real backend should run, but one field needs to be deterministic or transformed for the test. route.fetch() performs the request and returns an APIResponse without sending it to the page. Read and modify that response, then call route.fulfill() with response as the base.
import { test, expect } from '@playwright/test';
test('adds a deterministic entitlement to the real response', async ({ page }) => {
await page.route('**/api/me', async route => {
const response = await route.fetch();
expect(response.ok()).toBeTruthy();
const original = await response.json() as {
id: string;
name: string;
entitlements: string[];
};
await route.fulfill({
response,
json: {
...original,
entitlements: [...original.entitlements, 'beta-reports'],
},
});
});
await page.goto('https://app.example.test/settings');
await expect(page.getByRole('link', { name: 'Beta reports' })).toBeVisible();
});
Passing response preserves the base response properties, while explicitly supplied fields override them. Here json replaces the response body. If you override headers, remember that you are providing the response header collection, so preserve required original values when appropriate.
route.fetch() supports request overrides plus timeout, redirect, and network-retry controls. Its network retries concern selected network errors such as connection reset, not HTTP 500 responses. Avoid retrying blindly inside tests because retries can conceal unstable services or duplicate non-idempotent actions.
This hybrid pattern is less deterministic than a full mock because it still depends on the backend. Use it when integration with the real service is part of the test's value. If the only purpose is to create one UI state, a complete fixture is usually clearer, faster, and easier to reproduce.
8. Choose page.route, browserContext.route, or HAR
page.route() applies to requests made by one page. It is a good default for a scenario local to a test. browserContext.route() applies across pages in the context, including popups, and suits shared domain rules. Register context routes before pages can trigger requests.
HAR replay is useful when an application needs many coordinated responses and a recorded network archive represents the desired contract. page.routeFromHAR() or browserContext.routeFromHAR() can serve matching requests from a HAR, with options controlling unmatched requests and update behavior. HAR files can contain tokens, cookies, personal information, and full bodies, so sanitize them before committing. Small explicit route handlers remain easier to review for a few endpoints.
| Scope | Best for | Main risk |
|---|---|---|
page.route() |
One page, one scenario | Popups are not covered |
browserContext.route() |
Multiple pages or shared context behavior | A broad rule affects more requests |
| HAR routing | Many stable recorded interactions | Stale or sensitive recordings |
| Local test server | Protocol realism and reusable contracts | More infrastructure to maintain |
If both page and context routes match, understand which handler owns the request and keep patterns disjoint where possible. For layered behavior, call route.fallback() rather than continue() when another matching handler must run. Matching handler order should be an explicit architecture choice, not something discovered during a flaky CI failure.
9. Make route fulfill assertions trustworthy
A mock can make a test pass for the wrong reason. The endpoint may have changed, the request may never fire, or the page may render old state. Add evidence at the boundary that matters. A route-hit count, request-method assertion, request-body assertion, response event, and visible UI assertion each answer a different question.
let matched = false;
await page.route('**/api/preferences', async route => {
matched = true;
expect(route.request().method()).toBe('GET');
await route.fulfill({
status: 200,
json: { theme: 'dark', compactMode: true },
});
});
await page.goto('https://app.example.test/preferences');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
expect(matched).toBe(true);
Assert important request input inside the handler before fulfilling. If the frontend sends the wrong account ID but the mock returns success for every request, the test can mask a real defect. Match path parameters, query values, body, or headers that are part of the contract.
Avoid duplicating the implementation in assertions. If a response says compactMode: true, checking a data-theme attribute alone misses the compact layout. Assert user-perceivable behavior or a stable accessibility signal. At the same time, do not assert every DOM node. The route test should communicate one scenario and a few meaningful outcomes.
Enable traces on first retry or failure in configuration for debugging. Remember that traces help explain the page and network timeline, but a dedicated route action may not appear as a standalone trace step in current Playwright releases. Clear test names and explicit route assertions remain important.
10. Playwright route fulfill architecture for maintainable tests
Treat mock payloads as test data with an owner. Type them against a local contract, generate defaults with a small factory, and override only scenario fields. This keeps payloads valid while showing what each test changes.
type AccountResponse = {
id: string;
owner: string;
status: 'active' | 'suspended';
limits: { projects: number };
};
function accountResponse(
overrides: Partial<AccountResponse> = {},
): AccountResponse {
return {
id: 'acct-default',
owner: 'Default Owner',
status: 'active',
limits: { projects: 3 },
...overrides,
};
}
await page.route('**/api/account', route =>
route.fulfill({
status: 200,
json: accountResponse({ status: 'suspended' }),
})
);
A shallow spread is enough here because the test replaces only a top-level primitive. For nested overrides, write an explicit merge for the nested object or use a scenario factory. Generic deep merge utilities can turn arrays and required values into surprising payloads.
Keep one behavior per route helper. mockSuspendedAccount(page) is easier to understand than mockAccount(page, options) with twelve toggles. Centralize contract defaults, but let scenario setup remain visible in the spec. Review mocks when the API schema changes, just as you review production clients.
Do not use route fulfillment to hide every unstable dependency. Contract tests and a smaller set of real end-to-end paths should verify that frontend expectations still match deployed services. Mocks provide controlled breadth, while live integration tests provide confidence at the service boundary. A healthy suite needs both.
Interview Questions and Answers
Q: What is route.fulfill() in Playwright?
It completes an intercepted browser request with a response supplied by the test. Options can define status, headers, content type, body, JSON, a file path, or an APIResponse base. The page processes the result through its normal network client.
Q: When should you use the json option instead of body?
Use json for serializable objects and arrays. Playwright serializes the value and sets the JSON content type when needed. Use body for raw text or Buffer content.
Q: How do you modify only one field in a real response?
Call route.fetch(), parse the returned APIResponse, create a modified payload, and fulfill with { response, json: modified }. This preserves the real response as a base while replacing the body.
Q: What is the difference between fulfilling status 500 and aborting?
A fulfilled 500 is a completed HTTP response that has status, headers, and a body. An aborted route has no HTTP response and normally rejects the browser's network operation. Applications frequently render different UI for those cases.
Q: How do you know a mock route actually ran?
Set a test-local counter or boolean inside the handler and assert it after the UI action. Also assert key request properties in the handler and a visible result on the page.
Q: Why should route registration happen before navigation?
Applications often issue requests during document startup. Registering after navigation creates a race that can let the real request pass before the handler exists.
Q: When would you use browserContext.route()?
Use it when the rule must cover requests from multiple pages or popups in the same context. For a single page and scenario, page.route() usually provides clearer scope.
Q: What makes a network mock maintainable?
A maintainable mock has a narrow matcher, minimal valid typed data, explicit status behavior, and assertions that prove the request and UI outcome. It also has complementary contract or live integration coverage to detect API drift.
Common Mistakes
- Installing the route after navigation or after the button click that starts the request.
- Matching every request with
**/*when only one endpoint should be mocked. - Returning a payload that does not match the real service contract.
- Using
body: JSON.stringify(data)without a JSON content type whenjson: datais clearer. - Treating a fulfilled HTTP 500 as equivalent to an aborted network connection.
- Forgetting to resolve a conditional branch or trying to resolve one route twice.
- Using shared mutable counters across parallel tests.
- Mocking a success response for any method, path parameter, or request body, which can hide frontend defects.
- Committing HAR files or fixtures that contain secrets or personal information.
- Asserting only the response status and not the user-visible behavior.
- Depending on a relative fixture path that changes with the process working directory.
Conclusion
Playwright route fulfill is the direct way to supply controlled HTTP responses to browser requests. Use json for API payloads, body or path for other content, explicit statuses for server behavior, and route.fetch() plus fulfill() when a real response should be patched.
Register the route before the trigger, resolve every branch exactly once, validate meaningful request input, and assert the interface outcome. Begin with one narrow mock for one hard-to-create state, then add typed factories and broader routing only when the suite has a clear need.
Interview Questions and Answers
Explain route.fulfill in Playwright.
route.fulfill() completes a routed browser request with a test-controlled HTTP response. It supports status, headers, contentType, body, json, path, and an APIResponse base. I use it for deterministic UI states and assert both key request input and visible behavior.
What is the difference between route.fulfill and route.continue?
fulfill supplies the response to the page, so the original backend is not called unless I fetched it separately. continue sends the request to the real network, optionally with request overrides. The choice depends on whether I am controlling the response or modifying the outbound request.
How do you patch a live API response in a Playwright test?
I call route.fetch(), validate and parse the APIResponse, create the changed payload, then call route.fulfill({ response, json: changedPayload }). This retains the real response as a base while overriding selected fields.
How would you test server failure and offline failure?
For server failure I fulfill with the real error status and schema, such as 503 plus retry metadata. For offline or connection failure I call route.abort() with an appropriate error code. I assert the distinct UI behavior for both because one has an HTTP response and the other does not.
How do you prevent a route mock from hiding a bad request?
I keep the matcher method-aware and assert path parameters, query values, important headers, or postData inside the handler. The handler fulfills only requests that satisfy the intended contract. I also keep contract or live integration tests outside the mocked suite.
What are the risks of HAR-based mocking?
HAR files can become stale, contain secrets or personal data, and obscure which response fields matter. They are useful for many coordinated requests, but I sanitize and review them. For a small number of endpoints I prefer explicit handlers and minimal typed fixtures.
How would you organize mock data in a large suite?
I define typed minimal defaults and scenario-specific factories, then override only the fields relevant to each test. I avoid giant production snapshots and generic helpers with many switches. Schema changes trigger review of the factories and contract tests.
Why can a fulfilled test pass even when the route never matched?
The UI may show cached, default, or previously loaded state that already satisfies a weak assertion. I track route hits, assert request details, and verify a state that only the mocked payload can produce. That turns interception into a proven test precondition.
Frequently Asked Questions
What does Playwright route fulfill do?
It completes an intercepted browser request with a response controlled by the test. You can provide status, headers, content type, JSON, a raw body, a file path, or a fetched APIResponse.
Does route.fulfill call the real API?
Not by itself. It supplies the response directly. Call route.fetch() first and pass its APIResponse to route.fulfill() when you want the real server response as a base.
How do I mock JSON in Playwright?
Register page.route() or browserContext.route(), then call route.fulfill({ status: 200, json: payload }). Register the handler before the action that starts the request.
Can route.fulfill return a fixture file?
Yes. Pass an absolute or stable relative file path with route.fulfill({ path }). Playwright infers the content type from the file extension.
How do I mock a 500 response in Playwright?
Fulfill the matching route with status 500 and a body or JSON error matching the service contract. Use route.abort() instead if the scenario is a transport failure with no HTTP response.
Should I use page.route or browserContext.route for mocks?
Use page.route() for one page and browserContext.route() when the mock must cover all pages and popups in the context. Keep the pattern narrow in either scope.
Why is my Playwright route fulfill mock not running?
The route may be registered too late, the URL pattern may not match, a service worker may own the request, or another handler may have resolved it first. Add a route-hit assertion and inspect the actual request URL.