15 Days of Playwright - Day 4
Mocking network requests is a crucial aspect of testing applications and intercepting responses. In this lesson, I will demonstrate how to mock network requests in Playwright tests.


Jhonatas Matos
API mocking is one of Playwright's most powerful features for creating reliable, fast, and deterministic tests. By controlling network responses, you can test edge cases, simulate errors, and eliminate external dependencies that make tests flaky.
Today, I will explore how to intercept and mock API requests using Playwright's network capabilities.
Why Mock APIs?
API mocking allows you to create controlled testing environments by:
- Isolating tests from external dependencies
- Simulating edge cases that are hard to reproduce
- Speeding up tests by avoiding real network calls
- Testing error handling without breaking real systems
- Stabilizing CI environments by eliminating flakiness
How Playwright's Network Interception Works
When you use page.route()
, Playwright acts as a man-in-the-middle:
- Your test registers a route handler
- Playwright intercepts matching requests
- Your handler decides to mock, modify, or continue the request
- The browser receives the controlled response
Understanding page.route()
The page.route()
method is your gateway to network control. It takes two parameters:
- URL Pattern: A glob pattern to match requests (
**/api/*
matches any API endpoint) - Handler Function: An async function that receives a
Route
object
- Modify API responses
- Simulate different HTTP status codes
- Test error scenarios
- Accelerate tests by avoiding real network calls
The Route Object Methods:
route.fulfill()
- Respond with mock dataroute.continue()
- Continue to real serverroute.abort()
- Abort the requestroute.fetch()
- Get the real response first
URL Pattern Examples:
**/api/users
- Match specific endpoint exactly**/api/*/detail
- Match nested endpoints**/*.json
- Match all JSON files**/api/**
- Match anything under /api/ (use cautiously!)
Basic Syntax
await page.route('**/api/endpoint', async route => {
// Handle the request here
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: 'mocked' })
});
});
In this example, we intercept requests to /api/endpoint and respond with a mocked JSON object.
Common Mocking Patterns
Basic Success Response
When to use: Testing happy paths and normal user flows
// Mock a successful login API call
await page.route('**/api/login', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
user: { id: 1, name: 'John Doe', email: 'john@example.com' },
token: 'fake-jwt-token'
})
});
});
In this example, we intercept requests to /api/login and respond with a mocked JSON object.
Error Scenarios
// Mock a server error
await page.route('**/api/transfer', async route => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
error: 'Internal server error',
message: 'Please try again later'
})
});
});
// Mock authentication failure
await page.route('**/api/profile', async route => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({
error: 'Unauthorized',
message: 'Invalid credentials'
})
});
});
In these examples, we intercept requests to /api/transfer and /api/profile and respond with mocked error responses.
Dynamic Responses Based on Request
// Respond differently based on request data
await page.route('**/api/products/*', async route => {
const request = route.request();
const productId = request.url().split('/').pop();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: productId,
name: `Product ${productId}`,
price: 99.99,
inStock: true
})
});
});
In this example, we intercept requests to /api/products/ and respond with a dynamic product object based on the requested product ID.
Adding Artificial Delay
// Simulate slow network
await page.route('**/api/transactions', async route => {
await new Promise(resolve => setTimeout(resolve, 2000)); // 2-second delay
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([])
});
});
Real-World Scenarios
Scenario 1: Testing Loading States
test('should show loading indicator during API call', async ({ page }) => {
// Mock delayed response
await page.route('**/api/user-data', async route => {
await new Promise(resolve => setTimeout(resolve, 1000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ name: 'John Doe' })
});
});
await page.goto('/profile');
// Verify loading indicator appears
await expect(page.locator('.loading-spinner')).toBeVisible();
// Verify data loads and spinner disappears
await expect(page.locator('.user-name')).toHaveText('John Doe');
await expect(page.locator('.loading-spinner')).toBeHidden();
});
Scenario 2: Testing Error Handling
test('should display error message on API failure', async ({ page }) => {
// Mock API failure
await page.route('**/api/checkout', async route => {
await route.fulfill({
status: 402,
contentType: 'application/json',
body: JSON.stringify({
error: 'Payment required',
message: 'Insufficient funds'
})
});
});
await page.goto('/checkout');
await page.click('#place-order');
// Verify error UI is displayed correctly
await expect(page.locator('.error-message'))
.toHaveText('Insufficient funds');
await expect(page.locator('.retry-button')).toBeVisible();
});
Scenario 3: A/B Testing Simulation
test('should handle different feature flag responses', async ({ page }) => {
// Mock different feature flag responses
await page.route('**/api/feature-flags', async route => {
const userType = Math.random() > 0.5 ? 'premium' : 'standard';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
newDashboard: userType === 'premium',
darkMode: true,
exportEnabled: userType === 'premium'
})
});
});
await page.goto('/dashboard');
// Test both UI variations
const isPremium = await page.locator('.premium-feature').isVisible();
if (isPremium) {
await expect(page.locator('.export-button')).toBeVisible();
} else {
await expect(page.locator('.export-button')).toBeHidden();
}
});
Advanced Techniques
Request Inspection
// Analyze requests before deciding how to handle them
await page.route('**/api/**', async route => {
const request = route.request();
console.log('Request URL:', request.url());
console.log('Method:', request.method());
console.log('Headers:', request.headers());
console.log('Post Data:', await request.postData());
// Continue with actual request
await route.continue();
});
Conditional Mocking
// Only mock specific requests
await page.route('**/api/**', async route => {
const request = route.request();
if (request.url().includes('/api/transactions')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, amount: 100 }])
});
} else {
// Pass through other API calls
await route.continue();
}
});
Response Modification
// Modify actual API responses
await page.route('**/api/products', async route => {
const response = await route.fetch();
const json = await response.json();
// Add additional data to response
json.push({ id: 999, name: 'Injected Product', price: 0.01 });
await route.fulfill({
response,
contentType: 'application/json',
body: JSON.stringify(json)
});
});
Request Inspection - The "Spy" Pattern
Sometimes you want to observe without interfering - perfect for:
- Analytics testing
- Request validation
- Performance monitoring
// Analyze requests before deciding how to handle them
await page.route('**/api/**', async route => {
const request = route.request();
console.log('📡 Request intercepted:', request.method(), request.url());
if (request.url().includes('/health')) {
// Don't mock health checks
await route.continue();
} else {
// Mock other API calls
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
}
});
Practical BugBank Example
import { test, expect } from '@playwright/test';
test.describe('BugBank API Mocking', () => {
test('should complete transfer with mocked API', async ({ page }) => {
// Mock balance check
await page.route('**/api/balance', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ balance: 2000 })
});
});
// Mock successful transfer
await page.route('**/api/transfer', async route => {
const request = route.request();
const postData = JSON.parse(await request.postData());
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
transactionId: 'txn_12345',
newBalance: 2000 - postData.amount,
message: 'Transfer completed successfully'
})
});
});
// Test execution
await page.goto('https://bugbank.netlify.app');
// Verify initial balance
await expect(page.locator('.balance-amount')).toHaveText('2000');
// Perform transfer
await page.fill('#recipient', 'user2@example.com');
await page.fill('#amount', '500');
await page.click('#transfer-button');
// Verify success
await expect(page.locator('.transaction-status'))
.toHaveText('Transfer completed successfully');
await expect(page.locator('.balance-amount')).toHaveText('1500');
});
test('should handle transfer failure', async ({ page }) => {
// Mock transfer failure
await page.route('**/api/transfer', async route => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
success: false,
error: 'Insufficient funds',
message: 'Your account balance is too low'
})
});
});
await page.goto('https://bugbank.netlify.app');
await page.fill('#recipient', 'user2@example.com');
await page.fill('#amount', '5000'); // Too much!
await page.click('#transfer-button');
// Verify error handling
await expect(page.locator('.error-message'))
.toHaveText('Your account balance is too low');
await expect(page.locator('.retry-button')).toBeVisible();
});
});
Best Practices
Be Specific with URL Patterns
// ✅ Good - specific endpoint
await page.route('**/api/users/123', handler);
// ❌ Avoid - too broad
await page.route('**/api/**', handler);
Clean Up Between Tests
import { test } from '@playwright/test';
test.afterEach(async () => {
await page.unrouteAll(); // Remove all handlers
});
Use Realistic Response Data
// ✅ Use realistic data structure
await route.fulfill({
body: JSON.stringify({
id: 1,
name: 'Real Product',
price: 29.99,
inventory: 42
})
});
Test Both Success and Error Paths
describe('API Tests', () => {
test('success case', () => { /* mock 200 response */ });
test('error case', () => { /* mock 400/500 response */ });
test('network failure', () => { /* mock timeout */ });
});
⚠️ Warning: Avoid over-mocking! Excessive mocking can:
- Create false positives if reality differs from mocks
- Miss integration issues between services
- Require maintenance as APIs evolve
Rule of thumb: Mock external dependencies, test internal integrations.
Debugging Tips
// Enable request logging
await page.route('**/api/**', async route => {
console.log(`➡️ ${route.request().method()} ${route.request().url()}`);
await route.continue();
});
// Or use Playwright's debug logging
DEBUG=pw:api npx playwright test
I added an example in the repository, check in the link below:
Link to GitHub project
References
If you want to read more about mocking check these complete guides:
API Mocking Best Practices
Network Mocking Best Practices
Conclusion
API mocking is not about avoiding the backend - it's about creating reliable test environments. The key benefits:
Speed & Reliability
- Tests run 10-100x faster without network calls
- No more flaky tests due to network issues
- Consistent results across all environments
Comprehensive Testing
- Test all edge cases: errors, timeouts, empty responses
- Simulate real-world scenarios: slow networks, server outages
- Validate error handling and user experience
Maintenance Benefits
- Parallel development: frontend and backend can work independently
- Documentation: mocks serve as API contract examples
- Debugging: isolate frontend vs backend issues
Remember: Mock strategically, not excessively. Use real APIs for critical paths and mocking for edge cases and performance.
Thank you for reading and see you in the next lesson! ☕💜