Network Interception & Mocking
How do you test what happens when a payment fails? When the inventory API returns an error? When the network is slow? You can't wait for these conditions to occur naturally. Instead, you mock them.
Why Mock Network Requests?
| Scenario | Real Network | Mocked |
|---|---|---|
| Payment failure | Need a card that always fails | Mock decline response |
| Out of stock | Wait for inventory depletion | Mock empty stock |
| Slow network | Throttle actual connection | Add artificial delay |
| API error (500) | Wait for server issues | Mock error response |
| Third-party down | Wait for outage | Mock timeout |
Mocking makes the untestable testable.
Basic Route Interception
page.route() Fundamentals
import { test, expect } from '@playwright/test';
test('intercept products API', async ({ page }) => {
// Intercept requests matching the pattern
await page.route('**/api/products', async (route) => {
// You can:
// 1. Fulfill with mock data
// 2. Continue to real server
// 3. Abort the request
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: '1', name: 'Mocked Product', price: 99.99 },
{ id: '2', name: 'Another Mock', price: 49.99 },
]),
});
});
await page.goto('/products');
// Page displays mocked data
await expect(page.getByText('Mocked Product')).toBeVisible();
await expect(page.getByText('$99.99')).toBeVisible();
});
Route Matching Patterns
// Exact URL
await page.route('https://api.shop.com/products', handler);
// Glob patterns
await page.route('**/api/products', handler); // Any domain
await page.route('**/api/products/*', handler); // With path segment
await page.route('**/api/products?**', handler); // With query string
// Regex patterns
await page.route(/\/api\/products\/\d+/, handler); // Product by ID
await page.route(/\/api\/(products|categories)/, handler); // Multiple endpoints
// Function predicate
await page.route(
(url) => url.pathname.startsWith('/api/') && url.searchParams.has('mock'),
handler
);
Three Response Patterns
1. Fulfill - Return Mock Data
await page.route('**/api/cart', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{ productId: 'SKU-001', name: 'Test Product', quantity: 2, price: 29.99 },
],
subtotal: 59.98,
tax: 5.40,
total: 65.38,
}),
});
});
2. Continue - Modify and Forward
await page.route('**/api/products/*', async (route) => {
// Get actual response from server
const response = await route.fetch();
const json = await response.json();
// Modify the response
json.price = json.price * 0.9; // 10% discount
json.onSale = true;
await route.fulfill({
response, // Use original response headers, status
body: JSON.stringify(json),
});
});
3. Abort - Simulate Failure
await page.route('**/api/recommendations', async (route) => {
// Simulate network failure
await route.abort('failed');
});
// Other abort reasons:
// 'aborted', 'accessdenied', 'addressunreachable', 'blockedbyclient',
// 'blockedbyresponse', 'connectionaborted', 'connectionclosed',
// 'connectionfailed', 'connectionrefused', 'connectionreset',
// 'internetdisconnected', 'namenotresolved', 'timedout', 'failed'
E-Commerce Mocking Scenarios
Mocking Payment Gateway
test.describe('Payment Scenarios', () => {
test('successful payment', async ({ page }) => {
await page.route('**/api/payments/process', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: true,
transactionId: 'TXN-12345',
message: 'Payment approved',
}),
});
});
await page.goto('/checkout');
// ... fill checkout form
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByText('Payment approved')).toBeVisible();
await expect(page.getByText('TXN-12345')).toBeVisible();
});
test('declined card', async ({ page }) => {
await page.route('**/api/payments/process', async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
success: false,
error: 'card_declined',
message: 'Your card was declined. Please try another payment method.',
}),
});
});
await page.goto('/checkout');
// ... fill checkout form
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByRole('alert')).toContainText('card was declined');
await expect(page.getByRole('button', { name: 'Pay Now' })).toBeEnabled();
});
test('insufficient funds', async ({ page }) => {
await page.route('**/api/payments/process', async (route) => {
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({
success: false,
error: 'insufficient_funds',
message: 'Insufficient funds. Available balance: $50.00',
}),
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByText('Insufficient funds')).toBeVisible();
});
test('payment timeout', async ({ page }) => {
await page.route('**/api/payments/process', async (route) => {
// Simulate slow response
await new Promise(resolve => setTimeout(resolve, 35000));
await route.fulfill({
status: 504,
body: 'Gateway Timeout',
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay Now' }).click();
// App should show timeout message before actual timeout
await expect(page.getByText(/taking longer than expected|try again/i)).toBeVisible({
timeout: 40000,
});
});
test('3D Secure redirect', async ({ page }) => {
await page.route('**/api/payments/process', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
success: false,
requiresAction: true,
actionType: '3ds_redirect',
redirectUrl: 'https://bank.example/3ds-verify',
}),
});
});
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay Now' }).click();
// Verify redirect handling
await expect(page.getByText('Verifying with your bank')).toBeVisible();
});
});
Mocking Inventory Status
test.describe('Inventory Scenarios', () => {
test('product in stock', async ({ page }) => {
await page.route('**/api/products/SKU-001', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'SKU-001',
name: 'Wireless Headphones',
price: 129.99,
stock: 50,
inStock: true,
}),
});
});
await page.goto('/products/SKU-001');
await expect(page.getByText('In Stock')).toBeVisible();
await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();
});
test('product out of stock', async ({ page }) => {
await page.route('**/api/products/SKU-001', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'SKU-001',
name: 'Wireless Headphones',
price: 129.99,
stock: 0,
inStock: false,
}),
});
});
await page.goto('/products/SKU-001');
await expect(page.getByText('Out of Stock')).toBeVisible();
await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeDisabled();
await expect(page.getByRole('button', { name: 'Notify Me' })).toBeVisible();
});
test('low stock warning', async ({ page }) => {
await page.route('**/api/products/SKU-001', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'SKU-001',
name: 'Wireless Headphones',
price: 129.99,
stock: 3,
inStock: true,
lowStock: true,
}),
});
});
await page.goto('/products/SKU-001');
await expect(page.getByText('Only 3 left!')).toBeVisible();
});
});
Mocking Shipping Calculations
test('shipping options based on location', async ({ page }) => {
await page.route('**/api/shipping/calculate', async (route) => {
const request = route.request();
const postData = JSON.parse(request.postData() || '{}');
// Different responses based on request
let options;
if (postData.zipCode?.startsWith('96')) {
// Hawaii - limited options
options = [
{ id: 'express', name: 'Express (5-7 days)', price: 45.99 },
];
} else {
options = [
{ id: 'standard', name: 'Standard (5-7 days)', price: 5.99 },
{ id: 'express', name: 'Express (2-3 days)', price: 15.99 },
{ id: 'overnight', name: 'Overnight', price: 29.99 },
];
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ options }),
});
});
await page.goto('/checkout');
await page.getByLabel('ZIP Code').fill('94102');
await page.getByLabel('ZIP Code').blur();
await expect(page.getByText('Standard (5-7 days)')).toBeVisible();
await expect(page.getByText('Express (2-3 days)')).toBeVisible();
});
Simulating Network Conditions
Slow Network
test('handles slow network gracefully', async ({ page }) => {
await page.route('**/api/**', async (route) => {
// Add 3 second delay to all API calls
await new Promise(resolve => setTimeout(resolve, 3000));
await route.continue();
});
await page.goto('/products');
// Loading indicator should appear
await expect(page.getByTestId('loading-spinner')).toBeVisible();
// Eventually content loads
await expect(page.getByTestId('product-card').first()).toBeVisible({
timeout: 10000,
});
});
Offline Mode
test('shows offline message when disconnected', async ({ page, context }) => {
await page.goto('/products');
await expect(page.getByTestId('product-card').first()).toBeVisible();
// Go offline
await context.setOffline(true);
// Try to add to cart
await page.getByRole('button', { name: 'Add to Cart' }).first().click();
// Should show offline message
await expect(page.getByText(/offline|no connection/i)).toBeVisible();
// Go back online
await context.setOffline(false);
// Retry should work
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
Intermittent Failures
test('retries failed requests', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/cart/add', async (route) => {
requestCount++;
if (requestCount < 3) {
// First two attempts fail
await route.fulfill({
status: 500,
body: 'Internal Server Error',
});
} else {
// Third attempt succeeds
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, cartCount: 1 }),
});
}
});
await page.goto('/products/SKU-001');
await page.getByRole('button', { name: 'Add to Cart' }).click();
// App should retry and eventually succeed
await expect(page.getByTestId('cart-count')).toHaveText('1');
expect(requestCount).toBe(3); // Verify retries happened
});
HAR File Recording and Playback
Recording Network Traffic
// Record HAR during test development
test('record checkout flow', async ({ page }) => {
// Start recording
await page.routeFromHAR('./hars/checkout.har', {
update: true, // Record mode
updateContent: 'embed', // Include response bodies
});
// Perform actions - all network traffic is recorded
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).first().click();
await page.goto('/checkout');
// ... complete checkout
});
Playback Recorded Traffic
test('checkout with recorded responses', async ({ page }) => {
// Playback mode - no real network calls
await page.routeFromHAR('./hars/checkout.har', {
update: false, // Playback mode
notFound: 'abort', // Fail if request not in HAR
});
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).first().click();
// All responses come from HAR file
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
HAR Best Practices
// playwright.config.ts
export default defineConfig({
use: {
// Record HAR in CI for debugging
har: process.env.CI ? {
path: './test-results/network.har',
mode: 'minimal', // Don't include response bodies
} : undefined,
},
});
Mocking Fixtures
Create reusable mock fixtures:
// fixtures/mocks.fixture.ts
import { test as base } from '@playwright/test';
type MockFixtures = {
mockProducts: (products: Product[]) => Promise<void>;
mockPayment: (result: 'success' | 'declined' | 'error') => Promise<void>;
mockInventory: (productId: string, stock: number) => Promise<void>;
};
export const test = base.extend<MockFixtures>({
mockProducts: async ({ page }, use) => {
const mock = async (products: Product[]) => {
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(products),
});
});
};
await use(mock);
},
mockPayment: async ({ page }, use) => {
const mock = async (result: 'success' | 'declined' | 'error') => {
await page.route('**/api/payments/process', async (route) => {
const responses = {
success: { status: 200, body: { success: true, transactionId: 'TXN-123' } },
declined: { status: 400, body: { success: false, error: 'card_declined' } },
error: { status: 500, body: { error: 'Internal error' } },
};
await route.fulfill({
status: responses[result].status,
contentType: 'application/json',
body: JSON.stringify(responses[result].body),
});
});
};
await use(mock);
},
mockInventory: async ({ page }, use) => {
const mock = async (productId: string, stock: number) => {
await page.route(`**/api/products/${productId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: productId,
stock,
inStock: stock > 0,
}),
});
});
};
await use(mock);
},
});
export { expect } from '@playwright/test';
// tests/payment.spec.ts
import { test, expect } from '../fixtures/mocks.fixture';
test('handles declined payment', async ({ page, mockPayment }) => {
await mockPayment('declined');
await page.goto('/checkout');
await page.getByRole('button', { name: 'Pay Now' }).click();
await expect(page.getByRole('alert')).toBeVisible();
});
Key Takeaways
- page.route() intercepts any network request
- Three patterns: Fulfill (mock), Continue (modify), Abort (fail)
- Test edge cases: Payment failures, inventory issues, network errors
- HAR files record and replay real network traffic
- Mock fixtures make common mocks reusable
- Simulate conditions: Slow network, offline mode, intermittent failures
Next Lesson
You can mock any network scenario. But what about visual bugs? In the next lesson, we'll learn visual testing and mobile emulation to catch UI regressions and test responsive designs.