IKivan-kozenko -aqa
Try yourself as a QA tester
All topics·Advanced·14 / 24

Mock browser APIs

For browser APIs that Playwright doesn't have a dedicated method for — Battery, cookieEnabled, matchMedia — you inject a mock object before the page loads using page.addInitScript(). The script runs in the browser context, before any page JS, so the app never knows it's talking to a fake.

page.addInitScript — inject before the page loads

page.addInitScript() runs your callback in the browser context before any page JavaScript. The page loads, your mock is already in place — the app sees a fake navigator.getBattery and never questions it.

Our dashboard shows a battery warning banner when the device is below 20%. The Battery API isn't fully supported in all browsers and I can't drain a laptop to test it. So I mock it.

ts
test('low battery warning banner appears below 20%', async ({ page }) => {
  // Впроваджуємо до goto — скрипт виконається до JS сторінки
  await page.addInitScript(() => {
    const mockBattery = {
      level: 0.15,       // 15% — нижче порогу
      charging: false,
      chargingTime: Infinity,
      dischargingTime: 3600,
      addEventListener: () => {},
    }
    window.navigator.getBattery = async () => mockBattery
  })

  await page.goto('/dashboard')

  // Банер з'являється тільки при level < 0.2
  await expect(page.getByTestId('battery-warning')).toBeVisible()
  await expect(page.getByTestId('battery-warning')).toContainText('15%')
})

test('no battery warning when charged', async ({ page }) => {
  await page.addInitScript(() => {
    window.navigator.getBattery = async () => ({
      level: 0.90,
      charging: true,
      chargingTime: 1800,
      dischargingTime: Infinity,
      addEventListener: () => {},
    })
  })

  await page.goto('/dashboard')
  await expect(page.getByTestId('battery-warning')).not.toBeVisible()
})

Read-only navigator properties

Some browser properties are read-only — direct assignment silently does nothing. navigator.cookieEnabled = false has zero effect. For configurable properties, use Object.defineProperty to override the getter.

I use this to test the 'cookies disabled' warning page. Our app shows a special error screen when cookies are blocked — which I can't reproduce naturally in a test.

ts
// navigator.cookieEnabled = false — не працює, властивість read-only
// Правильно: Object.defineProperty
test('app shows cookies-disabled error page', async ({ page }) => {
  await page.addInitScript(() => {
    Object.defineProperty(
      Object.getPrototypeOf(navigator),
      'cookieEnabled',
      { get: () => false, configurable: true }
    )
  })

  await page.goto('/dashboard')

  // Застосунок перевіряє navigator.cookieEnabled при завантаженні
  await expect(page.getByTestId('cookies-disabled-error')).toBeVisible()
  await expect(page.getByTestId('cookies-disabled-error'))
    .toContainText('Enable cookies to continue')
})

Verify which browser APIs the page calls

Sometimes I need to check that the app called the right browser API in the right order. page.exposeFunction() bridges the browser context back to Node.js — the mock in the browser calls a function that appends to a log array in the test.

ts
test('dashboard subscribes to battery events correctly', async ({ page }) => {
  const calls: string[] = []

  // ExposeFunction — доступна в браузері як window.logCall
  await page.exposeFunction('logCall', (msg: string) => calls.push(msg))

  await page.addInitScript(() => {
    window.navigator.getBattery = async () => {
      window.logCall('getBattery')
      return {
        level: 0.75,
        charging: true,
        chargingTime: 1800,
        dischargingTime: Infinity,
        addEventListener: (name: string) => window.logCall(`addEventListener:${name}`),
      }
    }
  })

  await page.goto('/dashboard')

  // Переконуємося що застосунок викликав API в правильному порядку
  expect(calls).toEqual([
    'getBattery',
    'addEventListener:chargingchange',
    'addEventListener:levelchange',
  ])
})

Simulate runtime API changes

Static mocks only test the initial state. To test that the UI reacts to changes — like the battery level dropping mid-session — I expose the mock object on window so the test can trigger updates via page.evaluate().

ts
test('battery warning appears when level drops below 20%', async ({ page }) => {
  await page.addInitScript(() => {
    class BatteryMock {
      level = 0.80
      charging = false
      chargingTime = Infinity
      dischargingTime = 7200
      _listeners: Record<string, (() => void)[]> = {}

      addEventListener(event: string, cb: () => void) {
        this._listeners[event] = this._listeners[event] || []
        this._listeners[event].push(cb)
      }

      _setLevel(value: number) {
        this.level = value
        this._listeners['levelchange']?.forEach(cb => cb())
      }
    }

    const mock = new BatteryMock()
    window.navigator.getBattery = async () => mock
    // @ts-ignore — зберігаємо для page.evaluate()
    window.__batteryMock = mock
  })

  await page.goto('/dashboard')
  await expect(page.getByTestId('battery-warning')).not.toBeVisible()

  // Симулюємо падіння заряду
  await page.evaluate(() => (window as any).__batteryMock._setLevel(0.15))

  await expect(page.getByTestId('battery-warning')).toBeVisible()
  await expect(page.getByTestId('battery-warning')).toContainText('15%')
})