Skip to content

Latest commit

 

History

History
1669 lines (1302 loc) · 56.4 KB

File metadata and controls

1669 lines (1302 loc) · 56.4 KB

Mobile and Responsive Testing

When to use: Testing how your application behaves on phones, tablets, and across viewport sizes. Covers device emulation, touch interactions, geolocation, orientation changes, and mobile-specific UI patterns. Prerequisites: core/configuration.md, core/locators.md

Quick Reference

import { devices } from '@playwright/test';

// Predefined device profiles (viewport, userAgent, touch, deviceScaleFactor)
devices['iPhone 14']           // 390x844, touch, Safari mobile UA
devices['iPhone 14 Pro Max']   // 430x932, touch, Safari mobile UA
devices['Pixel 7']             // 412x915, touch, Chrome mobile UA
devices['iPad Pro 11']         // 834x1194, touch, Safari tablet UA
devices['Galaxy S9+']          // 320x658, touch, Chrome mobile UA
devices['Desktop Chrome']      // 1280x720, no touch, Chrome desktop UA
devices['Desktop Safari']      // 1280x720, no touch, Safari desktop UA

// Landscape variants
devices['iPhone 14 landscape'] // 844x390, touch, Safari mobile UA
devices['iPad Pro 11 landscape'] // 1194x834, touch, Safari tablet UA
# Run mobile project only
npx playwright test --project=mobile-chrome
npx playwright test --project=mobile-safari

# Run all projects (desktop + mobile in parallel)
npx playwright test

# List available device names
npx playwright test --list-devices 2>/dev/null || node -e "const {devices}=require('@playwright/test');console.log(Object.keys(devices).join('\n'))"

Patterns

1. Device Emulation

Use when: Testing your app as it appears on a specific real-world device -- iPhone, Pixel, iPad. Applies the correct viewport, user agent, device scale factor, and touch support in one shot. Avoid when: You only need to test a specific viewport width (use custom viewports instead). Device emulation is not a substitute for real device testing when pixel-perfect rendering matters.

TypeScript

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

export default defineConfig({
  testDir: './tests',
  projects: [
    {
      name: 'Desktop Chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 14'] },
    },
    {
      name: 'Tablet',
      use: { ...devices['iPad Pro 11'] },
    },
  ],
});
// tests/mobile-navigation.spec.ts
import { test, expect } from '@playwright/test';

test('mobile user can navigate via hamburger menu', async ({ page, isMobile }) => {
  await page.goto('/');

  if (isMobile) {
    // Mobile: hamburger menu is visible, desktop nav is hidden
    await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
    await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden();

    // Open hamburger menu
    await page.getByRole('button', { name: 'Menu' }).click();
    await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('**/products');
  } else {
    // Desktop: nav links are directly visible
    await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('**/products');
  }

  await expect(page.getByRole('heading', { name: 'Products', level: 1 })).toBeVisible();
});

JavaScript

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  projects: [
    {
      name: 'Desktop Chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 14'] },
    },
    {
      name: 'Tablet',
      use: { ...devices['iPad Pro 11'] },
    },
  ],
});
// tests/mobile-navigation.spec.js
const { test, expect } = require('@playwright/test');

test('mobile user can navigate via hamburger menu', async ({ page, isMobile }) => {
  await page.goto('/');

  if (isMobile) {
    await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
    await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden();

    await page.getByRole('button', { name: 'Menu' }).click();
    await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('**/products');
  } else {
    await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
    await page.getByRole('link', { name: 'Products' }).click();
    await page.waitForURL('**/products');
  }

  await expect(page.getByRole('heading', { name: 'Products', level: 1 })).toBeVisible();
});

2. Custom Viewports

Use when: Testing responsive layouts at specific breakpoints without full device emulation. Ideal for verifying CSS media queries fire at the right widths. Avoid when: You need realistic mobile behavior (touch events, mobile user agent, device scale factor). Use device emulation instead.

TypeScript

// tests/responsive-layout.spec.ts
import { test, expect } from '@playwright/test';

const breakpoints = [
  { name: 'mobile-small', width: 320, height: 568 },
  { name: 'mobile', width: 375, height: 667 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1024, height: 768 },
  { name: 'desktop-large', width: 1440, height: 900 },
];

for (const bp of breakpoints) {
  test(`layout adapts correctly at ${bp.name} (${bp.width}px)`, async ({ page }) => {
    await page.setViewportSize({ width: bp.width, height: bp.height });
    await page.goto('/');

    if (bp.width < 768) {
      // Mobile layout: stacked, hamburger menu visible
      await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
      await expect(page.getByTestId('sidebar')).toBeHidden();
    } else if (bp.width < 1024) {
      // Tablet layout: collapsible sidebar
      await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
      await expect(page.getByTestId('sidebar')).toBeVisible();
    } else {
      // Desktop layout: full sidebar, no hamburger
      await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
      await expect(page.getByTestId('sidebar')).toBeVisible();
      await expect(page.getByTestId('sidebar')).toHaveCSS('width', '280px');
    }
  });
}
// Per-file viewport override (applies to all tests in this file)
import { test, expect } from '@playwright/test';

test.use({ viewport: { width: 375, height: 667 } });

test('mobile checkout flow fits on small screen', async ({ page }) => {
  await page.goto('/checkout');

  // Verify no horizontal scrollbar
  const hasHorizontalScroll = await page.evaluate(
    () => document.documentElement.scrollWidth > document.documentElement.clientWidth
  );
  expect(hasHorizontalScroll).toBe(false);

  // Verify all form fields are visible without horizontal scroll
  await expect(page.getByLabel('Card number')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Pay now' })).toBeVisible();
});

JavaScript

// tests/responsive-layout.spec.js
const { test, expect } = require('@playwright/test');

const breakpoints = [
  { name: 'mobile-small', width: 320, height: 568 },
  { name: 'mobile', width: 375, height: 667 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1024, height: 768 },
  { name: 'desktop-large', width: 1440, height: 900 },
];

for (const bp of breakpoints) {
  test(`layout adapts correctly at ${bp.name} (${bp.width}px)`, async ({ page }) => {
    await page.setViewportSize({ width: bp.width, height: bp.height });
    await page.goto('/');

    if (bp.width < 768) {
      await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
      await expect(page.getByTestId('sidebar')).toBeHidden();
    } else if (bp.width < 1024) {
      await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
      await expect(page.getByTestId('sidebar')).toBeVisible();
    } else {
      await expect(page.getByRole('button', { name: 'Menu' })).toBeHidden();
      await expect(page.getByTestId('sidebar')).toBeVisible();
      await expect(page.getByTestId('sidebar')).toHaveCSS('width', '280px');
    }
  });
}
// Per-file viewport override
const { test, expect } = require('@playwright/test');

test.use({ viewport: { width: 375, height: 667 } });

test('mobile checkout flow fits on small screen', async ({ page }) => {
  await page.goto('/checkout');

  const hasHorizontalScroll = await page.evaluate(
    () => document.documentElement.scrollWidth > document.documentElement.clientWidth
  );
  expect(hasHorizontalScroll).toBe(false);

  await expect(page.getByLabel('Card number')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Pay now' })).toBeVisible();
});

3. Touch Events

Use when: Testing touch-specific interactions -- tap, swipe, pinch. Required for mobile-only gestures that have no mouse equivalent. Avoid when: The feature works identically with mouse clicks. Playwright's click() dispatches touch events automatically on touch-enabled device profiles.

TypeScript

// tests/touch-interactions.spec.ts
import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['iPhone 14'] });

test('tap to select an item', async ({ page }) => {
  await page.goto('/gallery');

  // tap() dispatches touchstart → touchend → click
  // Only available when hasTouch is true (device profiles set this)
  await page.getByRole('img', { name: 'Sunset photo' }).tap();
  await expect(page.getByText('Selected: Sunset photo')).toBeVisible();
});

test('swipe to dismiss a card', async ({ page }) => {
  await page.goto('/notifications');

  const card = page.getByTestId('notification-card').first();
  const box = await card.boundingBox();

  if (box) {
    // Simulate a left swipe: touchstart on right side, touchmove to left, touchend
    await page.touchscreen.tap(box.x + box.width - 20, box.y + box.height / 2);

    // Swipe gesture using mouse (touch events are synthesized from mouse on emulated devices)
    await card.hover({ position: { x: box.width - 20, y: box.height / 2 } });
    await page.mouse.down();
    await page.mouse.move(box.x - 100, box.y + box.height / 2, { steps: 10 });
    await page.mouse.up();

    await expect(card).toBeHidden();
  }
});

test('long press to open context menu', async ({ page }) => {
  await page.goto('/files');

  const file = page.getByRole('listitem').filter({ hasText: 'Report.pdf' });

  // Long press: dispatch pointerdown, wait, then pointerup
  await file.click({ delay: 800 }); // delay in ms simulates long press
  await expect(page.getByRole('menu')).toBeVisible();
  await page.getByRole('menuitem', { name: 'Delete' }).click();
});

test('pinch to zoom on a map', async ({ page }) => {
  await page.goto('/map');

  // Pinch-to-zoom requires dispatching multi-touch events via JavaScript
  const mapElement = page.getByTestId('map-container');

  await mapElement.evaluate((el) => {
    // Simulate pinch-out (zoom in) with two touch points moving apart
    const center = { x: el.clientWidth / 2, y: el.clientHeight / 2 };

    const touch1 = new Touch({
      identifier: 0,
      target: el,
      clientX: center.x - 50,
      clientY: center.y,
    });
    const touch2 = new Touch({
      identifier: 1,
      target: el,
      clientX: center.x + 50,
      clientY: center.y,
    });

    el.dispatchEvent(new TouchEvent('touchstart', {
      touches: [touch1, touch2],
      changedTouches: [touch1, touch2],
      bubbles: true,
    }));

    const touch1Moved = new Touch({
      identifier: 0,
      target: el,
      clientX: center.x - 120,
      clientY: center.y,
    });
    const touch2Moved = new Touch({
      identifier: 1,
      target: el,
      clientX: center.x + 120,
      clientY: center.y,
    });

    el.dispatchEvent(new TouchEvent('touchmove', {
      touches: [touch1Moved, touch2Moved],
      changedTouches: [touch1Moved, touch2Moved],
      bubbles: true,
    }));

    el.dispatchEvent(new TouchEvent('touchend', {
      touches: [],
      changedTouches: [touch1Moved, touch2Moved],
      bubbles: true,
    }));
  });

  // Verify zoom level changed
  await expect(page.getByTestId('zoom-level')).not.toHaveText('1x');
});

JavaScript

// tests/touch-interactions.spec.js
const { test, expect, devices } = require('@playwright/test');

test.use({ ...devices['iPhone 14'] });

test('tap to select an item', async ({ page }) => {
  await page.goto('/gallery');

  await page.getByRole('img', { name: 'Sunset photo' }).tap();
  await expect(page.getByText('Selected: Sunset photo')).toBeVisible();
});

test('swipe to dismiss a card', async ({ page }) => {
  await page.goto('/notifications');

  const card = page.getByTestId('notification-card').first();
  const box = await card.boundingBox();

  if (box) {
    await card.hover({ position: { x: box.width - 20, y: box.height / 2 } });
    await page.mouse.down();
    await page.mouse.move(box.x - 100, box.y + box.height / 2, { steps: 10 });
    await page.mouse.up();

    await expect(card).toBeHidden();
  }
});

test('long press to open context menu', async ({ page }) => {
  await page.goto('/files');

  const file = page.getByRole('listitem').filter({ hasText: 'Report.pdf' });
  await file.click({ delay: 800 });
  await expect(page.getByRole('menu')).toBeVisible();
  await page.getByRole('menuitem', { name: 'Delete' }).click();
});

4. Mobile-Specific UI

Use when: Testing UI components that only appear on mobile -- hamburger menus, bottom sheets, pull-to-refresh, sticky mobile headers, floating action buttons. Avoid when: The component renders identically on desktop and mobile.

TypeScript

// tests/mobile-ui.spec.ts
import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['iPhone 14'] });

test('hamburger menu opens and closes', async ({ page }) => {
  await page.goto('/');

  const menuButton = page.getByRole('button', { name: 'Menu' });
  const nav = page.getByRole('navigation', { name: 'Main' });

  // Menu starts closed
  await expect(nav).toBeHidden();

  // Open menu
  await menuButton.click();
  await expect(nav).toBeVisible();

  // Verify all nav items are visible
  await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible();
  await expect(nav.getByRole('link', { name: 'Products' })).toBeVisible();
  await expect(nav.getByRole('link', { name: 'Account' })).toBeVisible();

  // Close menu by tapping outside (overlay)
  await page.getByTestId('menu-overlay').click();
  await expect(nav).toBeHidden();
});

test('bottom sheet slides up on mobile', async ({ page }) => {
  await page.goto('/products/1');

  await page.getByRole('button', { name: 'Add to cart' }).click();

  // Bottom sheet appears with cart summary
  const bottomSheet = page.getByTestId('bottom-sheet');
  await expect(bottomSheet).toBeVisible();
  await expect(bottomSheet).toContainText('Added to cart');

  // Dismiss by swiping down
  const box = await bottomSheet.boundingBox();
  if (box) {
    const startX = box.x + box.width / 2;
    const startY = box.y + 20;
    await page.mouse.move(startX, startY);
    await page.mouse.down();
    await page.mouse.move(startX, startY + 300, { steps: 10 });
    await page.mouse.up();
  }

  await expect(bottomSheet).toBeHidden();
});

test('pull to refresh reloads content', async ({ page }) => {
  await page.goto('/feed');

  // Store initial first item text
  const firstItem = page.getByRole('listitem').first();
  const initialText = await firstItem.textContent();

  // Pull-to-refresh: swipe down from top of scrollable area
  const feed = page.getByTestId('feed-container');
  const box = await feed.boundingBox();

  if (box) {
    await page.mouse.move(box.x + box.width / 2, box.y + 10);
    await page.mouse.down();
    await page.mouse.move(box.x + box.width / 2, box.y + 250, { steps: 15 });
    await page.mouse.up();
  }

  // Wait for refresh indicator and then content reload
  await expect(page.getByTestId('refresh-spinner')).toBeVisible();
  await expect(page.getByTestId('refresh-spinner')).toBeHidden();
});

test('sticky mobile header remains visible on scroll', async ({ page }) => {
  await page.goto('/products');

  const header = page.getByRole('banner');

  // Scroll down significantly
  await page.evaluate(() => window.scrollTo(0, 2000));

  // Header should remain visible (sticky positioning)
  await expect(header).toBeVisible();
  await expect(header).toBeInViewport();
});

JavaScript

// tests/mobile-ui.spec.js
const { test, expect, devices } = require('@playwright/test');

test.use({ ...devices['iPhone 14'] });

test('hamburger menu opens and closes', async ({ page }) => {
  await page.goto('/');

  const menuButton = page.getByRole('button', { name: 'Menu' });
  const nav = page.getByRole('navigation', { name: 'Main' });

  await expect(nav).toBeHidden();
  await menuButton.click();
  await expect(nav).toBeVisible();

  await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible();
  await expect(nav.getByRole('link', { name: 'Products' })).toBeVisible();
  await expect(nav.getByRole('link', { name: 'Account' })).toBeVisible();

  await page.getByTestId('menu-overlay').click();
  await expect(nav).toBeHidden();
});

test('bottom sheet slides up on mobile', async ({ page }) => {
  await page.goto('/products/1');

  await page.getByRole('button', { name: 'Add to cart' }).click();

  const bottomSheet = page.getByTestId('bottom-sheet');
  await expect(bottomSheet).toBeVisible();
  await expect(bottomSheet).toContainText('Added to cart');

  const box = await bottomSheet.boundingBox();
  if (box) {
    const startX = box.x + box.width / 2;
    const startY = box.y + 20;
    await page.mouse.move(startX, startY);
    await page.mouse.down();
    await page.mouse.move(startX, startY + 300, { steps: 10 });
    await page.mouse.up();
  }

  await expect(bottomSheet).toBeHidden();
});

test('sticky mobile header remains visible on scroll', async ({ page }) => {
  await page.goto('/products');

  const header = page.getByRole('banner');
  await page.evaluate(() => window.scrollTo(0, 2000));
  await expect(header).toBeVisible();
  await expect(header).toBeInViewport();
});

5. Geolocation

Use when: Testing location-dependent features -- store finders, delivery zones, weather widgets, location-based pricing. Avoid when: The feature does not use the Geolocation API. If it uses IP-based location, mock the API response instead.

TypeScript

// playwright.config.ts -- set geolocation per project
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  projects: [
    {
      name: 'mobile-nyc',
      use: {
        ...devices['iPhone 14'],
        geolocation: { latitude: 40.7128, longitude: -74.0060 },
        permissions: ['geolocation'],
      },
    },
    {
      name: 'mobile-london',
      use: {
        ...devices['iPhone 14'],
        geolocation: { latitude: 51.5074, longitude: -0.1278 },
        permissions: ['geolocation'],
        locale: 'en-GB',
        timezoneId: 'Europe/London',
      },
    },
  ],
});
// tests/store-locator.spec.ts
import { test, expect } from '@playwright/test';

test('shows nearby stores based on geolocation', async ({ page, context }) => {
  // Geolocation is already set via project config above.
  // To override in a specific test:
  await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 }); // Los Angeles

  await page.goto('/store-locator');
  await page.getByRole('button', { name: 'Use my location' }).click();

  // Verify the app uses the mocked location
  await expect(page.getByText('Stores near Los Angeles')).toBeVisible();
  await expect(page.getByRole('listitem')).toHaveCount(5); // nearest 5 stores
});

test('geolocation updates in real time', async ({ page, context }) => {
  await context.setGeolocation({ latitude: 40.7128, longitude: -74.0060 });
  await page.goto('/delivery-tracker');

  await expect(page.getByText('New York')).toBeVisible();

  // Simulate user moving to a new location
  await context.setGeolocation({ latitude: 40.7580, longitude: -73.9855 }); // Times Square

  // Trigger a location refresh (app-specific)
  await page.getByRole('button', { name: 'Refresh location' }).click();
  await expect(page.getByText('Times Square')).toBeVisible();
});

test('handles geolocation permission denied', async ({ page, context }) => {
  // Clear geolocation permissions to simulate denial
  await context.clearPermissions();
  await page.goto('/store-locator');

  await page.getByRole('button', { name: 'Use my location' }).click();

  // App should show fallback UI
  await expect(page.getByText('Location access denied')).toBeVisible();
  await expect(page.getByLabel('Enter your zip code')).toBeVisible();
});

JavaScript

// tests/store-locator.spec.js
const { test, expect } = require('@playwright/test');

test('shows nearby stores based on geolocation', async ({ page, context }) => {
  await context.setGeolocation({ latitude: 34.0522, longitude: -118.2437 });

  await page.goto('/store-locator');
  await page.getByRole('button', { name: 'Use my location' }).click();

  await expect(page.getByText('Stores near Los Angeles')).toBeVisible();
  await expect(page.getByRole('listitem')).toHaveCount(5);
});

test('geolocation updates in real time', async ({ page, context }) => {
  await context.setGeolocation({ latitude: 40.7128, longitude: -74.0060 });
  await page.goto('/delivery-tracker');

  await expect(page.getByText('New York')).toBeVisible();

  await context.setGeolocation({ latitude: 40.7580, longitude: -73.9855 });
  await page.getByRole('button', { name: 'Refresh location' }).click();
  await expect(page.getByText('Times Square')).toBeVisible();
});

test('handles geolocation permission denied', async ({ page, context }) => {
  await context.clearPermissions();
  await page.goto('/store-locator');

  await page.getByRole('button', { name: 'Use my location' }).click();

  await expect(page.getByText('Location access denied')).toBeVisible();
  await expect(page.getByLabel('Enter your zip code')).toBeVisible();
});

6. Multi-Project Responsive Testing

Use when: Running the same tests across desktop and mobile browsers in parallel. The standard approach for responsive apps. Avoid when: Your app is desktop-only or mobile-only.

TypeScript

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

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,

  projects: [
    // ── Desktop ────────────────────────────────────────────
    {
      name: 'desktop-chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'desktop-firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'desktop-safari',
      use: { ...devices['Desktop Safari'] },
    },

    // ── Mobile ─────────────────────────────────────────────
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 14'] },
    },

    // ── Tablet ─────────────────────────────────────────────
    {
      name: 'tablet',
      use: { ...devices['iPad Pro 11'] },
    },
  ],
});
// tests/responsive-checkout.spec.ts
import { test, expect } from '@playwright/test';

test('checkout works across all viewports', async ({ page, isMobile }) => {
  await page.goto('/products');

  // Add item -- same action on all viewports
  await page.getByRole('button', { name: 'Add to cart' }).first().click();

  // Navigate to cart
  if (isMobile) {
    // Mobile: cart link may be in hamburger or bottom nav
    await page.getByRole('link', { name: 'Cart' }).click();
  } else {
    // Desktop: cart icon in header
    await page.getByRole('link', { name: /Cart \(\d+\)/ }).click();
  }

  await page.waitForURL('**/cart');
  await expect(page.getByRole('heading', { name: 'Your cart' })).toBeVisible();

  // Proceed to checkout
  await page.getByRole('link', { name: 'Checkout' }).click();
  await page.waitForURL('**/checkout');

  // Fill form -- same fields on all viewports
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Pay now' }).click();

  await expect(page.getByText('Order confirmed')).toBeVisible();
});
# Run desktop projects only
npx playwright test --project=desktop-chrome --project=desktop-firefox --project=desktop-safari

# Run mobile projects only
npx playwright test --project=mobile-chrome --project=mobile-safari

# Run specific test file on all projects
npx playwright test tests/responsive-checkout.spec.ts

# CI optimization: mobile + desktop-chrome on PRs, all browsers on main
# In CI config:
# PR:   npx playwright test --project=desktop-chrome --project=mobile-chrome --project=mobile-safari
# Main: npx playwright test

JavaScript

// playwright.config.js
const { defineConfig, devices } = require('@playwright/test');

module.exports = defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,

  projects: [
    {
      name: 'desktop-chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'desktop-firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'desktop-safari',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 7'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 14'] },
    },
    {
      name: 'tablet',
      use: { ...devices['iPad Pro 11'] },
    },
  ],
});
// tests/responsive-checkout.spec.js
const { test, expect } = require('@playwright/test');

test('checkout works across all viewports', async ({ page, isMobile }) => {
  await page.goto('/products');

  await page.getByRole('button', { name: 'Add to cart' }).first().click();

  if (isMobile) {
    await page.getByRole('link', { name: 'Cart' }).click();
  } else {
    await page.getByRole('link', { name: /Cart \(\d+\)/ }).click();
  }

  await page.waitForURL('**/cart');
  await expect(page.getByRole('heading', { name: 'Your cart' })).toBeVisible();

  await page.getByRole('link', { name: 'Checkout' }).click();
  await page.waitForURL('**/checkout');

  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Card number').fill('4242424242424242');
  await page.getByRole('button', { name: 'Pay now' }).click();

  await expect(page.getByText('Order confirmed')).toBeVisible();
});

7. Responsive Breakpoint Testing

Use when: Systematically verifying layout behavior at every major CSS breakpoint. Best used alongside visual regression testing. Avoid when: You only need one or two viewport sizes -- just use test.use() overrides instead.

TypeScript

// tests/fixtures/responsive.fixture.ts
import { test as base, expect } from '@playwright/test';

type Breakpoint = {
  name: string;
  width: number;
  height: number;
  isMobileExpected: boolean;
};

const BREAKPOINTS: Breakpoint[] = [
  { name: 'xs',  width: 320,  height: 568, isMobileExpected: true },
  { name: 'sm',  width: 640,  height: 800, isMobileExpected: true },
  { name: 'md',  width: 768,  height: 1024, isMobileExpected: false },
  { name: 'lg',  width: 1024, height: 768, isMobileExpected: false },
  { name: 'xl',  width: 1280, height: 800, isMobileExpected: false },
  { name: '2xl', width: 1440, height: 900, isMobileExpected: false },
];

// Custom fixture that runs a test at every breakpoint
export const test = base.extend<{ forEachBreakpoint: void }>({
  forEachBreakpoint: [async ({ page }, use, testInfo) => {
    // This fixture is used as a tag -- actual breakpoint iteration is done via test.describe
    await use();
  }, { auto: false }],
});

// Helper: create a describe block that tests every breakpoint
export function describeBreakpoints(
  title: string,
  fn: (bp: Breakpoint) => void
) {
  for (const bp of BREAKPOINTS) {
    base.describe(`${title} @ ${bp.name} (${bp.width}px)`, () => {
      base.use({ viewport: { width: bp.width, height: bp.height } });
      fn(bp);
    });
  }
}

export { expect, BREAKPOINTS };
// tests/responsive-grid.spec.ts
import { test, expect, describeBreakpoints } from './fixtures/responsive.fixture';

describeBreakpoints('product grid', (bp) => {
  test('shows correct number of columns', async ({ page }) => {
    await page.goto('/products');

    const grid = page.getByTestId('product-grid');
    const columns = await grid.evaluate((el) => {
      const style = window.getComputedStyle(el);
      return style.gridTemplateColumns.split(' ').length;
    });

    if (bp.width < 640) {
      expect(columns).toBe(1);     // xs: single column
    } else if (bp.width < 1024) {
      expect(columns).toBe(2);     // sm-md: two columns
    } else {
      expect(columns).toBe(4);     // lg+: four columns
    }
  });

  test('no content overflow', async ({ page }) => {
    await page.goto('/products');

    const hasOverflow = await page.evaluate(
      () => document.documentElement.scrollWidth > document.documentElement.clientWidth
    );
    expect(hasOverflow).toBe(false);
  });
});

JavaScript

// tests/fixtures/responsive.fixture.js
const { test: base, expect } = require('@playwright/test');

const BREAKPOINTS = [
  { name: 'xs',  width: 320,  height: 568, isMobileExpected: true },
  { name: 'sm',  width: 640,  height: 800, isMobileExpected: true },
  { name: 'md',  width: 768,  height: 1024, isMobileExpected: false },
  { name: 'lg',  width: 1024, height: 768, isMobileExpected: false },
  { name: 'xl',  width: 1280, height: 800, isMobileExpected: false },
  { name: '2xl', width: 1440, height: 900, isMobileExpected: false },
];

function describeBreakpoints(title, fn) {
  for (const bp of BREAKPOINTS) {
    base.describe(`${title} @ ${bp.name} (${bp.width}px)`, () => {
      base.use({ viewport: { width: bp.width, height: bp.height } });
      fn(bp);
    });
  }
}

module.exports = { test: base, expect, BREAKPOINTS, describeBreakpoints };
// tests/responsive-grid.spec.js
const { test, expect, describeBreakpoints } = require('./fixtures/responsive.fixture');

describeBreakpoints('product grid', (bp) => {
  test('shows correct number of columns', async ({ page }) => {
    await page.goto('/products');

    const grid = page.getByTestId('product-grid');
    const columns = await grid.evaluate((el) => {
      const style = window.getComputedStyle(el);
      return style.gridTemplateColumns.split(' ').length;
    });

    if (bp.width < 640) {
      expect(columns).toBe(1);
    } else if (bp.width < 1024) {
      expect(columns).toBe(2);
    } else {
      expect(columns).toBe(4);
    }
  });

  test('no content overflow', async ({ page }) => {
    await page.goto('/products');

    const hasOverflow = await page.evaluate(
      () => document.documentElement.scrollWidth > document.documentElement.clientWidth
    );
    expect(hasOverflow).toBe(false);
  });
});

8. Orientation Testing

Use when: Testing portrait vs landscape layouts. Critical for tablet apps, media players, dashboards, and any app that adapts to orientation. Avoid when: Your app does not change layout based on orientation (purely responsive to width only -- test with custom viewports instead).

TypeScript

// tests/orientation.spec.ts
import { test, expect, devices } from '@playwright/test';

test.describe('iPad orientation changes', () => {
  test.use({ ...devices['iPad Pro 11'] });

  test('dashboard adjusts layout in landscape', async ({ page }) => {
    // Start in portrait (834x1194 -- default for iPad Pro 11)
    await page.goto('/dashboard');

    // Portrait: sidebar collapses, chart stacks vertically
    await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'fixed');

    // Switch to landscape by changing viewport
    await page.setViewportSize({ width: 1194, height: 834 });

    // Landscape: sidebar visible inline, charts side-by-side
    await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'relative');
  });

  test('video player switches to fullscreen in landscape', async ({ page }) => {
    await page.goto('/videos/1');

    // Portrait: video in inline player
    const player = page.getByTestId('video-player');
    const portraitBox = await player.boundingBox();

    // Switch to landscape
    await page.setViewportSize({ width: 1194, height: 834 });

    // Video should expand to fill width
    const landscapeBox = await player.boundingBox();
    expect(landscapeBox!.width).toBeGreaterThan(portraitBox!.width);
  });
});

test.describe('iPhone orientation changes', () => {
  test('portrait mode', () => {
    test.use({ ...devices['iPhone 14'] }); // 390x844
  });

  test('landscape mode', () => {
    test.use({ ...devices['iPhone 14 landscape'] }); // 844x390
  });
});

// Test both orientations with a loop
const orientations = [
  { name: 'portrait', device: devices['iPad Pro 11'] },
  { name: 'landscape', device: devices['iPad Pro 11 landscape'] },
];

for (const { name, device } of orientations) {
  test.describe(`form usability in ${name}`, () => {
    test.use({ ...device });

    test('all form fields are visible without scrolling', async ({ page }) => {
      await page.goto('/contact');

      const form = page.getByRole('form');
      await expect(form).toBeVisible();

      // Check form fits within viewport
      const formBox = await form.boundingBox();
      const viewport = page.viewportSize()!;
      expect(formBox!.width).toBeLessThanOrEqual(viewport.width);
    });
  });
}

JavaScript

// tests/orientation.spec.js
const { test, expect, devices } = require('@playwright/test');

test.describe('iPad orientation changes', () => {
  test.use({ ...devices['iPad Pro 11'] });

  test('dashboard adjusts layout in landscape', async ({ page }) => {
    await page.goto('/dashboard');

    await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'fixed');

    await page.setViewportSize({ width: 1194, height: 834 });

    await expect(page.getByTestId('sidebar')).toHaveCSS('position', 'relative');
  });

  test('video player switches to fullscreen in landscape', async ({ page }) => {
    await page.goto('/videos/1');

    const player = page.getByTestId('video-player');
    const portraitBox = await player.boundingBox();

    await page.setViewportSize({ width: 1194, height: 834 });

    const landscapeBox = await player.boundingBox();
    expect(landscapeBox.width).toBeGreaterThan(portraitBox.width);
  });
});

const orientations = [
  { name: 'portrait', device: devices['iPad Pro 11'] },
  { name: 'landscape', device: devices['iPad Pro 11 landscape'] },
];

for (const { name, device } of orientations) {
  test.describe(`form usability in ${name}`, () => {
    test.use({ ...device });

    test('all form fields are visible without scrolling', async ({ page }) => {
      await page.goto('/contact');

      const form = page.getByRole('form');
      await expect(form).toBeVisible();

      const formBox = await form.boundingBox();
      const viewport = page.viewportSize();
      expect(formBox.width).toBeLessThanOrEqual(viewport.width);
    });
  });
}

9. Mobile Performance

Use when: Simulating real-world mobile conditions -- slow 3G networks, underpowered CPUs. Critical for testing loading states, skeleton screens, and timeout handling. Avoid when: Testing functional correctness only. Performance throttling slows down your test suite significantly.

TypeScript

// tests/mobile-performance.spec.ts
import { test, expect, devices } from '@playwright/test';

test.describe('mobile on slow network', () => {
  test.use({ ...devices['Pixel 7'] });

  test('shows skeleton loader on slow 3G', async ({ page }) => {
    // Get the CDP session for network throttling (Chromium only)
    const cdpSession = await page.context().newCDPSession(page);

    // Simulate slow 3G: 500kbps download, 500kbps upload, 400ms RTT
    await cdpSession.send('Network.emulateNetworkConditions', {
      offline: false,
      downloadThroughput: (500 * 1024) / 8,  // bytes per second
      uploadThroughput: (500 * 1024) / 8,
      latency: 400,                            // ms
    });

    await page.goto('/products');

    // Skeleton screen should appear while content loads
    await expect(page.getByTestId('product-skeleton')).toBeVisible();

    // Eventually, real content replaces skeleton
    await expect(page.getByRole('listitem')).toHaveCount(12, { timeout: 30_000 });
    await expect(page.getByTestId('product-skeleton')).toBeHidden();
  });

  test('shows offline message when network drops', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

    // Drop network entirely
    await page.context().setOffline(true);

    // Try to navigate -- should show offline message
    await page.getByRole('link', { name: 'Settings' }).click();
    await expect(page.getByText(/you.*offline|no.*connection/i)).toBeVisible();

    // Restore network
    await page.context().setOffline(false);
    await page.reload();
    await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
  });

  test('images lazy-load on slow connections', async ({ page }) => {
    const cdpSession = await page.context().newCDPSession(page);
    await cdpSession.send('Network.emulateNetworkConditions', {
      offline: false,
      downloadThroughput: (1000 * 1024) / 8,
      uploadThroughput: (500 * 1024) / 8,
      latency: 200,
    });

    await page.goto('/gallery');

    // Images below the fold should have loading="lazy" and not load immediately
    const belowFoldImages = page.locator('img[loading="lazy"]');
    const count = await belowFoldImages.count();
    expect(count).toBeGreaterThan(0);

    // Scroll to trigger lazy loading
    await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
    await expect(belowFoldImages.first()).toHaveJSProperty('complete', true);
  });

  test('CPU throttling shows performance impact', async ({ page }) => {
    const cdpSession = await page.context().newCDPSession(page);

    // Simulate 4x CPU slowdown (typical mid-range mobile device)
    await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 });

    await page.goto('/');

    // Measure time to interactive
    const tti = await page.evaluate(() => {
      const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
      return entries[0]?.domInteractive ?? 0;
    });

    // Assert TTI is within acceptable range for throttled mobile
    expect(tti).toBeLessThan(5000);

    // Reset throttling
    await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 1 });
  });
});

JavaScript

// tests/mobile-performance.spec.js
const { test, expect, devices } = require('@playwright/test');

test.describe('mobile on slow network', () => {
  test.use({ ...devices['Pixel 7'] });

  test('shows skeleton loader on slow 3G', async ({ page }) => {
    const cdpSession = await page.context().newCDPSession(page);

    await cdpSession.send('Network.emulateNetworkConditions', {
      offline: false,
      downloadThroughput: (500 * 1024) / 8,
      uploadThroughput: (500 * 1024) / 8,
      latency: 400,
    });

    await page.goto('/products');

    await expect(page.getByTestId('product-skeleton')).toBeVisible();
    await expect(page.getByRole('listitem')).toHaveCount(12, { timeout: 30_000 });
    await expect(page.getByTestId('product-skeleton')).toBeHidden();
  });

  test('shows offline message when network drops', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

    await page.context().setOffline(true);

    await page.getByRole('link', { name: 'Settings' }).click();
    await expect(page.getByText(/you.*offline|no.*connection/i)).toBeVisible();

    await page.context().setOffline(false);
    await page.reload();
    await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
  });

  test('CPU throttling shows performance impact', async ({ page }) => {
    const cdpSession = await page.context().newCDPSession(page);

    await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 });

    await page.goto('/');

    const tti = await page.evaluate(() => {
      const entries = performance.getEntriesByType('navigation');
      return entries[0]?.domInteractive ?? 0;
    });

    expect(tti).toBeLessThan(5000);
    await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 1 });
  });
});

10. PWA Mobile Testing

Use when: Testing Progressive Web App features -- service workers, install prompts, offline mode, push notifications on mobile. Avoid when: Your app is not a PWA. For general offline testing, see the network offline pattern in Pattern 9.

TypeScript

// tests/pwa-mobile.spec.ts
import { test, expect, devices } from '@playwright/test';

test.use({ ...devices['Pixel 7'] });

test('service worker registers and caches resources', async ({ page }) => {
  await page.goto('/');

  // Wait for service worker to register
  const swRegistered = await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    return registration.active?.state === 'activated';
  });
  expect(swRegistered).toBe(true);

  // Verify critical resources are cached
  const cacheContents = await page.evaluate(async () => {
    const cache = await caches.open('app-shell-v1');
    const keys = await cache.keys();
    return keys.map((req) => new URL(req.url).pathname);
  });

  expect(cacheContents).toContain('/');
  expect(cacheContents).toContain('/offline.html');
});

test('app works offline after initial load', async ({ page }) => {
  // Load the app online first -- service worker caches resources
  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();

  // Wait for service worker to finish caching
  await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    // Wait for the service worker to activate
    if (registration.active?.state !== 'activated') {
      await new Promise<void>((resolve) => {
        registration.active?.addEventListener('statechange', () => {
          if (registration.active?.state === 'activated') resolve();
        });
      });
    }
  });

  // Go offline
  await page.context().setOffline(true);

  // Navigate to a cached page
  await page.goto('/about');

  // Cached page should render
  await expect(page.getByRole('heading', { name: 'About' })).toBeVisible();
});

test('offline fallback page shows when uncached route is accessed', async ({ page }) => {
  await page.goto('/');

  // Wait for service worker
  await page.evaluate(() => navigator.serviceWorker.ready);

  // Go offline and navigate to an uncached route
  await page.context().setOffline(true);
  await page.goto('/uncached-page');

  // Should show the offline fallback page
  await expect(page.getByText(/offline|no.*connection/i)).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('app prompts to install on mobile', async ({ page, context }) => {
  // Listen for the beforeinstallprompt event
  await page.goto('/');

  const installPromptFired = await page.evaluate(() => {
    return new Promise<boolean>((resolve) => {
      // Check if the event has already fired
      window.addEventListener('beforeinstallprompt', (e) => {
        e.preventDefault(); // Prevent auto-prompt
        resolve(true);
      });

      // If PWA criteria are met, the event fires automatically
      // Timeout after 5s if it doesn't fire
      setTimeout(() => resolve(false), 5000);
    });
  });

  // Note: beforeinstallprompt only fires in Chromium and when PWA criteria are met
  // This test validates the event handler, not the browser chrome UI
  if (installPromptFired) {
    // App should show a custom install banner
    await expect(page.getByTestId('install-banner')).toBeVisible();
    await page.getByRole('button', { name: 'Install app' }).click();
  }
});

test('push notification permission request on mobile', async ({ page, context }) => {
  // Grant notification permission
  await context.grantPermissions(['notifications']);

  await page.goto('/settings/notifications');

  await page.getByRole('button', { name: 'Enable notifications' }).click();

  // Verify the app registered for push
  const pushSubscription = await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.getSubscription();
    return subscription !== null;
  });

  expect(pushSubscription).toBe(true);
  await expect(page.getByText('Notifications enabled')).toBeVisible();
});

JavaScript

// tests/pwa-mobile.spec.js
const { test, expect, devices } = require('@playwright/test');

test.use({ ...devices['Pixel 7'] });

test('service worker registers and caches resources', async ({ page }) => {
  await page.goto('/');

  const swRegistered = await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    return registration.active?.state === 'activated';
  });
  expect(swRegistered).toBe(true);

  const cacheContents = await page.evaluate(async () => {
    const cache = await caches.open('app-shell-v1');
    const keys = await cache.keys();
    return keys.map((req) => new URL(req.url).pathname);
  });

  expect(cacheContents).toContain('/');
  expect(cacheContents).toContain('/offline.html');
});

test('app works offline after initial load', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Home' })).toBeVisible();

  await page.evaluate(async () => {
    await navigator.serviceWorker.ready;
  });

  await page.context().setOffline(true);
  await page.goto('/about');

  await expect(page.getByRole('heading', { name: 'About' })).toBeVisible();
});

test('offline fallback page shows when uncached route is accessed', async ({ page }) => {
  await page.goto('/');
  await page.evaluate(() => navigator.serviceWorker.ready);

  await page.context().setOffline(true);
  await page.goto('/uncached-page');

  await expect(page.getByText(/offline|no.*connection/i)).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('push notification permission request on mobile', async ({ page, context }) => {
  await context.grantPermissions(['notifications']);

  await page.goto('/settings/notifications');

  await page.getByRole('button', { name: 'Enable notifications' }).click();

  const pushSubscription = await page.evaluate(async () => {
    const registration = await navigator.serviceWorker.ready;
    const subscription = await registration.pushManager.getSubscription();
    return subscription !== null;
  });

  expect(pushSubscription).toBe(true);
  await expect(page.getByText('Notifications enabled')).toBeVisible();
});

Decision Guide

Question Answer Approach
Need to test how app looks on iPhone 14? Yes Use devices['iPhone 14'] -- gets viewport, UA, touch, scale factor in one shot
Need to test a CSS breakpoint at 768px? Yes Use test.use({ viewport: { width: 768, height: 1024 } }) -- simpler, no UA change
Need to test both portrait and landscape? Yes Use named device + landscape variant: devices['iPad Pro 11'] and devices['iPad Pro 11 landscape']
Need realistic mobile performance? Yes Use CDP Network.emulateNetworkConditions + Emulation.setCPUThrottlingRate (Chromium only)
Need to test touch gestures? Yes Use device profile with hasTouch: true, then use tap(), mouse gestures for swipe
Need to test geolocation? Yes Set geolocation and permissions: ['geolocation'] in context options
Need pixel-perfect mobile testing? No -- use real devices Playwright emulation approximates; font rendering and native UI differ from real hardware
Which devices to test by default? Start with 3 Desktop Chrome + Pixel 7 (Android) + iPhone 14 (iOS). Add tablet if your app has a tablet layout.
When to add more device projects? When bugs escape If users report device-specific bugs, add that device profile permanently
Run all devices on every PR? No Run desktop + one mobile on PRs. Run all devices on main branch merges.
Device emulation vs custom viewport? Depends on goal Emulation: testing real-world device behavior. Custom viewport: testing CSS breakpoints.
Should I test every screen size? No Test your actual CSS breakpoints, plus smallest (320px) and largest (1440px+) supported sizes

Anti-Patterns

Don't Do This Problem Do This Instead
Only testing at 1280x720 desktop Misses all mobile and tablet layout bugs; most web traffic is mobile Add at least Pixel 7 and iPhone 14 projects
Using device emulation for pixel-perfect testing Emulation approximates real devices -- font rendering, sub-pixel antialiasing, and native browser chrome differ Use emulation for layout and interaction testing; use real device labs (BrowserStack, Sauce Labs) for pixel-perfect validation
Ignoring touch interactions Mobile users tap, swipe, and long-press; click() alone may not trigger touch-specific event handlers Use tap() on touch device profiles; test swipe gestures with mouse move sequences
Not testing orientation changes Users rotate tablets and phones; layouts may break in landscape Test both portrait and landscape with device variants or page.setViewportSize()
page.setViewportSize() without hasTouch: true Viewport is small but browser reports no touch support -- @media (hover: hover) still matches desktop Use device profiles or explicitly set hasTouch: true in test.use()
Hardcoding isMobile checks in every test Duplicates logic, hard to maintain; tests become brittle Use page objects that abstract mobile vs desktop behavior behind methods
Testing mobile layout with visibility: hidden checks only Element may still take up space; CSS may use display: none or transform: translateX(-100%) Use toBeHidden() (checks not visible) or toHaveCSS('display', 'none') for specific CSS behavior
Running 10+ device projects on every CI run Massive CI time and cost with diminishing returns beyond 3-4 devices Pick representative devices: one Android phone, one iPhone, one tablet, and desktop browsers
Network throttling in every test Slows entire suite dramatically for minimal extra coverage Create a separate mobile-perf project or tag performance tests with @slow and run them separately
await page.waitForTimeout(2000) after orientation change Arbitrary delay; layout reflow may be faster or slower await expect(locator).toHaveCSS('property', 'value') -- assertion auto-retries until layout settles
Testing geolocation without setting permissions Browser blocks geolocation silently; test sees no location data and passes incorrectly Always set permissions: ['geolocation'] alongside geolocation coordinates
CDP throttling on Firefox or WebKit CDP sessions are Chromium-only; test will throw on other browsers Guard CDP calls with a browser check or use Chromium-only projects for performance tests

Troubleshooting

tap() throws "Page.tap: Not supported" error

Cause: The browser context was created without hasTouch: true. Device profiles set this automatically, but custom viewport configurations do not.

// Wrong -- custom viewport without touch support
test.use({ viewport: { width: 375, height: 667 } });
test('tap fails', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button').tap(); // Error: Not supported
});

// Fix -- enable touch explicitly
test.use({
  viewport: { width: 375, height: 667 },
  hasTouch: true,
});
test('tap works', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button').tap(); // Works
});

// Or use a device profile that includes hasTouch
test.use({ ...devices['iPhone 14'] });

isMobile is always false

Cause: isMobile is set by the device profile's isMobile property, not by viewport size. Custom viewports do not set it.

// isMobile is false here -- no device profile used
test.use({ viewport: { width: 375, height: 667 } });

test('isMobile is false', async ({ page, isMobile }) => {
  console.log(isMobile); // false
});

// Fix: use a device profile, or set isMobile explicitly
test.use({
  viewport: { width: 375, height: 667 },
  isMobile: true,
  hasTouch: true,
});

test('isMobile is true', async ({ page, isMobile }) => {
  console.log(isMobile); // true
});

Geolocation not working -- location remains default

Cause: Missing permissions: ['geolocation'] in context options. The browser silently denies the Geolocation API without this permission.

// Wrong -- geolocation set but no permission granted
test.use({
  geolocation: { latitude: 40.7128, longitude: -74.0060 },
  // Missing: permissions: ['geolocation']
});

// Fix
test.use({
  geolocation: { latitude: 40.7128, longitude: -74.0060 },
  permissions: ['geolocation'],
});

CDP session throws on Firefox/WebKit

Cause: page.context().newCDPSession(page) is Chromium-only. Firefox and WebKit do not support CDP.

// Wrong -- crashes on Firefox and WebKit
test('throttle network', async ({ page }) => {
  const cdp = await page.context().newCDPSession(page); // Throws on non-Chromium
});

// Fix -- guard with browser name check
test('throttle network', async ({ page, browserName }) => {
  test.skip(browserName !== 'chromium', 'CDP throttling is Chromium-only');

  const cdp = await page.context().newCDPSession(page);
  await cdp.send('Network.emulateNetworkConditions', {
    offline: false,
    downloadThroughput: (500 * 1024) / 8,
    uploadThroughput: (500 * 1024) / 8,
    latency: 400,
  });
});

// Alternative -- use context.setOffline() which works on all browsers
test('offline mode', async ({ page }) => {
  await page.context().setOffline(true); // Works everywhere
});

Viewport change does not trigger CSS media queries

Cause: page.setViewportSize() changes the viewport but does not trigger resize or orientationchange events in some frameworks that rely on JavaScript-based responsive logic rather than CSS media queries.

// If media queries don't fire after setViewportSize, dispatch manually
await page.setViewportSize({ width: 375, height: 667 });
await page.evaluate(() => window.dispatchEvent(new Event('resize')));

// Better: use the viewport in test.use() so it's set before page load
test.use({ viewport: { width: 375, height: 667 } });

Service worker not registering in tests

Cause: Service workers require HTTPS or localhost. Playwright's default baseURL of http://localhost:3000 works, but other HTTP origins do not.

// Ensure your baseURL is localhost or HTTPS
// playwright.config.ts
export default defineConfig({
  use: {
    baseURL: 'http://localhost:3000',          // Works for service workers
    // baseURL: 'http://192.168.1.100:3000',   // Does NOT work -- not localhost
  },
});

// If testing against a non-localhost server, use HTTPS
// or use serviceWorkers: 'allow' (default) in context options

Related