# 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](configuration.md), [core/locators.md](locators.md) ## Quick Reference ```typescript 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 ``` ```bash # 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 ```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'] }, }, ], }); ``` ```typescript // 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 ```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'] }, }, ], }); ``` ```javascript // 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 ```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'); } }); } ``` ```typescript // 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 ```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'); } }); } ``` ```javascript // 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 ```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 ```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 ```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 ```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 ```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', }, }, ], }); ``` ```typescript // 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 ```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 ```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'] }, }, ], }); ``` ```typescript // 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(); }); ``` ```bash # 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 ```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'] }, }, ], }); ``` ```javascript // 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 ```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 }; ``` ```typescript // 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 ```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 }; ``` ```javascript // 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 ```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 ```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 ```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 ```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 ```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((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((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 ```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. ```typescript // 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. ```typescript // 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. ```typescript // 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. ```typescript // 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. ```typescript // 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. ```typescript // 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 - [core/configuration.md](configuration.md) -- project setup with device profiles and multi-project config - [core/visual-regression.md](visual-regression.md) -- combine responsive testing with screenshot comparison across viewports - [core/network-mocking.md](network-mocking.md) -- mock API responses alongside mobile testing - [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- in-depth PWA testing beyond mobile context - [core/performance-testing.md](performance-testing.md) -- comprehensive performance testing including mobile metrics - [core/browser-apis.md](browser-apis.md) -- geolocation, permissions, and other browser APIs in detail - [ci/projects-and-dependencies.md](../ci/projects-and-dependencies.md) -- advanced multi-project patterns for responsive testing matrices