Playwright emulates mobile devices by adjusting the browser environment: the viewport size, the user agent string, deviceScaleFactor for pixel density, isMobile for the layout viewport behavior, and hasTouch for touch events. That set is enough to make your app render, and behave, the way it does on a phone screen. Media queries fire, mobile navigation appears, touch handlers attach.
Be clear about what it is not. The tests still run in desktop browser engines. An "iPhone 15" project runs desktop WebKit with iPhone settings applied, and while WebKit is the engine behind iOS Safari, version and platform differences remain. Emulation also says nothing about performance: your animations run on your CI machine's CPU, not on a three-year-old phone. Treat emulation as a way to test your responsive frontend, and keep separate expectations for hardware.
Playwright ships a registry of device descriptors covering current and older phones and tablets:
import { devices } from '@playwright/test';
const iphone = devices['iPhone 15'];
// {
// viewport: { width: 393, height: 852 },
// userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS ...',
// deviceScaleFactor: 3,
// isMobile: true,
// hasTouch: true,
// defaultBrowserType: 'webkit',
// }Each descriptor bundles the five emulation options plus a default browser engine: WebKit for Apple devices, Chromium for Android ones. devices['Pixel 7'], devices['iPad (gen 11)'], and landscape variants like devices['iPhone 15 landscape'] all follow the same shape. The full list lives in Playwright's deviceDescriptorsSource.json, and printing Object.keys(devices) in a script shows what your installed version knows about.
The standard setup runs the same specs across desktop and mobile by defining one project per target:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 7'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 15'] } },
],
});Every test now runs three times, and npx playwright test --project="Mobile Safari" targets one environment when you are debugging. Reports label each result with its project name, so a failure that only happens on the iPhone viewport is visible at a glance.
One caveat: Firefox does not support isMobile, so mobile projects stick to Chromium and WebKit. That maps to reality well enough, since mobile traffic is overwhelmingly Chrome and Safari.
When only one spec file cares about mobile, skip the project and override options locally:
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['Pixel 7'] });
test('bottom navigation is visible on mobile', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('navigation', { name: 'Bottom' })).toBeVisible();
});test.use applies at the file or describe level, not inside a test body, because Playwright needs the options before it creates the context. The writing tests guide covers how contexts and fixtures fit together.
Responsive bugs live at breakpoints, and breakpoints are just widths. You do not need a device descriptor to test them:
test.describe('navigation at the 768px breakpoint', () => {
test.use({ viewport: { width: 767, height: 900 } });
test('shows the hamburger menu below the breakpoint', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Main' })).toBeHidden();
});
});
test.describe('navigation above the breakpoint', () => {
test.use({ viewport: { width: 1280, height: 900 } });
test('shows the full navigation', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
});
});Testing one pixel below and comfortably above the breakpoint catches the off-by-one CSS errors that manual resizing misses. Pair these with role-based locators so the assertions track what users can actually reach, not what the DOM happens to contain.
With hasTouch: true, locators gain tap(), which dispatches real touch events instead of mouse events:
test.use({ ...devices['Pixel 7'] });
test('opens the action sheet on tap', async ({ page }) => {
await page.goto('/monitors');
await page.getByRole('button', { name: 'More actions' }).tap();
await expect(page.getByRole('menu')).toBeVisible();
});Calling tap() without hasTouch enabled throws, which is a useful guard: it stops you from writing touch tests that silently run as mouse tests. In practice, click() also works under emulation for most UI, so reserve tap() for code paths that listen for touch events specifically, like swipe-adjacent gestures and touch-only handlers.
Landscape testing is a viewport swap plus isMobile. The registry does the work for you with dedicated descriptors:
test.use({ ...devices['iPhone 15 landscape'] });For a custom device, swap width and height yourself and keep isMobile: true so the browser applies mobile viewport behavior. Orientation bugs cluster around fixed-position toolbars and full-height layouts, so a couple of landscape smoke tests on your busiest screens usually pays for itself.
Device emulation extends past the screen. Location-aware and internationalized features are configurable per test:
test.use({
...devices['iPhone 15'],
geolocation: { latitude: 48.8566, longitude: 2.3522 },
permissions: ['geolocation'],
locale: 'fr-FR',
timezoneId: 'Europe/Paris',
});
test('shows nearby locations in French', async ({ page }) => {
await page.goto('/stores');
await expect(page.getByRole('heading', { name: 'Magasins à proximité' })).toBeVisible();
});The permissions entry matters: without it, the browser would show a permission prompt your test cannot click through. locale drives navigator.language and Intl formatting, and timezoneId controls what Date reports inside the page. To move the device mid-test, call context.setGeolocation() with new coordinates.
The colorScheme option flips the prefers-color-scheme media query:
test.use({ colorScheme: 'dark' });
test('renders the dark theme', async ({ page }) => {
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});Dark mode regressions are mostly visual, unreadable text on a dark card, a logo that vanishes, so this option pairs naturally with screenshot comparisons rather than text assertions.
Playwright has no built-in network throttling. If a slow connection is essential to a test, Chromium exposes it through a CDP session:
const client = await page.context().newCDPSession(page);
await client.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 150,
});This is Chromium-only and sits outside Playwright's supported API, so keep it out of critical assertions. For testing slow backends specifically, delaying a mocked route is more precise and works in every browser.
Emulation will not catch engine-specific rendering differences, iOS Safari quirks tied to the actual OS, virtual keyboard behavior, notch and safe-area handling, or anything performance-related. If your product lives or dies on mobile, run your Playwright suite on emulated projects for breadth, then verify the few flows that matter most on real hardware or a device cloud. The two layers catch different bugs, and neither substitutes for the other.
Device settings carry into monitoring unchanged: a Playwright script written against an iPhone viewport can run on a schedule against your live site. Hyperping Browser Checks execute your scripts from multiple regions and alert you when a flow breaks, so mobile-only regressions surface before your users report them.