IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Intermediate·15 / 27

Mock APIs

Three strategies: return fake JSON directly, fetch the real response and patch it, or record the whole session to a HAR file and replay. Each has its place. I use fake JSON for happy-path isolation, patching when I need 90% real data, and HAR when the interaction is too complex to hand-craft.

Return fake JSON — intercept and fulfill

route.fulfill() short-circuits the request — the server is never called and the page always gets the exact data you specify

The most common case: intercept a URL pattern and return custom JSON. No real request goes to the server. I use this when I need the test to always see exactly two orders — regardless of what's in the database, regardless of environment.

Set up page.route() BEFORE navigating to the page. The pattern is a glob — */**/api/orders matches any origin.

ts
test('orders page shows the mocked list', async ({ page }) => {
  // Перехоплюємо ДО goto — route активний до навігації
  await page.route('*/**/api/orders', async route => {
    await route.fulfill({
      json: [
        { id: 'ORD-001', status: 'pending', item: 'Laptop Stand', qty: 2 },
        { id: 'ORD-002', status: 'shipped', item: 'USB Hub',       qty: 1 },
      ],
    })
  })

  await page.goto('/orders')

  // Дані з мока — стабільні незалежно від бази
  await expect(page.getByText('Laptop Stand')).toBeVisible()
  await expect(page.getByText('USB Hub')).toBeVisible()
  await expect(page.getByRole('row')).toHaveCount(3) // header + 2 rows
})

Fetch the real response and patch it

Sometimes I need real data from the server — maybe 20 orders that are already seeded — but I want to force one of them into a specific state. Instead of fully mocking, I fetch the actual response and modify it before sending it to the page.

The key: route.fetch() makes the actual request, then you modify json and call route.fulfill({ response, json }). Passing the original response preserves headers, status, and cookies.

ts
test('overdue order shows warning badge', async ({ page }) => {
  await page.route('*/**/api/orders', async route => {
    // Виконуємо реальний запит
    const response = await route.fetch()
    const orders = await response.json()

    // Модифікуємо перше замовлення — додаємо поле
    orders[0].overdue = true
    orders[0].dueDaysAgo = 5

    // Повертаємо оригінальну відповідь + патч
    await route.fulfill({ response, json: orders })
  })

  await page.goto('/orders')

  // Badge з'являється тільки для overdue замовлень
  await expect(page.getByTestId('overdue-badge')).toBeVisible()
})

HAR files — record once, replay forever

A HAR file captures every network request the page made: URL, method, headers, request body, response status, response body. Record the whole checkout flow once against a real backend, commit the HAR file, replay it in CI without touching the server.

Step 1 — record: run with update: true. Playwright makes real requests and saves them to the HAR file. Step 2 — commit the .har file. Step 3 — replay: switch to update: false. All requests are served from the file.

HAR replay matches URL and HTTP method strictly. For POST requests it also matches the payload. If multiple recorded entries match, the one with the most matching headers wins.

ts
// Крок 1: запускаємо з update: true — записує реальний трафік
test('checkout flow (record mode)', async ({ page }) => {
  await page.routeFromHAR('./hars/checkout.har', {
    url: '*/**/api/**',
    update: true, // записує реальні відповіді у файл
  })

  await page.goto('/checkout')
  await page.getByLabel('Card number').fill('4242424242424242')
  await page.getByRole('button', { name: 'Pay' }).click()
  await page.waitForURL('/orders/*/confirmation')
})
ts
// Крок 3: update: false — відтворює з HAR без реального сервера
test('checkout flow (replay mode)', async ({ page }) => {
  await page.routeFromHAR('./hars/checkout.har', {
    url: '*/**/api/**',
    update: false, // обслуговує з файлу, не йде до сервера
  })

  await page.goto('/checkout')
  await page.getByLabel('Card number').fill('4242424242424242')
  await page.getByRole('button', { name: 'Pay' }).click()
  await page.waitForURL('/orders/*/confirmation')

  await expect(page.getByRole('heading', { name: 'Order confirmed' })).toBeVisible()
})

Mock WebSockets

The orders dashboard uses WebSocket for real-time status updates. In tests I don't want to depend on a real WebSocket server — I mock the connection and send controlled messages.

Two modes: fully mock the connection (no real server), or proxy to the real server and intercept specific messages. The proxy approach lets you test edge cases without breaking the full integration.

ts
// Повне мокування — без реального WS сервера
test('status update appears when WS message arrives', async ({ page }) => {
  await page.routeWebSocket('wss://app.example.com/ws', ws => {
    ws.onMessage(message => {
      // Можна реагувати на конкретні повідомлення
      if (message === 'subscribe:orders') {
        ws.send(JSON.stringify({
          type: 'order_update',
          orderId: 'ORD-001',
          status: 'shipped',
        }))
      }
    })
  })

  await page.goto('/dashboard')
  await expect(page.getByTestId('order-ORD-001-status')).toHaveText('Shipped')
})
ts
// Проксі до реального сервера + перехоплення конкретних повідомлень
test('error state when server sends error frame', async ({ page }) => {
  await page.routeWebSocket('wss://app.example.com/ws', ws => {
    const server = ws.connectToServer()

    ws.onMessage(message => {
      // Підміняємо конкретне повідомлення, решту — пропускаємо
      if (message === 'get:dashboard') {
        server.send(JSON.stringify({ type: 'error', code: 'RATE_LIMITED' }))
      } else {
        server.send(message)
      }
    })
  })

  await page.goto('/dashboard')
  await expect(page.getByTestId('error-banner')).toContainText('Rate limited')
})