name: network-mock-server description: > Set up request interception to mock API responses: record real responses for baseline, replay them deterministically, modify response fields, add latency per endpoint, simulate errors, and persist mocks across navigation. Supports both Playwright page.route() and CDP Fetch domain for lower-level control.
Network Mock Server
Create a flexible API mocking layer that intercepts network requests and returns controlled responses. Record real API responses as a baseline, then replay, modify, or replace them for testing. Mocks persist across page navigations within the same browser context.
When to Use
- Testing frontend behavior against specific API response shapes.
- Reproducing edge cases by modifying real API response fields.
- Simulating slow endpoints to test loading states and timeouts.
- Testing error handling by returning specific HTTP status codes per endpoint.
- Creating deterministic test environments that do not depend on backend availability.
- Recording API responses for offline development or snapshot testing.
Prerequisites
- Playwright MCP server connected and responding (all
mcp__playwright__browser_*tools available). - Chromium-based browser required for CDP
Fetch.enable(optional, for lower-level control). - Target page must make API requests (XHR/fetch) that you want to intercept.
Workflow
Step 1 -- Navigate and Record Baseline API Responses
First, load the page normally and capture all API responses as a baseline.
browser_navigate({ url: "<target_url>" })
browser_wait_for({ time: 5 })
Capture all network requests made during page load:
browser_network_requests({ includeStatic: false })
Step 2 -- Record Detailed API Responses
Install a response recorder that captures full response bodies for API calls.
browser_run_code({
code: `async (page) => {
const recorded = [];
// Listen to all responses
page.on('response', async (response) => {
const url = response.url();
// Only record API calls, skip static assets
if (url.includes('/api/') || url.includes('/graphql') ||
response.request().resourceType() === 'xhr' ||
response.request().resourceType() === 'fetch') {
try {
const body = await response.text();
recorded.push({
url: url,
method: response.request().method(),
status: response.status(),
headers: response.headers(),
contentType: response.headers()['content-type'] || null,
body: body.substring(0, 50000),
timestamp: Date.now()
});
} catch (e) {
recorded.push({
url: url,
method: response.request().method(),
status: response.status(),
error: 'Could not read body: ' + e.message,
timestamp: Date.now()
});
}
}
});
// Store reference for later harvest
page.__recordedResponses = recorded;
// Reload to capture from scratch
await page.reload({ waitUntil: 'networkidle' });
return 'Response recorder installed and page reloaded';
}`
})
Step 3 -- Harvest Recorded Responses
browser_run_code({
code: `async (page) => {
const recorded = page.__recordedResponses || [];
return {
totalRecorded: recorded.length,
endpoints: recorded.map(r => ({
method: r.method,
url: r.url,
status: r.status,
contentType: r.contentType,
bodySize: r.body ? r.body.length : 0,
bodyPreview: r.body ? r.body.substring(0, 500) : null
}))
};
}`
})
Step 4 -- Set Up Mock Replay (Exact Replay)
Replay recorded responses for deterministic behavior.
browser_run_code({
code: `async (page) => {
const recorded = page.__recordedResponses || [];
if (recorded.length === 0) return { error: 'No recorded responses to replay' };
// Build a lookup map: method+url -> response
const mockMap = {};
for (const r of recorded) {
const key = r.method + ' ' + new URL(r.url).pathname;
if (!mockMap[key]) mockMap[key] = r; // First response wins
}
// Clear any existing routes
await page.unrouteAll();
// Install mock routes
await page.route('**/*', (route) => {
const req = route.request();
const key = req.method() + ' ' + new URL(req.url()).pathname;
const mock = mockMap[key];
if (mock) {
route.fulfill({
status: mock.status,
contentType: mock.contentType || 'application/json',
body: mock.body,
headers: { 'x-mocked': 'true' }
});
} else {
// Pass through non-mocked requests
route.continue();
}
});
return {
mockedEndpoints: Object.keys(mockMap).length,
endpoints: Object.keys(mockMap)
};
}`
})
Step 5 -- Mock with Modified Responses
Modify specific fields in recorded responses (e.g., change user name, empty arrays, alter counts).
browser_run_code({
code: `async (page) => {
await page.unrouteAll();
// Example: modify a specific endpoint's response
await page.route('**/api/users**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'x-mocked': 'modified' },
body: JSON.stringify({
users: [
{ id: 1, name: 'Mock User 1', email: 'mock1@test.com', role: 'admin' },
{ id: 2, name: 'Mock User 2', email: 'mock2@test.com', role: 'user' }
],
total: 2,
page: 1
})
});
});
// Example: return empty collection
await page.route('**/api/notifications**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
headers: { 'x-mocked': 'empty' },
body: JSON.stringify({ notifications: [], unread: 0 })
});
});
// Pass through everything else
await page.route('**/*', (route) => {
if (!route.request().url().includes('/api/users') &&
!route.request().url().includes('/api/notifications')) {
route.continue();
}
});
return 'Modified mocks installed for /api/users and /api/notifications';
}`
})
Reload and verify:
browser_run_code({
code: `async (page) => {
await page.reload({ waitUntil: 'networkidle' });
return 'Page reloaded with modified mocks';
}`
})
browser_take_screenshot({ type: "png", filename: "mock-modified-response.png" })
Step 6 -- Mock with Latency
Add artificial delay to specific endpoints to test loading states.
browser_run_code({
code: `async (page) => {
await page.unrouteAll();
await page.route('**/api/**', async (route) => {
const url = route.request().url();
// Add 3-second delay to specific endpoints
if (url.includes('/api/search') || url.includes('/api/data')) {
await new Promise(resolve => setTimeout(resolve, 3000));
}
// Add 1-second delay to all other API calls
else {
await new Promise(resolve => setTimeout(resolve, 1000));
}
// Continue to real server (with delay applied)
route.continue();
});
return 'Latency mocks installed: 3s for search/data, 1s for other APIs';
}`
})
browser_take_screenshot({ type: "png", filename: "mock-latency-loading.png" })
Step 7 -- Mock Error Responses Per Endpoint
Return different error codes for different endpoints.
browser_run_code({
code: `async (page) => {
await page.unrouteAll();
const errorConfig = {
'/api/auth': { status: 401, body: { error: 'Unauthorized', message: 'Token expired' } },
'/api/users': { status: 500, body: { error: 'Internal Server Error', message: 'Database connection failed' } },
'/api/upload': { status: 413, body: { error: 'Payload Too Large', message: 'File exceeds 10MB limit' } },
'/api/search': { status: 429, body: { error: 'Too Many Requests', message: 'Rate limit exceeded. Retry after 60s', retryAfter: 60 } }
};
await page.route('**/api/**', (route) => {
const pathname = new URL(route.request().url()).pathname;
for (const [pattern, config] of Object.entries(errorConfig)) {
if (pathname.includes(pattern)) {
route.fulfill({
status: config.status,
contentType: 'application/json',
headers: { 'x-mocked': 'error' },
body: JSON.stringify(config.body)
});
return;
}
}
// Pass through non-configured endpoints
route.continue();
});
return 'Error mocks installed: ' + Object.entries(errorConfig).map(([k,v]) => k + ' -> ' + v.status).join(', ');
}`
})
browser_run_code({
code: `async (page) => {
await page.reload({ waitUntil: 'domcontentloaded' });
return 'Page reloaded with error mocks';
}`
})
browser_take_screenshot({ type: "png", filename: "mock-error-responses.png" })
Step 8 -- CDP Fetch Domain for Lower-Level Control (Optional)
Use the CDP Fetch domain for intercepting requests at the network level, before they reach the service worker or browser cache.
browser_run_code({
code: `async (page) => {
const client = await page.context().newCDPSession(page);
// Enable Fetch domain to intercept requests
await client.send('Fetch.enable', {
patterns: [
{ urlPattern: '*/api/*', requestStage: 'Response' }
]
});
client.on('Fetch.requestPaused', async (params) => {
try {
// Get the original response
const response = await client.send('Fetch.getResponseBody', {
requestId: params.requestId
});
// Decode the body
let body = response.base64Encoded
? Buffer.from(response.body, 'base64').toString('utf-8')
: response.body;
// Modify the response (example: inject a field)
try {
const json = JSON.parse(body);
json.__mocked = true;
json.__mockTimestamp = Date.now();
body = JSON.stringify(json);
} catch {} // Not JSON, pass through
// Fulfill with modified response
await client.send('Fetch.fulfillRequest', {
requestId: params.requestId,
responseCode: params.responseStatusCode,
responseHeaders: params.responseHeaders,
body: Buffer.from(body).toString('base64')
});
} catch (e) {
// If modification fails, continue with original
await client.send('Fetch.continueRequest', {
requestId: params.requestId
});
}
});
return 'CDP Fetch domain interceptor installed';
}`
})
Step 9 -- Verify Mocked Data Received by Application
Confirm that the frontend received and rendered the mocked data.
browser_evaluate({
function: `() => {
// Check if mocked data is visible in the DOM
const bodyText = document.body.innerText;
const results = {
mockUserVisible: bodyText.includes('Mock User'),
pageContent: bodyText.substring(0, 1000)
};
// Check for mock indicators in recent fetch responses
if (window.performance) {
const entries = performance.getEntriesByType('resource')
.filter(e => e.name.includes('/api/'))
.slice(-10);
results.recentApiCalls = entries.map(e => ({
url: e.name,
duration: Math.round(e.duration),
transferSize: e.transferSize
}));
}
return results;
}`
})
Step 10 -- Clean Up All Mocks
browser_run_code({
code: `async (page) => {
// Remove all Playwright route handlers
await page.unrouteAll();
// Disable CDP Fetch if it was enabled
try {
const client = await page.context().newCDPSession(page);
await client.send('Fetch.disable');
} catch {}
// Reload to get real responses
await page.reload({ waitUntil: 'networkidle' });
return 'All mocks cleared, page reloaded with real responses';
}`
})
Interpreting Results
Report Format
## Network Mock Server -- <url>
### Recorded Baseline
| # | Method | Endpoint | Status | Size | Content-Type |
|---|--------|----------|--------|------|--------------|
| 1 | GET | /api/users | 200 | 2.4KB | application/json |
| 2 | GET | /api/notifications | 200 | 890B | application/json |
| 3 | POST | /api/search | 200 | 12.1KB | application/json |
| 4 | GET | /api/config | 200 | 340B | application/json |
### Active Mocks
| Endpoint | Mode | Config |
|----------|------|--------|
| /api/users | modified | 2 mock users, role=admin |
| /api/notifications | empty | {notifications: [], unread: 0} |
| /api/search | latency | 3000ms delay, real response |
| /api/auth | error | 401 Token expired |
### Verification
- Mock User 1 visible in UI: yes
- Empty notification badge: yes
- Loading spinner during search: yes (3s)
- Login redirect on auth error: yes
What to Look For
- Empty state rendering: mock endpoints with empty arrays to verify the app shows appropriate empty states (not broken layouts or errors).
- Loading state quality: add latency to observe loading indicators. Missing loading states cause perceived freezes.
- Error message clarity: mock error responses to check that user-facing error messages are helpful and specific (not "Something went wrong").
- Auth token expiry handling: mock 401 responses to verify the app redirects to login or refreshes tokens.
- Rate limit handling: mock 429 with
retryAfterto verify the app respects rate limits and shows appropriate feedback. - Large payload handling: record real responses and check if the app handles large datasets gracefully (pagination, virtual scrolling).
Limitations
- page.route() scope: Playwright route handlers are scoped to the page instance. They do not intercept requests from Service Workers, Web Workers, or other pages in the context.
- Navigation persistence:
page.route()handlers persist across same-page navigations but are lost if the page is closed. CDP Fetch domain handlers persist across navigations within the same CDP session. - Response body recording: large response bodies (>50KB) are truncated during recording to prevent memory issues. Binary responses (images, fonts) are not recorded.
- GraphQL: GraphQL endpoints all use the same URL with different query bodies. URL-based matching will intercept all GraphQL requests. Use request body inspection (via CDP Fetch) for fine-grained GraphQL mocking.
- CORS: mocked responses do not automatically include CORS headers. If the frontend expects specific CORS headers, include them in the mock response headers.
- Timing accuracy:
setTimeout-based latency inpage.route()is approximate and adds to any real network latency ifroute.continue()is used after the delay.