Mobile Testing with Playwright: Emulation, Real Devices, and Remote Browsers
January 10, 2026 · 5 min read
Mobile traffic accounts for more than half of web visits, but mobile testing is often an afterthought. Playwright makes it easy to test mobile behaviors without physical devices, using device descriptors that accurately emulate screen size, pixel ratio, user agent, and touch capabilities.
Device Descriptors
Playwright ships with over 50 device presets. Use them with devices:
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['iPhone 14'] });
test('homepage renders on iPhone 14', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home-iphone14.png');
});Or configure them per project in playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{ name: 'desktop', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14 Pro'] } },
{ name: 'tablet', use: { ...devices['iPad Pro 11'] } },
],
});Each preset sets:
viewport: width and height in CSS pixelsdeviceScaleFactor: retina ratio (2x or 3x)userAgent: mobile browser user agent stringisMobile: tells the browser to enable mobile modehasTouch: enables touch event APIs
Custom Viewports
Define your own device profile for a specific breakpoint:
test.use({
viewport: { width: 375, height: 812 },
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)',
});Testing Responsive Layouts
Check that navigation collapses correctly on mobile:
import { test, expect, devices } from '@playwright/test';
test.describe('navigation', () => {
test.describe('mobile', () => {
test.use({ ...devices['iPhone 14'] });
test('hamburger menu opens nav drawer', async ({ page }) => {
await page.goto('/');
// Desktop nav should be hidden
await expect(page.getByRole('navigation').getByRole('link', { name: 'Blog' })).not.toBeVisible();
// Open mobile menu
await page.getByRole('button', { name: 'Menu' }).click();
// Nav links should be visible in drawer
await expect(page.getByRole('navigation').getByRole('link', { name: 'Blog' })).toBeVisible();
});
});
test.describe('desktop', () => {
test.use({ ...devices['Desktop Chrome'] });
test('shows nav links directly', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('navigation').getByRole('link', { name: 'Blog' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Menu' })).not.toBeVisible();
});
});
});Touch Events
Playwright emulates touch events when hasTouch: true. Swipe gestures require dispatching touch events manually:
test('swipe dismisses notification', async ({ page }) => {
test.use({ ...devices['iPhone 14'], hasTouch: true });
await page.goto('/');
const notification = page.getByTestId('notification');
const box = await notification.boundingBox();
if (!box) throw new Error('Notification not found');
// Simulate a left swipe
await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x - 100, box.y + box.height / 2);
await page.mouse.up();
await expect(notification).not.toBeVisible();
});For apps using Hammer.js or similar gesture libraries, you can dispatch touch events directly:
await page.evaluate(() => {
const el = document.querySelector('[data-testid="swipeable"]')!;
el.dispatchEvent(new TouchEvent('touchstart', {
touches: [new Touch({ identifier: 1, target: el, clientX: 200, clientY: 300 })],
}));
el.dispatchEvent(new TouchEvent('touchend', {
changedTouches: [new Touch({ identifier: 1, target: el, clientX: 50, clientY: 300 })],
}));
});Geolocation
Test location-based features by providing fake coordinates:
test('shows local weather based on location', async ({ browser }) => {
const context = await browser.newContext({
geolocation: { latitude: 41.9028, longitude: 12.4964 }, // Rome
permissions: ['geolocation'],
});
const page = await context.newPage();
await page.goto('/weather');
await expect(page.getByText('Rome')).toBeVisible();
await context.close();
});Dark Mode and System Preferences
Test system-level preferences like color scheme:
test.describe('system dark mode', () => {
test.use({ colorScheme: 'dark' });
test('applies dark theme based on system preference', async ({ page }) => {
await page.goto('/');
const bg = await page.evaluate(() =>
getComputedStyle(document.body).backgroundColor
);
// Verify dark background color applied
expect(bg).not.toBe('rgb(255, 255, 255)');
});
});Reduced Motion
Test accessibility for users who prefer reduced motion:
test.use({ reducedMotion: 'reduce' });
test('sidebar slides in without animation when motion is reduced', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Sidebar' }).click();
// Should be immediately visible, not animated
await expect(page.getByRole('complementary')).toBeVisible();
});Offline Mode
Test Progressive Web App behavior when the network is unavailable:
test('shows cached content when offline', async ({ context, page }) => {
await page.goto('/');
// Load the page fully online first (to populate cache)
await page.waitForLoadState('networkidle');
// Go offline
await context.setOffline(true);
// Reload and verify cached version shows
await page.reload();
await expect(page.getByText('You are offline')).toBeVisible();
// Or check that cached content still shows for a PWA:
await expect(page.getByTestId('article-list')).toBeVisible();
await context.setOffline(false);
});Running on BrowserStack
For tests that require real device rendering (especially iOS Safari), connect Playwright to BrowserStack:
// playwright.config.ts
const caps = {
browser: 'safari',
browser_version: 'latest',
os: 'osx',
os_version: 'ventura',
name: 'Playwright Mobile Tests',
build: process.env.GITHUB_RUN_ID,
};
export default defineConfig({
projects: [
{
name: 'browserstack-safari',
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify(caps))}&authToken=${process.env.BROWSERSTACK_AUTH_TOKEN}`,
},
},
},
],
});BrowserStack provides real device pools (iOS, Android) accessible via WebSocket. The same Playwright tests run without code changes.
Screenshot Testing at Mobile Viewports
Run visual regression tests for each breakpoint:
const viewports = [
{ name: 'mobile', ...devices['iPhone 14'] },
{ name: 'tablet', ...devices['iPad Pro 11'] },
{ name: 'desktop', viewport: { width: 1440, height: 900 } },
];
for (const viewport of viewports) {
test(`homepage at ${viewport.name}`, async ({ browser }) => {
const context = await browser.newContext(viewport);
const page = await context.newPage();
await page.goto('/');
await expect(page).toHaveScreenshot(`home-${viewport.name}.png`);
await context.close();
});
}Mobile Testing Strategy
Emulation covers 80% of mobile test cases:
- Responsive layout checks
- Touch interaction testing
- Mobile-specific navigation patterns
- Viewport-dependent conditional rendering
Real devices (via BrowserStack or Sauce Labs) are necessary for:
- iOS Safari rendering quirks
- Native browser controls (address bar height, safe areas)
- GPS and hardware sensors
- WebRTC and media APIs
A pragmatic approach: run all tests against emulated devices in every CI run, and schedule real-device tests nightly or before releases. This balances coverage with cost and speed.