
Playwright API Testing and Mocking: Network Control for Reliable Tests
Network requests are often the source of test flakiness. External APIs go down, rate limits kick in, and test data becomes inconsistent. Playwright provides powerful tools to take control of your application's network layer - whether you want to make direct API calls, mock responses, or intercept and modify requests in flight.
This guide covers everything from basic API testing to advanced network manipulation techniques that make your tests faster and more reliable.
Table Of Contents-
Why Control Network Requests?
Network control in tests serves several purposes:
Speed: Real API calls take time. Mocking responses eliminates network latency, making tests run faster.
Reliability: External services have outages, rate limits, and changing data. Mocked responses are consistent.
Edge Cases: Testing error states, timeouts, and unusual data is difficult with real APIs. Mocking makes it trivial.
Isolation: Tests should verify your frontend code, not external services. Mocking creates true unit tests for UI logic.
Cost: Some APIs charge per request. Testing against mocks avoids unnecessary costs.
API Testing with APIRequestContext
Playwright includes a built-in API testing capability that doesn't require a browser. Use request fixture for standalone API tests:
import { test, expect } from '@playwright/test'
test('API: get user by ID', async ({ request }) => {
const response = await request.get('https://api.example.com/users/1')
expect(response.ok()).toBeTruthy()
expect(response.status()).toBe(200)
const user = await response.json()
expect(user.id).toBe(1)
expect(user.email).toMatch(/@/)
})
test('API: create new user', async ({ request }) => {
const response = await request.post('https://api.example.com/users', {
data: {
name: 'John Doe',
email: 'john@example.com',
},
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user.name).toBe('John Doe')
})Request Options
The request methods accept various options:
const response = await request.post('https://api.example.com/login', {
// JSON body
data: { username: 'user', password: 'pass' },
// Custom headers
headers: {
Authorization: 'Bearer token123',
'X-Custom-Header': 'value',
},
// Query parameters
params: { page: '1', limit: '10' },
// Form data
form: { field1: 'value1', field2: 'value2' },
// Multipart form data (file upload)
multipart: {
file: {
name: 'test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('file content'),
},
},
// Timeout
timeout: 10000,
// Ignore HTTPS errors
ignoreHTTPSErrors: true,
})Creating a Reusable API Client
For complex API testing, create a custom request context:
import { test as base, expect, APIRequestContext } from '@playwright/test'
// Create custom fixtures
const test = base.extend<{ apiClient: APIRequestContext }>({
apiClient: async ({ playwright }, use) => {
const context = await playwright.request.newContext({
baseURL: 'https://api.example.com',
extraHTTPHeaders: {
Authorization: 'Bearer ' + process.env.API_TOKEN,
Accept: 'application/json',
},
})
await use(context)
await context.dispose()
},
})
test('list users', async ({ apiClient }) => {
const response = await apiClient.get('/users')
expect(response.ok()).toBeTruthy()
})Route Interception Basics
The page.route() method intercepts network requests matching a pattern:
test('intercept API calls', async ({ page }) => {
// Intercept all requests to /api/*
await page.route('**/api/**', (route) => {
console.log('Intercepted:', route.request().url())
route.continue() // Let the request proceed
})
await page.goto('/dashboard')
})URL Patterns
Route accepts several pattern types:
// Glob patterns
await page.route('**/api/**', handler) // Any URL containing /api/
await page.route('**/*.png', handler) // All PNG images
await page.route('**/users/*', handler) // /users/ with one segment
// String matching
await page.route('https://api.example.com/users', handler) // Exact URL
// Regular expressions
await page.route(/\/api\/v\d+\/users/, handler) // /api/v1/users, /api/v2/users, etc.
// Function for complex matching
await page.route(
(url) => url.hostname === 'api.example.com' && url.pathname.startsWith('/v2'),
handler,
)Route Handler Actions
Inside a route handler, you can:
await page.route('**/api/**', async (route) => {
// Continue to real server
await route.continue()
// Continue with modifications
await route.continue({
headers: { ...route.request().headers(), 'X-Custom': 'value' },
})
// Fulfill with mock response
await route.fulfill({
status: 200,
body: JSON.stringify({ data: 'mocked' }),
})
// Abort the request
await route.abort() // Generic abort
await route.abort('failed') // Network failure
await route.abort('timedout') // Timeout
})Mocking API Responses
Mocking replaces real API calls with predefined responses:
test('dashboard shows user data', async ({ page }) => {
// Mock the user API
await page.route('**/api/user', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 1,
name: 'Test User',
email: 'test@example.com',
role: 'admin',
}),
})
})
await page.goto('/dashboard')
// Verify UI shows mocked data
await expect(page.getByText('Test User')).toBeVisible()
await expect(page.getByText('admin')).toBeVisible()
})Mocking Multiple Endpoints
test('shopping cart functionality', async ({ page }) => {
// Mock products API
await page.route('**/api/products', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 19.99 },
]),
})
})
// Mock cart API
await page.route('**/api/cart', (route) => {
const method = route.request().method()
if (method === 'GET') {
route.fulfill({
body: JSON.stringify({ items: [], total: 0 }),
})
} else if (method === 'POST') {
route.fulfill({
status: 201,
body: JSON.stringify({ message: 'Added to cart' }),
})
}
})
await page.goto('/shop')
// Test interactions...
})Dynamic Mocks Based on Request
test('search returns matching products', async ({ page }) => {
await page.route('**/api/search**', (route) => {
const url = new URL(route.request().url())
const query = url.searchParams.get('q')?.toLowerCase() || ''
const allProducts = [
{ id: 1, name: 'Blue Widget' },
{ id: 2, name: 'Red Widget' },
{ id: 3, name: 'Blue Gadget' },
]
const results = allProducts.filter((p) =>
p.name.toLowerCase().includes(query),
)
route.fulfill({
body: JSON.stringify(results),
})
})
await page.goto('/search')
await page.getByPlaceholder('Search').fill('blue')
await page.getByRole('button', { name: 'Search' }).click()
await expect(page.locator('.result')).toHaveCount(2)
})Routes are matched in order of registration. More specific routes should be registered before generic ones to ensure correct matching.
Modifying Requests and Responses
Sometimes you want to modify real requests or responses rather than fully mock them:
Modifying Requests
test('add auth header to API calls', async ({ page }) => {
await page.route('**/api/**', (route) => {
const headers = {
...route.request().headers(),
Authorization: 'Bearer test-token-123',
}
route.continue({ headers })
})
await page.goto('/dashboard')
})Modifying Responses
test('modify response data', async ({ page }) => {
await page.route('**/api/user', async (route) => {
// Fetch the real response
const response = await route.fetch()
const json = await response.json()
// Modify the data
json.permissions = ['read', 'write', 'admin']
// Return modified response
route.fulfill({
response,
body: JSON.stringify(json),
})
})
await page.goto('/settings')
})Adding Delay to Responses
test('shows loading state', async ({ page }) => {
await page.route('**/api/data', async (route) => {
// Add artificial delay
await new Promise((resolve) => setTimeout(resolve, 2000))
route.fulfill({
body: JSON.stringify({ data: 'loaded' }),
})
})
await page.goto('/dashboard')
// Verify loading indicator appears
await expect(page.getByText('Loading...')).toBeVisible()
// Verify data appears after load
await expect(page.getByText('loaded')).toBeVisible()
})Handling Different Response Types
JSON Responses
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ key: 'value' }),
})HTML Responses
route.fulfill({
status: 200,
contentType: 'text/html',
body: '<html><body><h1>Mocked Page</h1></body></html>',
})File Downloads
import fs from 'fs'
import path from 'path'
await page.route('**/download/report.pdf', (route) => {
const filePath = path.join(__dirname, 'fixtures', 'sample.pdf')
route.fulfill({
status: 200,
contentType: 'application/pdf',
body: fs.readFileSync(filePath),
headers: {
'Content-Disposition': 'attachment; filename="report.pdf"',
},
})
})Serving Static Files
await page.route('**/images/**', (route) => {
const url = new URL(route.request().url())
const filename = path.basename(url.pathname)
const filePath = path.join(__dirname, 'fixtures', 'images', filename)
if (fs.existsSync(filePath)) {
route.fulfill({ path: filePath })
} else {
route.continue()
}
})Network Conditions and Errors
Simulating Errors
test('handles server error gracefully', async ({ page }) => {
await page.route('**/api/data', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Internal Server Error' }),
})
})
await page.goto('/dashboard')
await expect(page.getByText('Something went wrong')).toBeVisible()
})
test('handles network failure', async ({ page }) => {
await page.route('**/api/data', (route) => {
route.abort('failed')
})
await page.goto('/dashboard')
await expect(page.getByText('Network error')).toBeVisible()
})
test('handles timeout', async ({ page }) => {
await page.route('**/api/data', (route) => {
route.abort('timedout')
})
await page.goto('/dashboard')
await expect(page.getByText('Request timed out')).toBeVisible()
})Error Types for abort()
| Error Type | Description |
|---|---|
'aborted' | Request was aborted |
'accessdenied' | Access denied |
'addressunreachable' | Address unreachable |
'blockedbyclient' | Blocked by client |
'connectionclosed' | Connection closed |
'connectionfailed' | Connection failed |
'failed' | Generic failure |
'namenotresolved' | DNS lookup failed |
'timedout' | Request timed out |
Waiting for Network Events
Wait for Specific Response
test('form submission', async ({ page }) => {
await page.goto('/contact')
// Start waiting before triggering the action
const responsePromise = page.waitForResponse('**/api/contact')
await page.getByLabel('Message').fill('Hello!')
await page.getByRole('button', { name: 'Send' }).click()
const response = await responsePromise
expect(response.status()).toBe(200)
})Wait with Conditions
// Wait for response with specific status
const response = await page.waitForResponse(
(response) => response.url().includes('/api/') && response.status() === 200,
)
// Wait for response body condition
const response = await page.waitForResponse(async (response) => {
if (!response.url().includes('/api/users')) return false
const body = await response.json()
return body.users.length > 0
})Wait for All Network Activity to Stop
test('page fully loaded', async ({ page }) => {
await page.goto('/dashboard', { waitUntil: 'networkidle' })
// All network requests have completed
})⚠️
'networkidle' waits for no network connections for 500ms. This can be slow and unreliable for pages with polling or websockets. Prefer waiting for specific elements or responses instead.
HAR Recording and Playback
HAR (HTTP Archive) files capture all network traffic, allowing you to record once and replay in tests:
Recording HAR
// playwright.config.ts
export default defineConfig({
use: {
// Record HAR file
recordHar: {
path: './hars/network.har',
mode: 'full',
},
},
})Or programmatically:
test('record network traffic', async ({ browser }) => {
const context = await browser.newContext({
recordHar: { path: 'network.har' },
})
const page = await context.newPage()
await page.goto('/dashboard')
// ... interact with page
await context.close() // HAR is saved on close
})Replaying HAR
test('replay from HAR', async ({ page }) => {
// Route all requests through HAR file
await page.routeFromHAR('./hars/network.har', {
url: '**/api/**',
update: false, // Set to true to update HAR with new requests
})
await page.goto('/dashboard')
// Requests matching the HAR will be served from file
})Best Practices
Organize Mocks in Fixtures
// fixtures/mocks.ts
export const mockUser = {
id: 1,
name: 'Test User',
email: 'test@example.com',
}
export const mockProducts = [
{ id: 1, name: 'Widget', price: 9.99 },
{ id: 2, name: 'Gadget', price: 19.99 },
]
// tests/dashboard.spec.ts
import { mockUser, mockProducts } from '../fixtures/mocks'
test('dashboard', async ({ page }) => {
await page.route('**/api/user', (route) => {
route.fulfill({ body: JSON.stringify(mockUser) })
})
})Create Reusable Mock Helpers
// helpers/mock-api.ts
export async function mockApi(page: Page, mocks: Record<string, any>) {
for (const [pattern, response] of Object.entries(mocks)) {
await page.route(pattern, (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
})
})
}
}
// In tests
await mockApi(page, {
'**/api/user': { id: 1, name: 'Test' },
'**/api/settings': { theme: 'dark' },
})Mock at the Right Level
- Full mocking: For unit-like tests of UI components
- Partial mocking: For integration tests where some APIs need to be real
- No mocking: For true end-to-end tests validating full system
Verify Request Data
test('form sends correct data', async ({ page }) => {
let capturedRequest: any
await page.route('**/api/submit', (route) => {
capturedRequest = route.request().postDataJSON()
route.fulfill({ status: 200, body: '{}' })
})
await page.goto('/form')
await page.getByLabel('Name').fill('John')
await page.getByRole('button', { name: 'Submit' }).click()
// Verify the request body
expect(capturedRequest).toEqual({
name: 'John',
})
})Network control is one of Playwright's most powerful features for building reliable tests. By mocking external dependencies, you gain speed, consistency, and the ability to test edge cases that would be difficult or impossible with real services. Start with strategic mocking of flaky or slow endpoints, and expand as you identify opportunities to improve test reliability.
Quiz on Playwright API Testing
Your Score: 0/10
Question: What is the primary purpose of mocking API responses in Playwright tests?
Continue Reading
Frequently Asked Questions (FAQs) / People Also Ask (PAA)
When should I mock APIs versus using real API calls in tests?
How do I mock different responses for the same endpoint based on request data?
Can I mock WebSocket connections in Playwright?
How do I add authentication headers to all API requests automatically?
What's the difference between route.continue() and route.fulfill()?
How can I verify that my application made the expected API calls?
How do I mock API responses that return binary data like images or PDFs?
Can I record real API responses and replay them in tests?