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

Emulation

When a client shows you a bug that only happens on mobile — this is how you reproduce it without picking up a phone. Playwright can fake any device, locale, timezone, geolocation, or color scheme.

Device profiles

One device profile sets all browser behavior at once

Playwright ships with a built-in registry of ~60 device profiles — iPhone models, Pixel phones, iPad variants, desktop browsers. Each profile sets userAgent, viewport, deviceScaleFactor, hasTouch and isMobile in one spread. I use this constantly when testing responsive layouts or touch interactions.

In playwright.config.ts you add a project per device. In the test file the page is already sized and touch-enabled — no extra setup.

ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  projects: [
    {
      name: 'Desktop Chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'iPhone 13',
      use: { ...devices['iPhone 13'] },
    },
    {
      name: 'iPad Pro 11',
      use: { ...devices['iPad Pro 11'] },
    },
  ],
})
ts
// Тест запускається з тими самими параметрами що в профілі
test('mobile nav shows hamburger menu', async ({ page }) => {
  await page.goto('/dashboard')
  // При viewport iPhone 13 desktop nav прихований, hamburger видимий
  await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
  await expect(page.getByRole('navigation')).not.toBeVisible()
})

Override viewport per test

The device profile sets a default viewport, but you can override it for a specific test or describe block. Useful when you need to test a specific breakpoint without creating a whole new project.

ts
// Для всього файлу
test.use({ viewport: { width: 1440, height: 900 } })

// Або для конкретного describe
test.describe('tablet layout', () => {
  test.use({ viewport: { width: 768, height: 1024 } })

  test('sidebar collapses at tablet width', async ({ page }) => {
    await page.goto('/dashboard')
    await expect(page.getByRole('complementary')).toHaveAttribute('data-collapsed', 'true')
  })
})

// Або прямо в тесті
test('wide screen shows split view', async ({ page }) => {
  await page.setViewportSize({ width: 1920, height: 1080 })
  await page.goto('/orders')
  await expect(page.getByTestId('split-view')).toBeVisible()
})

Locale and timezone

Date formatting, number separators, currency symbols — all of these depend on locale. If your app shows orders with dates, and you're testing a German customer, the date should be 14.05.2026, not 05/14/2026. Playwright lets you emulate any locale and timezone at the context level.

Note: this affects only what the browser reports — navigator.language, Intl API, and timezone for JS date operations. It doesn't change the test runner's timezone.

ts
// playwright.config.ts — глобально для всіх тестів
export default defineConfig({
  use: {
    locale: 'de-DE',
    timezoneId: 'Europe/Berlin',
  },
})
ts
// Або для окремого тесту
test.use({
  locale: 'uk-UA',
  timezoneId: 'Europe/Kyiv',
})

test('order date shows in Ukrainian format', async ({ page }) => {
  await page.goto('/orders')
  // Дата має бути у форматі ДД.ММ.РРРР
  await expect(page.getByTestId('order-date').first()).toContainText(/d{2}.d{2}.d{4}/)
})

Browser permissions

If your app asks for notifications, camera, or geolocation access — by default the browser blocks it with a permission dialog that Playwright can't click through. You need to grant the permission programmatically before the page even asks for it.

ts
// Глобально в конфізі
export default defineConfig({
  use: {
    permissions: ['notifications', 'geolocation'],
  },
})

// Або в тесті через context
test('notification opt-in flow works', async ({ page, context }) => {
  await context.grantPermissions(['notifications'])
  await page.goto('/settings/notifications')
  await page.getByRole('button', { name: 'Enable notifications' }).click()
  // Діалогу браузера немає — дозвіл вже виданий
  await expect(page.getByText('Notifications enabled')).toBeVisible()
})

// Скинути всі дозволи
await context.clearPermissions()

Geolocation

If the app shows location-based content — store locators, delivery zones, region-specific pricing — you need to fake the user's position. Set geolocation in the config or override it mid-test.

ts
test.use({
  geolocation: { latitude: 50.4501, longitude: 30.5234 }, // Kyiv
  permissions: ['geolocation'],
})

test('shows Kyiv delivery zone', async ({ page }) => {
  await page.goto('/delivery-zones')
  await expect(page.getByText('Доставка по Києву')).toBeVisible()
})

test('location-based store finder', async ({ page, context }) => {
  await page.goto('/stores')

  // Змінити позицію прямо в тесті
  await context.setGeolocation({ latitude: 48.4647, longitude: 35.0462 }) // Dnipro
  await page.getByRole('button', { name: 'Find stores near me' }).click()
  await expect(page.getByText('Дніпро')).toBeVisible()
})

Dark mode and color scheme

If your app supports dark mode via prefers-color-scheme, tests run in light mode by default. To test dark mode components, set colorScheme: 'dark' — either globally or for a specific test.

ts
test.describe('dark mode', () => {
  test.use({ colorScheme: 'dark' })

  test('dashboard looks right in dark mode', async ({ page }) => {
    await page.goto('/dashboard')
    // Перевіряємо що dark mode клас є на body
    await expect(page.locator('body')).toHaveClass(/dark/)
  })
})

// Або переключати в тесті через emulateMedia
test('color scheme toggle works', async ({ page }) => {
  await page.goto('/dashboard')
  await page.emulateMedia({ colorScheme: 'dark' })
  await expect(page.locator('[data-theme]')).toHaveAttribute('data-theme', 'dark')
  await page.emulateMedia({ colorScheme: 'light' })
  await expect(page.locator('[data-theme]')).toHaveAttribute('data-theme', 'light')
})

Offline mode

Set offline: true to simulate a dropped connection. Useful for testing error states — what does the app show when the API is unreachable? Better to test this with emulation than to actually kill the server.

ts
test('shows error banner when offline', async ({ page, context }) => {
  await page.goto('/orders')

  // Симулюємо обрив з'єднання
  await context.setOffline(true)

  await page.getByRole('button', { name: 'Refresh' }).click()
  await expect(page.getByRole('alert')).toContainText('Немає з'єднання')

  // Відновлюємо
  await context.setOffline(false)
})