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 pageuserAgent— the UA string the browser sendsdeviceScaleFactor— pixel density (2 for retina, 3 for high-DPI)isMobile— whethermeta name=viewportand CSS media features apply as mobilehasTouch— 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-devicesIt 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
setViewportSizewithisMobile. 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 onnavigator.userAgentor'ontouchstart' in windowwon't behave like real mobile. Usedevices['iPhone 13']or setisMobile: true, hasTouch: trueexplicitly. - Setting locale via JavaScript instead of
use.locale.page.evaluate(() => navigator.language = 'de-DE')doesn't work —navigator.languageis read-only at runtime. Setlocale: '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
setViewportSizeonly when a specific breakpoint matters.
🎯 Practice task
Build a responsive Sauce Demo spec across three viewports. 25-30 minutes.
-
Update
playwright.config.tsto 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"] } } ] }); -
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(); }); }); -
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. -
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(); }); -
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(); }); -
Stretch: add
geolocationandlocaleto one project in the config. Add a test that assertsnavigator.language === 'de-DE'. Run it. Withoutlocalein the config, the test fails (locale isen-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.