Mobile Emulation and Responsive Testing

8 min read

The previous lesson set up multi-browser projects — Chromium, Firefox, WebKit. This one extends the same projects mechanism to mobile devices and viewport-based responsive testing. Playwright doesn't drive a real iPhone; it emulates one with a precisely-tuned viewport, user-agent, device-scale-factor, and touch-event simulation. For most responsive bugs that's sufficient; for the few that aren't, you pair Playwright with a real-device cloud (BrowserStack, Sauce Labs). This lesson is the device descriptor system, the viewport switch, geolocation and locale emulation, and the dark-mode pattern — every browser-environment toggle you'll need to test a mobile app.

Device descriptors — devices['iPhone 13']

Playwright ships descriptors for 100+ presets. Each one bundles every relevant browser setting:

  • viewport — the inner dimensions of the page
  • userAgent — the UA string the browser sends
  • deviceScaleFactor — pixel density (2 for retina, 3 for high-DPI)
  • isMobile — whether meta name=viewport and CSS media features apply as mobile
  • hasTouch — whether touch events are dispatched alongside (or instead of) mouse events

The standard pattern in playwright.config.ts:

import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  projects: [
    { name: "iPhone 13", use: { ...devices["iPhone 13"] } },
    { name: "Pixel 5", use: { ...devices["Pixel 5"] } },
    { name: "iPad Mini", use: { ...devices["iPad Mini"] } },
    { name: "Galaxy S9+", use: { ...devices["Galaxy S9+"] } }
  ]
});

...devices['iPhone 13'] spreads in the right viewport (390×664), the right user-agent (Mobile Safari), the right device-scale-factor (3), and turns on isMobile and hasTouch automatically. CSS media queries like @media (hover: none) and @media (pointer: coarse) evaluate as you'd expect on a real phone.

To list every preset:

npx playwright show-devices

It dumps the whole table — name, viewport, UA, scale, mobile, touch. Pick the device closest to your audience.

The browser engine still matters

A device preset chooses the engine based on the device:

  • iPhone / iPad → WebKit (Safari engine)
  • Pixel / Galaxy → Chromium (Chrome engine)

This is correct: a real iPhone uses WebKit; a real Pixel uses Chromium. Tests against 'iPhone 13' exercise WebKit-specific behaviour automatically. If you only have Chromium installed (npx playwright install chromium), iPhone projects will fail until you add WebKit (npx playwright install webkit).

Custom viewport without a preset

When you need a specific size that doesn't match a real device — testing a 360×640 small phone, or an unusual 1920×1080 desktop:

projects: [
  {
    name: "small-phone",
    use: {
      viewport: { width: 360, height: 640 },
      isMobile: true,
      hasTouch: true,
      userAgent: "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 ..."
    }
  }
];

Or set the viewport per-test for one-off responsive checks:

test("desktop nav collapses on mobile viewport", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 667 });
  await page.goto("/");
  await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
  await expect(page.getByTestId("desktop-nav")).toBeHidden();
 
  await page.setViewportSize({ width: 1280, height: 800 });
  await expect(page.getByTestId("desktop-nav")).toBeVisible();
  await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
});

page.setViewportSize resizes mid-test — useful for testing breakpoints without spinning up new projects. For systematic mobile coverage, prefer projects.

Geolocation, locale, timezone — environment toggles

Playwright lets you fake every browser-environment value the page might read:

projects: [
  {
    name: "London user",
    use: {
      ...devices["Desktop Chrome"],
      geolocation: { longitude: -0.1276, latitude: 51.5074 },
      permissions: ["geolocation"],
      locale: "en-GB",
      timezoneId: "Europe/London"
    }
  },
  {
    name: "Berlin user",
    use: {
      ...devices["Desktop Chrome"],
      geolocation: { longitude: 13.405, latitude: 52.52 },
      permissions: ["geolocation"],
      locale: "de-DE",
      timezoneId: "Europe/Berlin"
    }
  }
];

Now a test that says "the homepage should show pricing in EUR for German users" runs against a real de-DE locale and Europe/Berlin timezone — exactly the conditions a German user would hit. Without setting these, the browser inherits the test machine's locale, which is usually whatever your CI runner happens to use (often en-US).

Per-test grants and overrides exist too:

test("German user sees EUR pricing", async ({ page, context }) => {
  await context.grantPermissions(["geolocation"]);
  await page.goto("/products");
  await expect(page.getByText(//)).toBeVisible();
});

Dark mode and other media features

emulateMedia switches the page's CSS media context — colour scheme, reduced motion, print:

test("dark mode renders correctly", async ({ page }) => {
  await page.emulateMedia({ colorScheme: "dark" });
  await page.goto("/");
  await expect(page.locator("body")).toHaveCSS("background-color", "rgb(15, 23, 42)");
});
 
test("reduced motion disables animations", async ({ page }) => {
  await page.emulateMedia({ reducedMotion: "reduce" });
  await page.goto("/");
  // Tests that the app respected prefers-reduced-motion
  await expect(page.locator(".animated-banner")).toHaveCSS("animation", "none");
});
 
test("print preview hides interactive elements", async ({ page }) => {
  await page.emulateMedia({ media: "print" });
  await page.goto("/invoice/123");
  await expect(page.getByRole("button")).toBeHidden();
});

colorScheme: 'dark' | 'light' | 'no-preference', reducedMotion: 'reduce' | 'no-preference', media: 'print' | 'screen'. Set in use: for the whole project, or per-test as above.

Three viewports, three behaviours

The same page, three viewport behaviours

Mobile (375×667)

  • Hamburger menu visible; full nav hidden

  • Single-column product grid

  • Touch events fire; hover doesn't

  • Sticky CTA bar at bottom for thumb reach

Tablet (768×1024)

  • Collapsed nav with toggle

  • Two-column product grid

  • Touch + hover both work — design must support both

  • Side panels expand inline; no sticky bar

Desktop (1280×720)

  • Full horizontal nav; no hamburger

  • Four-column product grid

  • Hover-driven menus; tooltips on links

  • Sidebar filters always visible

A responsive test suite verifies all three. Different selectors apply at each breakpoint; the framework lets you express that cleanly with projects + viewport overrides.

A complete responsive spec

A test that runs against three projects, asserting on the right UI at each size:

import { test, expect } from "@playwright/test";
 
test.describe("Responsive nav behaviour", () => {
  test("nav adapts to viewport size", async ({ page, viewport }) => {
    await page.goto("/");
 
    if (viewport && viewport.width < 768) {
      // Mobile
      await expect(page.getByTestId("mobile-menu-button")).toBeVisible();
      await expect(page.getByTestId("desktop-nav")).toBeHidden();
 
      await page.getByTestId("mobile-menu-button").click();
      await expect(page.getByRole("link", { name: "Products" })).toBeVisible();
    } else if (viewport && viewport.width < 1024) {
      // Tablet
      await expect(page.getByTestId("collapsed-nav-toggle")).toBeVisible();
    } else {
      // Desktop
      await expect(page.getByTestId("desktop-nav")).toBeVisible();
      await expect(page.getByTestId("mobile-menu-button")).toBeHidden();
    }
  });
});

The viewport fixture is destructured directly. Three projects (mobile, tablet, desktop) run the same test; the conditional inside picks the right assertions per viewport. Same code, three valid outcomes.

If conditional code feels messy, split into three tests with test.skip:

test("desktop has full nav", async ({ page, viewport }) => {
  test.skip(viewport!.width < 1024, "Desktop only");
  // desktop assertions
});
 
test("mobile shows hamburger", async ({ page, viewport }) => {
  test.skip(viewport!.width >= 768, "Mobile only");
  // mobile assertions
});

For most teams, separate tests scale better — the spec is more readable and skipped tests are explicit about why.

Real devices vs emulation

Playwright emulates: viewport, UA, scale factor, touch events. It doesn't reproduce:

  • The actual processor and memory of the device — performance behaviour differs.
  • The exact text rendering and font set — minor visual differences.
  • Hardware quirks (gyroscope, accelerometer, biometric prompts).
  • The actual iOS WebView (which is just WebKit, but with iOS-specific configuration).

For 95% of bugs, emulation is enough. For the remaining 5% — performance under low-end CPU, real-device gestures, biometric flows — pair Playwright with BrowserStack or Sauce Labs:

projects: [
  { name: "Mobile Chrome (emulated)", use: { ...devices["Pixel 5"] } },
  { name: "Mobile Safari (emulated)", use: { ...devices["iPhone 13"] } },
  // BrowserStack integration for real-device verification
  // Run separately on CI nightly or on-release
];

Coming from Cypress?

Cypress has cy.viewport(...) for resizing the test viewport, but no preset device list, no isMobile flag, no touch-event simulation. The Cypress equivalent of this lesson is "set a viewport and hope CSS media queries fire correctly" — Playwright's device descriptors give you a true mobile context, including the user agent and touch events, in one line.

If your Cypress suite has a cy.viewport('iphone-x') pattern, the migration to Playwright's devices["iPhone X"] is direct, and you gain user-agent and touch-event accuracy along the way.

⚠️ Common mistakes

  • Confusing setViewportSize with isMobile. Resizing to 375×667 makes the viewport mobile-sized, but the user agent is still desktop and there are no touch events. Apps that branch on navigator.userAgent or 'ontouchstart' in window won't behave like real mobile. Use devices['iPhone 13'] or set isMobile: true, hasTouch: true explicitly.
  • Setting locale via JavaScript instead of use.locale. page.evaluate(() => navigator.language = 'de-DE') doesn't work — navigator.language is read-only at runtime. Set locale: 'de-DE' in the project config (or per-context); the browser reports the locale natively.
  • Testing every breakpoint as a separate project. If you have six breakpoints (mobile S, mobile L, tablet, desktop S, desktop L, ultra-wide), six projects times three browsers is 18 runs. That's expensive. Test the transitions — three projects (mobile, tablet, desktop) covers most CSS bugs; add per-test setViewportSize only when a specific breakpoint matters.

🎯 Practice task

Build a responsive Sauce Demo spec across three viewports. 25-30 minutes.

  1. Update playwright.config.ts to add three viewport-based projects:

    import { defineConfig, devices } from "@playwright/test";
     
    export default defineConfig({
      testDir: "./tests",
      use: { baseURL: "https://www.saucedemo.com", trace: "on-first-retry" },
      projects: [
        { name: "Desktop Chrome", use: { ...devices["Desktop Chrome"] } },
        { name: "iPhone 13", use: { ...devices["iPhone 13"] } },
        { name: "Pixel 5", use: { ...devices["Pixel 5"] } }
      ]
    });
  2. Create tests/responsive.spec.ts:

    import { test, expect } from "@playwright/test";
     
    test.describe("Responsive Sauce Demo", () => {
      test.beforeEach(async ({ page }) => {
        await page.goto("/");
        await page.getByPlaceholder("Username").fill("standard_user");
        await page.getByPlaceholder("Password").fill("secret_sauce");
        await page.getByRole("button", { name: "Login" }).click();
        await expect(page).toHaveURL(/inventory/);
      });
     
      test("inventory cards adapt to viewport", async ({ page, viewport }) => {
        await expect(page.locator(".inventory_item")).toHaveCount(6);
     
        // The grid layout differs by viewport (CSS-based) — assert width via getBoundingClientRect
        const card = page.locator(".inventory_item").first();
        const box = await card.boundingBox();
     
        if (viewport && viewport.width < 768) {
          // Mobile — single column, card width nearly fills viewport
          expect(box!.width).toBeGreaterThan(viewport.width * 0.7);
        } else {
          // Desktop — multi-column, card is narrower than the viewport
          expect(box!.width).toBeLessThan(viewport!.width * 0.6);
        }
      });
     
      test("burger menu opens on every viewport", async ({ page }) => {
        await page.getByRole("button", { name: "Open Menu" }).click();
        await expect(page.getByRole("link", { name: "Logout" })).toBeVisible();
      });
    });
  3. Run all three projects: npx playwright test responsive.spec.ts. Each project runs the test with its own viewport, applying the right branch of the conditional.

  4. Test dark mode. Add a test:

    test("page renders with dark colour scheme", async ({ page }) => {
      await page.emulateMedia({ colorScheme: "dark" });
      await page.goto("/inventory.html");
      // Sauce Demo doesn't have a dark mode, but emulateMedia still affects prefers-color-scheme
      // For your own app, you'd assert on dark-specific styles here
      const bg = await page.locator("body").evaluate(el => getComputedStyle(el).backgroundColor);
      expect(bg).toBeDefined();
    });
  5. Test geolocation. Add a test that grants geolocation and reads navigator.geolocation.getCurrentPosition:

    test("geolocation reports London", async ({ page, context }) => {
      await context.grantPermissions(["geolocation"]);
      await page.goto("/inventory.html");
      const pos = await page.evaluate(() =>
        new Promise<{ lat: number; lon: number }>(resolve => {
          navigator.geolocation.getCurrentPosition(p => resolve({ lat: p.coords.latitude, lon: p.coords.longitude }));
        })
      );
      // Default geolocation is unset; for a real test, set use.geolocation in the project config
      expect(pos).toBeDefined();
    });
  6. Stretch: add geolocation and locale to one project in the config. Add a test that asserts navigator.language === 'de-DE'. Run it. Without locale in the config, the test fails (locale is en-US); with it, the test passes. This is the muscle for testing internationalised apps.

You can now run a test against five mobile devices, three locales, light/dark mode, and three browsers — all from one config and one test file. The next lesson handles the remaining production-grade pattern in this chapter: storing and reusing authentication state so 200 tests don't all log in via UI.

// tip to track lessons you complete and pick up where you left off across devices.