Back to Sanity

Mobile & Responsive Testing

.agents/skills/playwright-best-practices/advanced/mobile-testing.md

5.20.010.5 KB
Original Source

Mobile & Responsive Testing

Table of Contents

  1. Device Emulation
  2. Touch Gestures
  3. Viewport Testing
  4. Mobile-Specific UI
  5. Responsive Breakpoints

Device Emulation

Use Built-in Devices

typescript
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']}},
  ],
})

Custom Device Configuration

typescript
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
})

Test Across Multiple Devices

typescript
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()
  })
}

Touch Gestures

Tap

typescript
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()
})

Swipe

typescript
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()
})

Swipe Fixture

typescript
// 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()
})

Long Press

typescript
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()
  }
})

Pinch Zoom

typescript
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)
  })
})

Viewport Testing

Test Different Sizes

typescript
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()
    }
  })
}

Dynamic Viewport Changes

typescript
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()
})

Mobile-Specific UI

Hamburger Menu

typescript
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()
})

Bottom Sheet

typescript
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()
})

Pull to Refresh

typescript
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)
})

Responsive Breakpoints

Test All Breakpoints

typescript
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()
      }
    })
  }
})

Visual Regression at Breakpoints

typescript
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-Patterns to Avoid

Anti-PatternProblemSolution
Only testing one viewportMisses responsive bugsTest multiple breakpoints
Ignoring touch eventsFeatures broken on mobileTest tap, swipe, long press
Hardcoded viewport in testsCan't test multiple sizesUse page.setViewportSize()
Not testing orientationLandscape bugs missedTest both portrait and landscape