QA How-To
Playwright route fulfill: Examples and Best Practices
Use practical Playwright route fulfill examples for JSON, errors, GraphQL, pagination, response patching, and reliable mock architecture in TypeScript.
20 min read | 2,410 words
TL;DR
The most reliable route.fulfill examples use a narrow matcher, validate the incoming request, return a contract-correct response, and assert the resulting UI. Use full mocks for deterministic scenarios, sequential handlers for retry behavior, and route.fetch() only when a real response is part of the test boundary.
Key Takeaways
- Match requests by endpoint, method, and meaningful input before fulfilling them.
- Use the json option for API data, and model the service's real status and error schema.
- Keep scenario state inside each test so parallel workers and retries remain isolated.
- Patch live responses with route.fetch() and route.fulfill({ response, json }) only when backend integration adds value.
- Test GraphQL by operationName and variables, not by treating every request to the shared endpoint identically.
- Prove each mock ran and assert the visible behavior produced by its distinctive data.
These playwright route fulfill examples show how to mock success, empty results, authorization errors, retries, GraphQL operations, downloads, and selectively modified live responses. Every pattern uses the current Playwright Test API and keeps request matching, response data, and assertions in the same scenario.
route.fulfill() is easy to call but easy to misuse. A mock that accepts any request and returns convenient data can make a broken client look healthy. The examples below emphasize contract checks and observable outcomes, so the test remains valuable when endpoints, schemas, or user flows change.
TL;DR
await page.route('**/api/projects', async route => {
expect(route.request().method()).toBe('GET');
await route.fulfill({
status: 200,
json: [{ id: 'p-1', name: 'Migration' }],
});
});
| If you need to test | Use | Also assert |
|---|---|---|
| Fixed UI state | fulfill({ json }) |
Distinctive visible content |
| Error handling | fulfill({ status, json }) |
Correct message and recovery action |
| Retry sequence | Test-local counter plus conditional fulfill | Request count and final state |
| GraphQL operation | Inspect operationName and variables |
Variables and operation-specific UI |
| Real response with one change | fetch() then fulfill({ response, json }) |
Backend status and changed behavior |
| Transport outage | abort(errorCode) |
Offline or network-error UI |
1. Playwright route fulfill examples: A reusable test pattern
Most reliable mocks follow four steps: install the route, validate the request, fulfill it, and assert application behavior. Setup must happen before navigation or interaction. Validation prevents the mock from rewarding an incorrect request. The response must resemble the real service contract. The final assertion should prove the frontend consumed the controlled state.
import { test, expect } from '@playwright/test';
test('shows a project owned by the signed-in user', async ({ page }) => {
let routeHits = 0;
await page.route('**/api/projects?*', async route => {
const request = route.request();
const url = new URL(request.url());
expect(request.method()).toBe('GET');
expect(url.searchParams.get('owner')).toBe('me');
routeHits += 1;
await route.fulfill({
status: 200,
json: [
{ id: 'p-18', name: 'Checkout migration', owner: 'me' },
],
});
});
await page.goto('https://app.example.test/projects?filter=mine');
await expect(page.getByRole('heading', { name: 'My projects' })).toBeVisible();
await expect(page.getByText('Checkout migration')).toBeVisible();
expect(routeHits).toBe(1);
});
The illustrative application URL must be replaced with a system under test, but the test code uses normal Playwright APIs. Matching the query matters because a mock for the wrong owner could hide a frontend filtering defect. The hit count detects a changed endpoint or a page that never made the request.
Keep the response minimal, but not invalid. Required identifiers and enum values should match the API contract. If the suite needs a conceptual introduction before these recipes, read how to use Playwright route fulfill.
2. Mock a JSON list, an empty result, and pagination
List screens usually need at least three tests: data present, no data, and another page. Avoid one huge fixture that tries to support every state. Each test should state its response and assert the corresponding controls.
A data-present route is straightforward with the json option. For an empty response, keep pagination metadata contract-correct rather than returning a bare array if the real API wraps results.
import { test, expect } from '@playwright/test';
test('renders the empty orders state', async ({ page }) => {
await page.route('**/api/orders?*', async route => {
const url = new URL(route.request().url());
expect(url.searchParams.get('page')).toBe('1');
await route.fulfill({
status: 200,
json: { items: [], page: 1, pageSize: 20, total: 0 },
});
});
await page.goto('https://app.example.test/orders');
await expect(page.getByRole('heading', { name: 'No orders yet' })).toBeVisible();
await expect(page.getByRole('link', { name: 'Create your first order' })).toBeVisible();
});
For pagination, derive the result from the query without turning the handler into a replica of the backend. Support only the pages the test exercises, and fail clearly for unexpected values.
await page.route('**/api/orders?*', async route => {
const pageNumber = new URL(route.request().url()).searchParams.get('page');
if (pageNumber === '1') {
await route.fulfill({
status: 200,
json: { items: [{ id: 'o-1', label: 'Order 1' }], total: 2 },
});
return;
}
if (pageNumber === '2') {
await route.fulfill({
status: 200,
json: { items: [{ id: 'o-2', label: 'Order 2' }], total: 2 },
});
return;
}
throw new Error(`Unexpected page requested: ${pageNumber}`);
});
Throwing for an unsupported page gives a better diagnostic than silently returning the first fixture. Keep the route local to the test, so this strictness does not affect unrelated scenarios.
3. Mock create, update, and validation responses
Mutation tests should validate the outgoing body before returning success. Otherwise the UI could submit the wrong fields while the unconditional mock still displays a success toast. Use request.postDataJSON() for JSON requests, assert the method and payload, then return the status and response shape the real endpoint uses.
import { test, expect } from '@playwright/test';
test('creates a team with submitted values', async ({ page }) => {
await page.route('**/api/teams', async route => {
const request = route.request();
expect(request.method()).toBe('POST');
expect(request.postDataJSON()).toEqual({
name: 'Quality Platform',
visibility: 'private',
});
await route.fulfill({
status: 201,
headers: { location: '/api/teams/team-9' },
json: {
id: 'team-9',
name: 'Quality Platform',
visibility: 'private',
},
});
});
await page.goto('https://app.example.test/teams/new');
await page.getByLabel('Team name').fill('Quality Platform');
await page.getByLabel('Visibility').selectOption('private');
await page.getByRole('button', { name: 'Create team' }).click();
await expect(page.getByRole('status')).toHaveText('Team created');
await expect(page).toHaveURL(//teams/team-9$/);
});
A validation test should return the production error code and field schema. For example, a service may use 422 with an errors object. Do not invent a generic message if the frontend expects field-level detail.
await route.fulfill({
status: 422,
json: {
code: 'VALIDATION_FAILED',
errors: { name: ['A team with this name already exists'] },
},
});
Then assert the error near the named control, focus behavior if accessibility requires it, and confirm the form data remains available for correction. This tests recovery rather than only message rendering.
4. Fulfill 401, 403, 404, 409, and 500 scenarios
HTTP errors express different product states. A 401 usually means missing or expired authentication. A 403 means the identity is known but not allowed. A 404 means the resource is unavailable at that identifier. A 409 indicates a conflict with current state. A 500 means the server failed unexpectedly. Your mock should preserve those distinctions.
| Status | Example scenario | Useful UI assertion |
|---|---|---|
| 401 | Session expired | Sign-in prompt or reauthentication flow |
| 403 | Viewer cannot edit | Permission message and hidden edit action |
| 404 | Record was deleted | Not-found page and back navigation |
| 409 | Stale version update | Conflict message and reload option |
| 500 | Unexpected server failure | Generic error, retry, and safe retained state |
import { test, expect } from '@playwright/test';
test('handles an edit conflict without losing the draft', async ({ page }) => {
await page.route('**/api/documents/doc-7', async route => {
expect(route.request().method()).toBe('PUT');
await route.fulfill({
status: 409,
json: {
code: 'VERSION_CONFLICT',
message: 'A newer document version is available',
currentVersion: 8,
},
});
});
await page.goto('https://app.example.test/documents/doc-7/edit');
await page.getByLabel('Title').fill('My corrected title');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('alert')).toContainText('newer document version');
await expect(page.getByLabel('Title')).toHaveValue('My corrected title');
await expect(page.getByRole('button', { name: 'Reload latest' })).toBeVisible();
});
This assertion set proves the user does not lose work. It is stronger than checking only that an alert appeared. For deeper conflict cases, consult HTTP 409 Conflict causes and testing.
Use route.abort() instead of fulfill when the requirement is a connection or DNS failure. An HTTP status and a network rejection travel through different application code.
5. Return sequential responses for retry testing
Retry flows need controlled progression. Keep an attempt counter inside the test and return an error on the first call followed by success. Assert the exact request count so an accidental retry storm cannot pass unnoticed.
import { test, expect } from '@playwright/test';
test('retries inventory after a temporary failure', async ({ page }) => {
let calls = 0;
await page.route('**/api/inventory/sku-10', async route => {
calls += 1;
if (calls === 1) {
await route.fulfill({
status: 503,
headers: { 'retry-after': '0' },
json: { code: 'TEMPORARILY_UNAVAILABLE' },
});
return;
}
await route.fulfill({
status: 200,
json: { sku: 'sku-10', available: 6 },
});
});
await page.goto('https://app.example.test/products/sku-10');
await expect(page.getByRole('alert')).toContainText('Inventory unavailable');
await page.getByRole('button', { name: 'Try again' }).click();
await expect(page.getByText('6 available')).toBeVisible();
expect(calls).toBe(2);
});
Prefer an explicit user retry in a UI test because it avoids artificial waits and directly tests the control. If production uses automatic backoff, use Playwright's clock capabilities to control timers rather than sleeping for real seconds. Keep network response sequencing separate from time control, so a failure tells you whether routing or scheduling broke. The Playwright clock API examples explain deterministic timer testing.
Do not place calls at module level. Playwright workers, retries, or multiple projects can make global state nondeterministic. A closure inside the test belongs to that test instance. If several tests need the pattern, create a helper that returns both an install function and a local call-count accessor.
6. Playwright route fulfill examples for GraphQL
GraphQL often sends many operations to one URL, so an endpoint-only matcher is not enough. Read the JSON body, branch on operationName, validate variables, and fall back or fail for unknown operations. A GraphQL HTTP response can still have status 200 while its body contains an errors array, so model the real client and server convention.
import { test, expect } from '@playwright/test';
test('renders the requested customer query', async ({ page }) => {
await page.route('**/graphql', async route => {
const request = route.request();
const body = request.postDataJSON() as {
operationName?: string;
variables?: Record<string, unknown>;
};
if (body.operationName !== 'CustomerDetails') {
await route.fallback();
return;
}
expect(body.variables).toEqual({ id: 'customer-14' });
await route.fulfill({
status: 200,
json: {
data: {
customer: {
id: 'customer-14',
name: 'Robin Chen',
tier: 'gold',
},
},
},
});
});
await page.goto('https://app.example.test/customers/customer-14');
await expect(page.getByRole('heading', { name: 'Robin Chen' })).toBeVisible();
await expect(page.getByText('Gold')).toBeVisible();
});
route.fallback() is important when another handler or the network should own unrelated operations. Calling route.continue() would skip other matching handlers. In a fully isolated test, throwing on an unexpected operation is even stricter and can reveal hidden dependencies.
For a GraphQL business error, return the schema expected by the client, such as { data: null, errors: [{ message, extensions }] }. Test partial-data behavior only if the production client supports it. Do not mock an operation name that the application omits unless you also have a reliable query parser or a stable request identifier.
7. Serve files and test downloads through fulfillment
A fulfilled response can represent a downloadable file. Set content type, content-disposition, and the raw body. Then wait for Playwright's download event before clicking the control that triggers the request.
import { test, expect } from '@playwright/test';
test('downloads a fulfilled CSV export', async ({ page }) => {
await page.route('**/api/reports/export.csv', async route => {
await route.fulfill({
status: 200,
headers: {
'content-disposition': 'attachment; filename=orders.csv',
},
contentType: 'text/csv; charset=utf-8',
body: 'id,total\no-1,125\no-2,90\n',
});
});
await page.goto('https://app.example.test/reports');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export CSV' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('orders.csv');
});
This checks browser download integration and filename handling. If content correctness matters, save the download to the test output folder and inspect it, or use download.createReadStream() and parse the bytes. Keep file cleanup inside Playwright's test output lifecycle.
For a fixture already on disk, use route.fulfill({ path: absolutePath }). Playwright infers content type from the extension, but add content-disposition when download behavior depends on it. Do not use a source repository path as a download destination because parallel workers can overwrite each other.
A fulfilled response is suitable for ordinary finite file bodies. If the requirement concerns streaming, range requests, very large files, or interrupted transfers, a small purpose-built local server offers more accurate protocol control.
8. Patch selected fields in a live response
Full mocking is ideal for deterministic states. Response patching is useful when the actual service response is valuable but one value must be forced. Call route.fetch(), check its status, parse it, then fulfill with the fetched response as a base and override the body.
import { test, expect } from '@playwright/test';
test('forces one feature flag while preserving live flags', async ({ page }) => {
await page.route('**/api/features', async route => {
const response = await route.fetch();
expect(response.ok()).toBeTruthy();
const flags = await response.json() as Record<string, boolean>;
await route.fulfill({
response,
json: {
...flags,
redesignedBilling: true,
},
});
});
await page.goto('https://app.example.test/billing');
await expect(page.getByRole('heading', { name: 'Billing overview' })).toBeVisible();
await expect(page.getByTestId('redesigned-billing')).toBeVisible();
});
This test still depends on network availability, authentication, and the response being valid JSON. Do not call it a fully mocked test. If response.ok() is false, fail rather than merging an error document into expected data.
Be deliberate about headers. Passing { response, json } uses the fetched response as a base and replaces the body. If you provide a new headers object, preserve response headers required by the client. Avoid carrying a stale content-length when the body changes, and let Playwright construct the final response appropriately.
Use patching sparingly. It can conceal an important interaction between flags or fields if a live response varies. A complete scenario fixture communicates the assumed inputs more clearly when backend behavior is not under test.
9. Route scope, order, service workers, and cleanup
Route behavior depends on where and when it is installed. page.route() covers one page. browserContext.route() covers pages in that context, including popups. A page-level rule is easier to reason about for a local scenario, while a context rule is appropriate for an authentication domain or multi-page flow.
When multiple handlers match, newer registrations run before older registrations. A handler that calls route.fallback() passes control to the next match. A handler that calls fulfill(), continue(), or abort() finishes processing. Keep matchers disjoint where possible. If layering is required, document the registration order in a helper test.
Service workers can handle requests before normal page routing. If a route never fires in an application that registers one, a test focused on network interception can create its browser context with serviceWorkers: 'block'. That changes runtime behavior, so retain separate coverage for the real service worker path. Routing also disables the browser HTTP cache, which means route-enabled tests should not be used to draw conclusions about cache performance.
Playwright Test creates isolated contexts by default. That is the simplest cleanup strategy. If a long-lived page needs a temporary route, keep the handler reference and remove it with page.unroute(pattern, handler). Do not call broad unroute operations in shared setup unless the ownership is explicit.
Route callbacks should not use secrets from production logs or print full request headers. Attach only sanitized diagnostic fields. Mock data belongs in source control only when it contains no credentials or personal data.
10. Best practices for dynamic route fulfill helpers
A route helper should make the scenario more readable without hiding critical behavior. A small typed helper can centralize fulfillment while leaving the endpoint and payload visible.
import type { Page } from '@playwright/test';
type JsonValue =
| string
| number
| boolean
| null
| JsonValue[]
| { [key: string]: JsonValue };
async function fulfillJson(
page: Page,
pattern: string,
payload: JsonValue,
status = 200,
): Promise<void> {
await page.route(pattern, async route => {
await route.fulfill({ status, json: payload });
});
}
await fulfillJson(page, '**/api/notifications', {
items: [{ id: 'n-1', text: 'Build completed', read: false }],
});
In a strict codebase, you may prefer endpoint-specific response types over the broad Serializable type. The helper above demonstrates the API, but a domain helper can enforce that required fields are present. Avoid one universal mock function with options for delay, status sequences, body transforms, proxying, and recording. That abstraction becomes a private network framework and makes simple tests hard to read.
Keep contract checks beside the route. For a mutation helper, accept an expected request object and assert it before fulfillment. For a query helper, accept expected search parameters. Return a small controller only when a test needs call counts or a state transition.
Review mocks during API changes. Compile-time types catch some drift, but they do not validate semantics, headers, or server defaults. Consumer contract tests and a narrow set of real service journeys complement the breadth provided by route fulfillment.
Interview Questions and Answers
Q: How do you make a route.fulfill mock prove the request was correct?
Inspect route.request() before fulfillment. Assert the method, path, important query parameters, headers, and post body that define the contract. Then return the controlled response and assert the UI result.
Q: How would you mock two responses from the same endpoint?
Use test-local state, such as an attempt counter, and fulfill conditionally. Return after each branch and assert the final call count. Do not share the counter across tests or workers.
Q: How should GraphQL requests be routed?
Match the shared GraphQL URL, parse postDataJSON(), and branch on operationName. Assert variables and either fulfill the target operation, fall back for unrelated operations, or fail strictly when isolation requires it.
Q: When is route.fetch plus fulfill preferable to a full mock?
Use it when the real backend interaction is part of the test's value and only a response field needs deterministic modification. It is still network-dependent, so a full fixture is better for isolated UI states.
Q: What is a good way to verify a download mock?
Fulfill with realistic content type and content-disposition headers, create page.waitForEvent('download') before clicking, and assert the suggested filename. Inspect the stream or saved output when content is part of the requirement.
Q: Why is a 409 mock different from a 500 mock?
A 409 represents a known state conflict and should usually provide a recovery action such as reload or merge. A 500 represents an unexpected server failure. Matching the service schema and asserting the correct recovery UI preserves that distinction.
Q: How do service workers affect page.route?
A service worker can intercept a request before page routing observes it. For routing-focused tests, block service workers in the context and retain separate coverage for service-worker behavior.
Q: What prevents mock data from becoming unmaintainable?
Use minimal typed factories, descriptive scenario overrides, strict request validation, and contract or live integration tests. Avoid copied production snapshots and general helpers with many unrelated switches.
Common Mistakes
- Fulfilling any request to a path without checking the HTTP method or relevant input.
- Registering the route after the application has already started the request.
- Returning a simplified schema that the production service never returns.
- Using a module-level response counter that leaks across retries or workers.
- Treating every GraphQL operation identically because it shares one endpoint.
- Using
continue()when another matching handler should run, instead offallback(). - Calling
route.fetch()for an isolated test that should not depend on a backend. - Testing an HTTP error with
abort()or a transport failure with a fulfilled status. - Relying on UI data that could be cached without proving the route was hit.
- Storing secrets, cookies, or personal information in fixtures and HAR files.
- Building a universal mock helper that hides scenario intent and handler order.
Conclusion
Strong playwright route fulfill examples are small contracts: a specific request arrives, the test verifies its important input, a realistic response is returned, and the interface demonstrates the expected behavior. This pattern works for lists, mutations, errors, retries, GraphQL, downloads, and response patching.
Choose full fulfillment for deterministic breadth, fetch-and-fulfill for selected live integration, and abort for transport failures. Start by strengthening one existing mock with request validation and a route-hit assertion, then expand only where controlled network behavior improves coverage.
Interview Questions and Answers
Describe a robust route.fulfill test pattern.
I install a narrow route before the trigger, validate the intercepted request, return a response matching the real contract, and assert the distinctive user-facing result. I also count route hits when interception is a required precondition. This prevents convenient mocks from hiding bad client requests.
How would you test pagination with route.fulfill?
I inspect the page query parameter and return a small fixture for each page the scenario exercises. Unexpected page values fail clearly. The test clicks the next control and verifies both the requested page and the changed records.
How do you mock retry behavior without making tests flaky?
I keep an attempt counter inside the test and return deterministic sequential statuses. I prefer an explicit retry button or control time with Playwright's clock APIs instead of sleeping. I assert both the final UI and exact request count.
What should a GraphQL route handler inspect?
It should parse the JSON body, match operationName, and validate variables. It can fulfill the target operation and fall back for unrelated operations. The payload must follow GraphQL conventions, including data and errors behavior used by the actual client.
How would you test optimistic concurrency with a mock?
I validate the update request, fulfill it with the service's 409 schema, and assert that the UI retains the draft while offering a recovery action. That checks more than an error banner. A separate success test covers the normal update path.
What are the tradeoffs of patching a real response?
route.fetch plus fulfill preserves backend participation and can force one deterministic field. It also retains network, authentication, data, and schema dependencies, so it is slower and less isolated than a full mock. I use it only when that integration is part of the test goal.
How do you scope route mocks in a multi-page test?
I use browserContext.route() when popups or multiple pages need the same behavior and page.route() for local page behavior. I keep matchers narrow and make handler order explicit when fallback composition is required.
How do you keep network fixtures secure?
I use synthetic data, remove tokens and personal information, and review fixture and HAR diffs. Diagnostics log only sanitized fields. Secrets remain in runtime configuration and are never embedded in fulfilled payloads or recordings.
Frequently Asked Questions
What is the simplest Playwright route fulfill example?
Register `page.route('**/api/path', handler)` before the request and call `route.fulfill({ status: 200, json: payload })` in the handler. Assert a visible state that only that payload can produce.
How do I return different mocked responses on retry?
Keep a counter inside the test closure and increment it in the route handler. Fulfill the first call with an error, later calls with success, and assert the exact total.
How do I mock GraphQL with Playwright route fulfill?
Route the GraphQL endpoint, parse request.postDataJSON(), and identify the operation by operationName. Validate variables and fulfill only the intended operation with a GraphQL-compatible data or errors object.
Can route.fulfill test file downloads?
Yes. Return the file bytes or text with a suitable content type and content-disposition header. Start waiting for the download event before clicking the action that triggers it.
How do I modify a real response in Playwright?
Call route.fetch(), parse and modify the APIResponse body, then call route.fulfill({ response, json: modifiedData }). Use this only when the live backend dependency is intentional.
Should a mocked error use route.abort or route.fulfill?
Use fulfill for HTTP errors such as 401, 409, or 500 because the application receives a status and body. Use abort for transport failures such as a refused connection or failed DNS lookup.
How can I stop Playwright mocks from hiding frontend bugs?
Validate the incoming method, URL data, headers, and body before fulfillment. Count route hits, keep payloads contract-correct, and retain contract or live integration coverage for the real service boundary.
Related Guides
- Playwright route abort: Examples and Best Practices
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright popup and new tab handling: Examples and Best Practices
- Playwright route continue with override: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices