Why mobile emulation matters in modern testing
Over half of web traffic now comes from mobile devices, yet many teams still test primarily on desktop. Mobile emulation: Playwright best practices explained is essential knowledge for anyone building user-facing applications, because the gap between how your app behaves on a laptop and on an iPhone 15 can mean the difference between a smooth user experience and frustrated customers abandoning your checkout flow.
The challenge isn't just making your layout responsive—it's ensuring touch interactions work correctly, that network conditions don't break your forms, and that your app behaves predictably across the fragmented landscape of real devices without requiring a physical device lab. Playwright gives us powerful tools to simulate these conditions with consistency and speed.
Devices['iPhone 15'] presets and why they exist
Playwright ships with a curated set of device profiles that capture real-world hardware characteristics. When you use devices['iPhone 15'], you're not just scaling the viewport—you're applying a pre-configured bundle that includes the correct user agent string, screen dimensions, device scale factor, and touch support settings that an actual iPhone 15 would send to your server.
This matters because servers and client-side code often make decisions based on these signals. Your analytics pipeline might bucket traffic by device type using the user agent. Your API might serve different image resolutions based on device scale factor. Your JavaScript might detect touch capability to choose between hover and tap interactions.
Here's how you'd set up a test using the iPhone 15 preset:
import { test, devices } from '@playwright/test';
test('checkout flow on iPhone 15', async ({ browser }) => {
const context = await browser.createContext({
...devices['iPhone 15'],
});
const page = await context.newPage();
await page.goto('https://example.com/shop');
// Your test logic here
await context.close();
});
The preset automatically gives you:
- Correct viewport dimensions (390×844 for iPhone 15)
- Device scale factor (3.0 for retina display)
- User agent string that identifies it as iOS Safari
- Touch support enabled
- Mobile browser defaults (e.g., viewport meta tag handling)
Rather than hardcoding these values in every test, using presets keeps your suite maintainable. When Apple releases the iPhone 16, Playwright updates the preset and your tests automatically benefit from the new device profile.
Viewport, user agent, and touch interactions
These three components work together to create a convincing mobile environment. Let's break down what each does and why it matters for your test strategy.
Viewport dimensions
The viewport controls the CSS media queries that fire and how your layout reflows. A test that passes on a 1920×1080 desktop might fail when run at 390×844 because your hamburger menu doesn't appear, or your two-column layout collapses to single-column and breaks spacing.
test('navigation collapses on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto('https://example.com');
// Desktop nav should be hidden
await expect(page.locator('[data-testid="desktop-nav"]')).toBeHidden();
// Mobile hamburger should be visible
await expect(page.locator('[data-testid="hamburger"]')).toBeVisible();
});
User agent string
The user agent tells your server what device and browser is making the request. Some apps serve entirely different HTML for mobile vs. desktop (though this is becoming less common). More often, your backend uses the user agent for analytics, feature flags, or to decide whether to set certain cookies.
When you use a device preset, the user agent is automatically set. If you need a custom user agent, you can override it:
const context = await browser.createContext({
...devices['iPhone 15'],
userAgent: 'Custom-Mobile-App/1.0',
});
Touch support
This is where many teams miss critical bugs. Desktop browsers have no concept of touch events—they have mouse events. Mobile browsers fire touch events: touchstart, touchmove, touchend. If your interactive components only listen for click events, they'll work fine in desktop tests but fail on real mobile devices.
When you enable touch in a Playwright context, the browser emulates touch events. However, Playwright itself still uses mouse commands when you call page.click(). To properly test touch behavior, you need to explicitly use touch methods:
test('swipe gesture on carousel', async ({ page }) => {
await page.goto('https://example.com/gallery');
const carousel = page.locator('[data-testid="carousel"]');
// Simulate a swipe by dragging
await carousel.dragTo(carousel, {
sourcePosition: { x: 300, y: 100 },
targetPosition: { x: 100, y: 100 },
});
// Or use direct touch simulation
await page.touchscreen.tap(150, 150);
});
The subtlety here is that real users often hold their finger on the screen for a moment before swiping, or perform multi-touch gestures. Playwright's touchscreen API lets you simulate these scenarios more accurately than mouse-only testing.
Not a real device — but close
This is the critical mindset shift many teams need to make. Playwright emulation is not a complete substitute for real device testing. It's a fast, reliable way to catch the majority of issues before you ever touch a physical device.
What emulation does catch:
- Layout and CSS media query issues
- Touch event handling and gesture recognition
- Correct form factor assumptions (no hover on touch devices)
- Proper handling of mobile-specific APIs (geolocation, camera permissions)
- Network condition simulation (throttling for slow 4G)
What emulation doesn't catch:
- Actual iOS/Android OS behavior (notification handling, app backgrounding, system gestures)
- Device-specific bugs in Safari or Chrome (Webkit vs. Blink rendering differences can still occur in emulation, but not all)
- Performance issues specific to lower-end hardware (a flagship phone can't truly emulate a budget Android device's CPU constraints)
- Real cellular network conditions (emulation uses your test runner's connection)
- Actual sensor data (accelerometer, gyroscope readings)
A pragmatic approach is to use emulation for rapid iteration and CI/CD gates, then run critical user journeys on real devices in a staging environment before release. Many teams use cloud device farms (BrowserStack, Sauce Labs) for this final validation layer, often triggered only on main branch commits.
The economics are compelling: emulation tests run in milliseconds and cost essentially nothing. Real device tests run in seconds and cost real money. Running 200 emulated mobile tests before running 10 real device tests is a much smarter allocation of resources.
Add a mobile project in config
Rather than embedding device logic in individual test files, the cleaner pattern is to define separate projects in your Playwright config. This lets you run all your tests against multiple device types with a single command, and keeps your test code device-agnostic.
Here's a practical setup:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'chromium-desktop',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'webkit-desktop',
use: { ...devices['Desktop Safari'] },
},
{
name: 'firefox-desktop',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile-ios',
use: { ...devices['iPhone 15'] },
},
{
name: 'mobile-android',
use: { ...devices['Pixel 7'] },
},
],
});
Now your test file stays simple:
import { test, expect } from '@playwright/test';
test('user can add item to cart', async ({ page }) => {
await page.goto('https://example.com/products');
await page.click('button:has-text("Add to Cart")');
await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
});
When you run npx playwright test, this single test now executes five times—once per project. If it fails on the iPhone 15 project but passes on desktop, you immediately know you have a mobile-specific issue.
For CI/CD pipelines, you can selectively run projects:
# Run only mobile projects
npx playwright test --project=mobile-ios --project=mobile-android
# Run a specific test on mobile only
npx playwright test cart.spec.ts --project=mobile-ios
This structure also makes it easy to tag tests that should only run on specific devices:
test('biometric payment (iOS only)', async ({ page, browserName }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-ios', 'Biometric payment not available on this device');
await page.goto('https://example.com/checkout');
// Test Face ID payment flow
});
Common mistakes
Assuming CSS media queries are enough: Many developers set the viewport size and assume the test is mobile-representative. But if you're not testing touch interactions or have never triggered a mobile code path, you might miss crucial bugs. Always test the actual user interactions on mobile, not just the layout.
Forgetting device scale factor: A device with scale factor 2.0 or 3.0 affects image rendering, font rendering, and sometimes JavaScript that reads window.devicePixelRatio. Presets handle this automatically, but if you're manually configuring a device, remember to set it.
Testing only one device type: Not all Android devices are a Pixel 7, and not all iPhones are iPhone 15. Setting up a few representative devices—iPhone, Android, and maybe a tablet—catches more issues than testing a single mobile profile.
Running mobile tests in isolation: If mobile tests only run in a separate CI job triggered manually, they'll bitrot quickly. Make mobile projects part of your standard test matrix so every commit validates mobile compatibility alongside desktop.
Ignoring network conditions: Emulation is fast because it's usually run on fast corporate networks. Real mobile users are on 4G, 5G, or even 3G in some regions. Use Playwright's network throttling to simulate these conditions, or you'll ship a brilliant app that hangs on slow networks.
Key takeaways
- Device presets like
devices['iPhone 15']bundle viewport, user agent, device scale factor, and touch support into a single configuration—don't manually set these separately. - Touch support is enabled in device presets, but Playwright's
click()still uses mouse events; usetouchscreen.tap()ordragTo()to properly test touch-specific behavior. - Mobile emulation catches layout and interaction bugs efficiently, but real device testing is still needed for OS-level behavior, performance constraints, and production validation.
- Define mobile projects in your Playwright config so the same test code runs against multiple device profiles without modification.
- Run mobile projects as part of your standard CI pipeline, not as an optional step, to keep mobile compatibility from regressing.






Comments
Loading comments…