.agents/skills/playwright-best-practices/advanced/mobile-testing.md
import {test, devices} from '@playwright/test'
// Configure in playwright.config.ts
export default defineConfig({
projects: [
{name: 'Desktop Chrome', use: {...devices['Desktop Chrome']}},
{name: 'Mobile Safari', use: {...devices['iPhone 14']}},
{name: 'Mobile Chrome', use: {...devices['Pixel 7']}},
{name: 'Tablet', use: {...devices['iPad Pro 11']}},
],
})
test.use({
viewport: {width: 390, height: 844},
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15',
})
test('custom mobile device', async ({page}) => {
await page.goto('/')
// Test runs with custom device settings
})
const mobileDevices = ['iPhone 14', 'Pixel 7', 'Galaxy S21']
for (const deviceName of mobileDevices) {
test(`checkout on ${deviceName}`, async ({browser}) => {
const device = devices[deviceName]
const context = await browser.newContext({...device})
const page = await context.newPage()
await page.goto('/checkout')
await expect(page.getByRole('button', {name: 'Pay'})).toBeVisible()
await context.close()
})
}
test.use({hasTouch: true})
test('tap to interact', async ({page}) => {
await page.goto('/gallery')
// Tap is like click but for touch devices
await page.getByRole('img', {name: 'Photo 1'}).tap()
await expect(page.getByRole('dialog')).toBeVisible()
})
test('swipe carousel', async ({page}) => {
await page.goto('/carousel')
const carousel = page.getByTestId('carousel')
const box = await carousel.boundingBox()
if (box) {
// Swipe left
await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2)
await page.mouse.move(box.x + 50, box.y + box.height / 2)
// Or use drag
await carousel.dragTo(carousel, {
sourcePosition: {x: box.width - 50, y: box.height / 2},
targetPosition: {x: 50, y: box.height / 2},
})
}
await expect(page.getByText('Slide 2')).toBeVisible()
})
// fixtures/touch.fixture.ts
import {test as base, Page} from '@playwright/test'
type TouchFixtures = {
swipe: (element: Locator, direction: 'left' | 'right' | 'up' | 'down') => Promise<void>
}
export const test = base.extend<TouchFixtures>({
swipe: async ({page}, use) => {
await use(async (element, direction) => {
const box = await element.boundingBox()
if (!box) throw new Error('Element not visible')
const centerX = box.x + box.width / 2
const centerY = box.y + box.height / 2
const distance = 100
const moves = {
left: {
startX: centerX + distance,
endX: centerX - distance,
y: centerY,
},
right: {
startX: centerX - distance,
endX: centerX + distance,
y: centerY,
},
up: {
startX: centerX,
endX: centerX,
startY: centerY + distance,
endY: centerY - distance,
},
down: {
startX: centerX,
endX: centerX,
startY: centerY - distance,
endY: centerY + distance,
},
}
const move = moves[direction]
await page.touchscreen.tap(move.startX, move.startY ?? move.y)
await page.mouse.move(move.endX, move.endY ?? move.y, {steps: 10})
await page.mouse.up()
})
},
})
// Usage
test('swipe to delete', async ({page, swipe}) => {
await page.goto('/inbox')
const message = page.getByTestId('message-1')
await swipe(message, 'left')
await expect(page.getByRole('button', {name: 'Delete'})).toBeVisible()
})
test('long press for context menu', async ({page}) => {
await page.goto('/files')
const file = page.getByText('document.pdf')
const box = await file.boundingBox()
if (box) {
// Touch down
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2)
// Hold for 500ms
await page.waitForTimeout(500)
// Context menu should appear
await expect(page.getByRole('menu')).toBeVisible()
}
})
test('pinch to zoom image', async ({page}) => {
await page.goto('/map')
// Pinch zoom requires two touch points
// Playwright doesn't have native pinch support, so we simulate via evaluate
await page.evaluate(() => {
const element = document.querySelector('#map')
if (element) {
// Simulate wheel event as fallback for zoom
element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -100, // Negative = zoom in
ctrlKey: true, // Ctrl+wheel = pinch on many apps
}),
)
}
})
// Or trigger the app's zoom function directly
await page.evaluate(() => {
;(window as any).mapInstance?.setZoom(15)
})
})
const viewports = [
{name: 'mobile', width: 375, height: 667},
{name: 'tablet', width: 768, height: 1024},
{name: 'desktop', width: 1920, height: 1080},
]
for (const {name, width, height} of viewports) {
test(`navigation on ${name}`, async ({page}) => {
await page.setViewportSize({width, height})
await page.goto('/')
if (width < 768) {
// Mobile: should have hamburger menu
await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()
} else {
// Desktop: should have visible nav links
await expect(page.getByRole('link', {name: 'Products'})).toBeVisible()
}
})
}
test('responsive layout change', async ({page}) => {
await page.setViewportSize({width: 1200, height: 800})
await page.goto('/dashboard')
// Desktop: sidebar visible
await expect(page.getByRole('complementary')).toBeVisible()
// Resize to mobile
await page.setViewportSize({width: 375, height: 667})
// Mobile: sidebar hidden, hamburger visible
await expect(page.getByRole('complementary')).toBeHidden()
await expect(page.getByRole('button', {name: 'Menu'})).toBeVisible()
})
test('mobile navigation', async ({page}) => {
await page.setViewportSize({width: 375, height: 667})
await page.goto('/')
// Open hamburger menu
await page.getByRole('button', {name: 'Menu'}).click()
// Navigation drawer should appear
const nav = page.getByRole('navigation')
await expect(nav).toBeVisible()
// Navigate via mobile menu
await nav.getByRole('link', {name: 'Products'}).click()
await expect(page).toHaveURL('/products')
// Menu should close after navigation
await expect(nav).toBeHidden()
})
test('bottom sheet interaction', async ({page}) => {
await page.setViewportSize({width: 375, height: 667})
await page.goto('/product/123')
await page.getByRole('button', {name: 'Add to Cart'}).click()
// Bottom sheet appears
const sheet = page.getByRole('dialog')
await expect(sheet).toBeVisible()
// Select options
await sheet.getByRole('combobox', {name: 'Size'}).selectOption('Large')
await sheet.getByRole('button', {name: 'Confirm'}).click()
await expect(page.getByText('Added to cart')).toBeVisible()
})
test('pull to refresh', async ({page}) => {
await page.goto('/feed')
const feed = page.getByTestId('feed')
const initialFirstItem = await feed.locator('> *').first().textContent()
// Simulate pull down
const box = await feed.boundingBox()
if (box) {
await page.touchscreen.tap(box.x + box.width / 2, box.y + 50)
await page.mouse.move(box.x + box.width / 2, box.y + 200, {steps: 20})
await page.mouse.up()
}
// Wait for refresh
await expect(page.getByTestId('loading')).toBeVisible()
await expect(page.getByTestId('loading')).toBeHidden()
// Content should be updated (in a real app)
})
const breakpoints = {
'xs': 320,
'sm': 640,
'md': 768,
'lg': 1024,
'xl': 1280,
'2xl': 1536,
}
test.describe('responsive header', () => {
for (const [name, width] of Object.entries(breakpoints)) {
test(`header at ${name} (${width}px)`, async ({page}) => {
await page.setViewportSize({width, height: 800})
await page.goto('/')
if (width < 768) {
await expect(page.getByTestId('mobile-menu-button')).toBeVisible()
await expect(page.getByTestId('desktop-nav')).toBeHidden()
} else {
await expect(page.getByTestId('mobile-menu-button')).toBeHidden()
await expect(page.getByTestId('desktop-nav')).toBeVisible()
}
})
}
})
test.describe('visual regression', () => {
const sizes = [
{width: 375, height: 667, name: 'mobile'},
{width: 768, height: 1024, name: 'tablet'},
{width: 1440, height: 900, name: 'desktop'},
]
for (const {width, height, name} of sizes) {
test(`homepage at ${name}`, async ({page}) => {
await page.setViewportSize({width, height})
await page.goto('/')
await expect(page).toHaveScreenshot(`homepage-${name}.png`)
})
}
})
| Anti-Pattern | Problem | Solution |
|---|---|---|
| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |
| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |
| Hardcoded viewport in tests | Can't test multiple sizes | Use page.setViewportSize() |
| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |