Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
advanced/mobile-testing.md
1# Mobile & Responsive Testing23## Table of Contents451. [Device Emulation](#device-emulation)62. [Touch Gestures](#touch-gestures)73. [Viewport Testing](#viewport-testing)84. [Mobile-Specific UI](#mobile-specific-ui)95. [Responsive Breakpoints](#responsive-breakpoints)1011## Device Emulation1213### Use Built-in Devices1415```typescript16import { test, devices } from "@playwright/test";1718// Configure in playwright.config.ts19export default defineConfig({20projects: [21{ name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },22{ name: "Mobile Safari", use: { ...devices["iPhone 14"] } },23{ name: "Mobile Chrome", use: { ...devices["Pixel 7"] } },24{ name: "Tablet", use: { ...devices["iPad Pro 11"] } },25],26});27```2829### Custom Device Configuration3031```typescript32test.use({33viewport: { width: 390, height: 844 },34deviceScaleFactor: 3,35isMobile: true,36hasTouch: true,37userAgent:38"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15",39});4041test("custom mobile device", async ({ page }) => {42await page.goto("/");43// Test runs with custom device settings44});45```4647### Test Across Multiple Devices4849```typescript50const mobileDevices = ["iPhone 14", "Pixel 7", "Galaxy S21"];5152for (const deviceName of mobileDevices) {53test(`checkout on ${deviceName}`, async ({ browser }) => {54const device = devices[deviceName];55const context = await browser.newContext({ ...device });56const page = await context.newPage();5758await page.goto("/checkout");59await expect(page.getByRole("button", { name: "Pay" })).toBeVisible();6061await context.close();62});63}64```6566## Touch Gestures6768### Tap6970```typescript71test.use({ hasTouch: true });7273test("tap to interact", async ({ page }) => {74await page.goto("/gallery");7576// Tap is like click but for touch devices77await page.getByRole("img", { name: "Photo 1" }).tap();7879await expect(page.getByRole("dialog")).toBeVisible();80});81```8283### Swipe8485```typescript86test("swipe carousel", async ({ page }) => {87await page.goto("/carousel");8889const carousel = page.getByTestId("carousel");90const box = await carousel.boundingBox();9192if (box) {93// Swipe left94await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);95await page.mouse.move(box.x + 50, box.y + box.height / 2);9697// Or use drag98await carousel.dragTo(carousel, {99sourcePosition: { x: box.width - 50, y: box.height / 2 },100targetPosition: { x: 50, y: box.height / 2 },101});102}103104await expect(page.getByText("Slide 2")).toBeVisible();105});106```107108### Swipe Fixture109110```typescript111// fixtures/touch.fixture.ts112import { test as base, Page } from "@playwright/test";113114type TouchFixtures = {115swipe: (116element: Locator,117direction: "left" | "right" | "up" | "down",118) => Promise<void>;119};120121export const test = base.extend<TouchFixtures>({122swipe: async ({ page }, use) => {123await use(async (element, direction) => {124const box = await element.boundingBox();125if (!box) throw new Error("Element not visible");126127const centerX = box.x + box.width / 2;128const centerY = box.y + box.height / 2;129const distance = 100;130131const moves = {132left: {133startX: centerX + distance,134endX: centerX - distance,135y: centerY,136},137right: {138startX: centerX - distance,139endX: centerX + distance,140y: centerY,141},142up: {143startX: centerX,144endX: centerX,145startY: centerY + distance,146endY: centerY - distance,147},148down: {149startX: centerX,150endX: centerX,151startY: centerY - distance,152endY: centerY + distance,153},154};155156const move = moves[direction];157await page.touchscreen.tap(move.startX, move.startY ?? move.y);158await page.mouse.move(move.endX, move.endY ?? move.y, { steps: 10 });159await page.mouse.up();160});161},162});163164// Usage165test("swipe to delete", async ({ page, swipe }) => {166await page.goto("/inbox");167168const message = page.getByTestId("message-1");169await swipe(message, "left");170171await expect(page.getByRole("button", { name: "Delete" })).toBeVisible();172});173```174175### Long Press176177```typescript178test("long press for context menu", async ({ page }) => {179await page.goto("/files");180181const file = page.getByText("document.pdf");182const box = await file.boundingBox();183184if (box) {185// Touch down186await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);187188// Hold for 500ms189await page.waitForTimeout(500);190191// Context menu should appear192await expect(page.getByRole("menu")).toBeVisible();193}194});195```196197### Pinch Zoom198199```typescript200test("pinch to zoom image", async ({ page }) => {201await page.goto("/map");202203// Pinch zoom requires two touch points204// Playwright doesn't have native pinch support, so we simulate via evaluate205await page.evaluate(() => {206const element = document.querySelector("#map");207if (element) {208// Simulate wheel event as fallback for zoom209element.dispatchEvent(210new WheelEvent("wheel", {211deltaY: -100, // Negative = zoom in212ctrlKey: true, // Ctrl+wheel = pinch on many apps213}),214);215}216});217218// Or trigger the app's zoom function directly219await page.evaluate(() => {220(window as any).mapInstance?.setZoom(15);221});222});223```224225## Viewport Testing226227### Test Different Sizes228229```typescript230const viewports = [231{ name: "mobile", width: 375, height: 667 },232{ name: "tablet", width: 768, height: 1024 },233{ name: "desktop", width: 1920, height: 1080 },234];235236for (const { name, width, height } of viewports) {237test(`navigation on ${name}`, async ({ page }) => {238await page.setViewportSize({ width, height });239await page.goto("/");240241if (width < 768) {242// Mobile: should have hamburger menu243await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();244} else {245// Desktop: should have visible nav links246await expect(page.getByRole("link", { name: "Products" })).toBeVisible();247}248});249}250```251252### Dynamic Viewport Changes253254```typescript255test("responsive layout change", async ({ page }) => {256await page.setViewportSize({ width: 1200, height: 800 });257await page.goto("/dashboard");258259// Desktop: sidebar visible260await expect(page.getByRole("complementary")).toBeVisible();261262// Resize to mobile263await page.setViewportSize({ width: 375, height: 667 });264265// Mobile: sidebar hidden, hamburger visible266await expect(page.getByRole("complementary")).toBeHidden();267await expect(page.getByRole("button", { name: "Menu" })).toBeVisible();268});269```270271## Mobile-Specific UI272273### Hamburger Menu274275```typescript276test("mobile navigation", async ({ page }) => {277await page.setViewportSize({ width: 375, height: 667 });278await page.goto("/");279280// Open hamburger menu281await page.getByRole("button", { name: "Menu" }).click();282283// Navigation drawer should appear284const nav = page.getByRole("navigation");285await expect(nav).toBeVisible();286287// Navigate via mobile menu288await nav.getByRole("link", { name: "Products" }).click();289290await expect(page).toHaveURL("/products");291// Menu should close after navigation292await expect(nav).toBeHidden();293});294```295296### Bottom Sheet297298```typescript299test("bottom sheet interaction", async ({ page }) => {300await page.setViewportSize({ width: 375, height: 667 });301await page.goto("/product/123");302303await page.getByRole("button", { name: "Add to Cart" }).click();304305// Bottom sheet appears306const sheet = page.getByRole("dialog");307await expect(sheet).toBeVisible();308309// Select options310await sheet.getByRole("combobox", { name: "Size" }).selectOption("Large");311await sheet.getByRole("button", { name: "Confirm" }).click();312313await expect(page.getByText("Added to cart")).toBeVisible();314});315```316317### Pull to Refresh318319```typescript320test("pull to refresh", async ({ page }) => {321await page.goto("/feed");322323const feed = page.getByTestId("feed");324const initialFirstItem = await feed.locator("> *").first().textContent();325326// Simulate pull down327const box = await feed.boundingBox();328if (box) {329await page.touchscreen.tap(box.x + box.width / 2, box.y + 50);330await page.mouse.move(box.x + box.width / 2, box.y + 200, { steps: 20 });331await page.mouse.up();332}333334// Wait for refresh335await expect(page.getByTestId("loading")).toBeVisible();336await expect(page.getByTestId("loading")).toBeHidden();337338// Content should be updated (in a real app)339});340```341342## Responsive Breakpoints343344### Test All Breakpoints345346```typescript347const breakpoints = {348xs: 320,349sm: 640,350md: 768,351lg: 1024,352xl: 1280,353"2xl": 1536,354};355356test.describe("responsive header", () => {357for (const [name, width] of Object.entries(breakpoints)) {358test(`header at ${name} (${width}px)`, async ({ page }) => {359await page.setViewportSize({ width, height: 800 });360await page.goto("/");361362if (width < 768) {363await expect(page.getByTestId("mobile-menu-button")).toBeVisible();364await expect(page.getByTestId("desktop-nav")).toBeHidden();365} else {366await expect(page.getByTestId("mobile-menu-button")).toBeHidden();367await expect(page.getByTestId("desktop-nav")).toBeVisible();368}369});370}371});372```373374### Visual Regression at Breakpoints375376```typescript377test.describe("visual regression", () => {378const sizes = [379{ width: 375, height: 667, name: "mobile" },380{ width: 768, height: 1024, name: "tablet" },381{ width: 1440, height: 900, name: "desktop" },382];383384for (const { width, height, name } of sizes) {385test(`homepage at ${name}`, async ({ page }) => {386await page.setViewportSize({ width, height });387await page.goto("/");388389await expect(page).toHaveScreenshot(`homepage-${name}.png`);390});391}392});393```394395## Anti-Patterns to Avoid396397| Anti-Pattern | Problem | Solution |398| --------------------------- | ------------------------- | -------------------------------- |399| Only testing one viewport | Misses responsive bugs | Test multiple breakpoints |400| Ignoring touch events | Features broken on mobile | Test tap, swipe, long press |401| Hardcoded viewport in tests | Can't test multiple sizes | Use `page.setViewportSize()` |402| Not testing orientation | Landscape bugs missed | Test both portrait and landscape |403404## Related References405406- **Visual Testing**: See [test-suite-structure.md](../core/test-suite-structure.md) for screenshot testing407- **Locators**: See [locators.md](../core/locators.md) for mobile-friendly selectors408- **Browser APIs**: See [browser-apis.md](../browser-apis/browser-apis.md) for permissions (camera, geolocation, notifications)409- **Canvas/Touch**: See [canvas-webgl.md](../testing-patterns/canvas-webgl.md) for touch gestures on canvas elements410