UI Automation
Playwright
API Testing & Mocking

Playwright API Testing and Mocking: Network Control for Reliable Tests

Parul Dhingra - Senior Quality Analyst
Parul Dhingra13+ Years ExperienceHire Me

Senior Quality Analyst

Updated: 1/23/2026

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.

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 TypeDescription
'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?