Series Navigation
→ Part 7: Authentication — Sessions, Storage State and Multi-User Testing
Why Mock the Network?
Real API calls in tests create problems:
- Slow tests — real network calls take hundreds of milliseconds each
- Flaky tests — external services can be down or slow
- Unpredictable data — you can't control what the API returns
- Test isolation — one test's data mutations can break another's
Network mocking solves all of these. You decide exactly what the API returns.
page.route() — The Core API
// Intercept all requests matching a URL pattern
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Srikanth', email: 'srikanth@example.com' },
{ id: 2, name: 'Priya', email: 'priya@example.com' }
])
});
});
await page.goto('/users');
// Page now shows our mocked users, no real API call made
URL patterns support glob syntax:
'**/api/users' // matches any URL ending in /api/users
'**/api/**' // matches any URL with /api/ in it
'https://api.example.com/v2/users' // exact URL
/\/api\/users\/\d+/ // regex pattern
Mocking GET Responses
test('displays user list from API', async ({ page }) => {
// Set up the mock before navigation
await page.route('**/api/users', route => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Alice', role: 'admin' },
{ id: 2, name: 'Bob', role: 'user' },
])
});
});
await page.goto('/admin/users');
// Assert the UI renders the mocked data
await expect(page.getByRole('row', { name: /Alice/ })).toBeVisible();
await expect(page.getByRole('row', { name: /Bob/ })).toBeVisible();
await expect(page.getByRole('row')).toHaveCount(3); // 2 + header row
});
Simulating Error States
Test how your UI handles API failures:
test('shows error message when API fails', async ({ page }) => {
await page.route('**/api/products', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' })
});
});
await page.goto('/products');
await expect(page.getByRole('alert')).toContainText('Failed to load products');
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});
test('shows network error message when offline', async ({ page }) => {
await page.route('**/api/**', route => route.abort('failed'));
await page.goto('/dashboard');
await expect(page.getByText('No internet connection')).toBeVisible();
});
Simulating Loading States
Test skeleton screens and loading spinners:
test('shows loading state while fetching', async ({ page }) => {
let resolveRequest: () => void;
// Delay the response indefinitely until we choose to resolve it
await page.route('**/api/products', route => {
return new Promise<void>(resolve => {
resolveRequest = () => {
route.fulfill({
status: 200,
body: JSON.stringify([{ id: 1, name: 'Widget' }])
});
resolve();
};
});
});
await page.goto('/products');
// Assert loading state is shown
await expect(page.getByTestId('skeleton-loader')).toBeVisible();
await expect(page.getByRole('table')).toBeHidden();
// Now resolve the request
resolveRequest!();
// Assert data loaded
await expect(page.getByRole('table')).toBeVisible();
await expect(page.getByTestId('skeleton-loader')).toBeHidden();
});
Modifying Real Requests
Instead of fully replacing a response, you can fetch the real response and modify it:
await page.route('**/api/user/profile', async route => {
// Make the real API call
const response = await route.fetch();
const body = await response.json();
// Modify specific fields
body.subscription = 'pro';
body.trialDaysRemaining = 0;
// Return the modified response
await route.fulfill({
response,
body: JSON.stringify(body)
});
});
Useful for testing edge cases that are hard to reproduce in a real environment.
Intercepting POST/PUT/DELETE Requests
test('form submission sends correct data', async ({ page }) => {
let capturedRequest: any;
await page.route('**/api/orders', async route => {
// Capture what was sent
capturedRequest = JSON.parse(route.request().postData() || '{}');
// Respond with success
await route.fulfill({
status: 201,
body: JSON.stringify({ id: 999, status: 'created' })
});
});
await page.goto('/checkout');
await page.getByLabel('Name').fill('Srikanth');
await page.getByRole('button', { name: 'Place Order' }).click();
// Assert the UI responded to the mock response
await expect(page.getByText('Order confirmed')).toBeVisible();
// Assert the correct data was sent
expect(capturedRequest.customerName).toBe('Srikanth');
expect(capturedRequest.items).toHaveLength(1);
});
Mocking Third-Party Services
Stub external APIs your frontend calls:
test('payment form works with mocked Stripe', async ({ page }) => {
// Mock the Stripe payment intent creation
await page.route('https://api.stripe.com/**', route => {
route.fulfill({
status: 200,
body: JSON.stringify({
id: 'pi_test_mock',
client_secret: 'pi_test_mock_secret',
status: 'requires_payment_method'
})
});
});
// Mock your own payment API
await page.route('**/api/create-payment-intent', route => {
route.fulfill({
status: 200,
body: JSON.stringify({ clientSecret: 'pi_test_mock_secret' })
});
});
await page.goto('/checkout');
// Test the checkout UI without a real Stripe account
});
Route Handlers in Page Objects
Keep mocking logic organised with your page objects:
// tests/pages/ProductsPage.ts
export class ProductsPage extends BasePage {
async mockProductsSuccess(products: Product[]) {
await this.page.route('**/api/products', route =>
route.fulfill({ status: 200, body: JSON.stringify(products) })
);
}
async mockProductsError() {
await this.page.route('**/api/products', route =>
route.fulfill({ status: 500, body: '{"error":"Server error"}' })
);
}
async goto() {
await this.page.goto('/products');
}
}
// In a test
test('shows products', async ({ page }) => {
const productsPage = new ProductsPage(page);
await productsPage.mockProductsSuccess([
{ id: 1, name: 'Widget A', price: 9.99 }
]);
await productsPage.goto();
await expect(page.getByText('Widget A')).toBeVisible();
});
request Context — Testing APIs Directly
Playwright can make raw HTTP requests without a browser page:
import { test, expect } from '@playwright/test';
test('API returns correct user structure', async ({ request }) => {
const response = await request.get('https://jsonplaceholder.typicode.com/users/1');
expect(response.status()).toBe(200);
const body = await response.json();
expect(body).toMatchObject({
id: 1,
name: expect.any(String),
email: expect.stringContaining('@')
});
});
test('can create a resource via API', async ({ request }) => {
const response = await request.post('https://jsonplaceholder.typicode.com/posts', {
data: { title: 'Test Post', body: 'Content', userId: 1 }
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.title).toBe('Test Post');
});
This is perfect for API smoke tests or setting up test data before UI tests run.
What's Next
In Part 7 we tackle authentication — the most important performance optimisation for test suites. Instead of logging in through the UI for every test, we capture the session state once and reuse it across hundreds of tests.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.