| |

Playwright Mobile Testing: Device Emulation in TypeScript

Playwright mobile testing device emulation in TypeScript - featured image

Playwright mobile testing is the fastest way to catch mobile-only bugs without buying a single device. When I run a checkout flow on an iPhone 13 profile and it breaks because a button renders off-screen, that is a bug a desktop run would never find. In this Day 49 tutorial I will walk you through device emulation in TypeScript: the built-in device registry, viewports, geolocation, permissions, touch gestures, and the honest limits of emulation versus a real phone.

Table of Contents

Contents

What Is Playwright Mobile Testing?

Playwright mobile testing means running the same Chromium, WebKit, or Firefox engine with a mobile profile attached, so the page renders and behaves the way it does on a phone. The engine is still a desktop browser under the hood. Playwright swaps four things at the context level: the user agent string, the viewport size, the device scale factor, and two flags, isMobile and hasTouch. Together those five fields convince the page that it is running on a phone, so responsive CSS breakpoints, touch-only handlers, and mobile-specific redirects all fire correctly.

I see teams conflate two ideas and it costs them. Device emulation changes how the page sees the device. A viewport resize only changes the window width. You can set viewport: { width: 390, height: 844 } and still miss the bug, because the page never receives a mobile user agent, so the server serves the desktop layout. If you want the real mobile experience in a test, use a device descriptor, not just a width.

The numbers explain why this matters. Playwright has 94,440 GitHub stars and @playwright/test passes 204 million downloads a month as of this writing, with the latest stable release at v1.62.1 shipped on July 30, 2026. Teams are not adopting it because they love writing config files. They adopt it because one codebase now covers desktop, tablet, and mobile without a separate Appium project. The official Playwright emulation docs are the reference for every context flag I cover below. I also wrote the responsive side of this in an earlier post on responsive breakpoint testing, and if you are deciding between Playwright and Appium for a mobile-first app, read my Appium 2.0 vs Playwright Mobile guide before you commit.

The Device Registry: iPhone, Pixel, and Galaxy Profiles

Playwright ships a built-in registry of device descriptors. You do not hand-write user agent strings or guess viewport sizes. You import devices and reference a profile by name, and Playwright fills in the rest.

import { devices } from '@playwright/test';

console.log(devices['iPhone 13']);
// {
//   userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) ...',
//   viewport: { width: 390, height: 844 },
//   deviceScaleFactor: 3,
//   isMobile: true,
//   hasTouch: true,
//   defaultBrowserType: 'webkit'
// }

Reading a device descriptor

Each descriptor has six fields you should know cold:

  • userAgent: the exact UA string the device sends. The server uses this to serve mobile markup.
  • viewport: the CSS pixel width and height. iPhone 13 is 390×844, Pixel 5 is 393×851.
  • deviceScaleFactor: the DPR. iPhone 13 reports 3, which matters for canvas and image rendering tests.
  • isMobile: flips the navigator.userAgentData and mobile media-query behavior.
  • hasTouch: enables touch event dispatch and the page.touchscreen API.
  • defaultBrowserType: which engine the device normally uses. iPhones map to WebKit, Pixels to Chromium.

Which devices ship out of the box

The registry covers the phones and tablets your users actually carry: iPhone 12 and 13 families, Pixel 5 and 7, Galaxy S8 through S20, several iPad generations including iPad Pro 11, and a set of desktop profiles like Desktop Chrome and Desktop Safari for parity runs. I keep a short list pinned in every project README because naming mistakes are the top source of silent failures. If you reference devices['iPhone 13 Pro Max'] and the profile is named slightly differently, Playwright throws at startup, which is loud and fast. That is the behavior you want.

One detail that trips people: the iPhone descriptors default to WebKit. A Safari rendering quirk on mobile will only reproduce if you run it on the WebKit engine. I wrote up the mobile-specific Safari and Firefox behavior separately in my WebKit and Firefox quirks guide. Cross-browser mobile is where most teams quietly ship bugs.

Setting Up Playwright Mobile Testing in Your Project

The cleanest setup is project-level device profiles in playwright.config.ts. Each profile becomes its own project, which means separate runs, separate reports, and separate retry budgets. Here is the config I start every client project with.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  use: {
    baseURL: 'https://scrolltest.com',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
    { name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
    { name: 'tablet',        use: { ...devices['iPad Pro 11'] } },
    { name: 'desktop',       use: { ...devices['Desktop Chrome'] } },
  ],
});

Running npx playwright test now executes the same spec four times, once per profile. The value here is not four green checkmarks. It is the specific failing profile name in the report when a layout breaks on one width. I can open the trace for the mobile-safari run and see exactly why the button overflowed.

Per-test device override

For a single spec that needs a phone, skip the config and use test.use at the top of the file or inside a describe block.

import { test, devices } from '@playwright/test';

test.use({ ...devices['iPhone 13'] });

test('mobile nav collapses into a hamburger', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Menu' }).click();
  await expect(page.getByRole('link', { name: 'Courses' })).toBeVisible();
});

test.use only affects the tests declared in that scope. It is the fastest way to add a mobile regression for one flaky feature without restructuring the whole suite.

Running just the mobile slice

Once mobile profiles live in their own projects, you can target them directly with --project and keep the CI feedback loop tight.

# Run only the two phone profiles
npx playwright test --project=mobile-safari --project=mobile-chrome

# Run the mobile specs, tagged so the desktop suite stays fast
npx playwright test --grep "@mobile"

I pair this with @mobile and @smoke tags so the nightly run is a full matrix and the pull-request run is just smoke tests on the two most common devices. Tags and the quarantine workflow are covered in Day 48 on tags and annotations, and that setup is what makes a four-project device matrix manageable in CI instead of a thirty-minute wait.

Viewports and Responsive Breakpoint Testing

A device profile sets the viewport for you, but you still need explicit viewport tests for the awkward widths between phone and tablet. The classic failure zone is 768px, where many sites switch from hamburger nav to a full menu and half of them get it wrong.

import { test, expect } from '@playwright/test';

const breakpoints = [
  { name: 'small-phone', width: 360, height: 740 },
  { name: 'large-phone', width: 430, height: 932 },
  { name: 'tablet-edge', width: 768, height: 1024 },
  { name: 'laptop',      width: 1280, height: 800 },
];

for (const bp of breakpoints) {
  test(`layout is stable at ${bp.name} (${bp.width}px)`, async ({ browser }) => {
    const page = await browser.newPage({ viewport: { width: bp.width, height: bp.height } });
    await page.goto('/');
    await expect(page.locator('body')).toHaveScreenshot(`home-${bp.name}.png`);
  });
}

I wrote a fuller treatment of this in the responsive breakpoint testing post, but the core rule is simple: a viewport change alone does not set isMobile or hasTouch. If the page has a touch-only carousel or a mobile redirect, pair the width with the matching device flags or use a device descriptor outright.

Geolocation and Permissions

Mobile testing gets interesting when the page needs location, camera, clipboard, or notification access. Playwright lets you spoof all of it per context. This is the feature that finally killed my team’s habit of hardcoding a “skip location” branch in test code.

import { test, expect } from '@playwright/test';

test('store finder shows the nearest Bengaluru store', async ({ browser }) => {
  const context = await browser.newContext({
    geolocation: { latitude: 12.9716, longitude: 77.5946 },
    permissions: ['geolocation'],
    locale: 'en-IN',
    timezoneId: 'Asia/Kolkata',
  });
  const page = await context.newPage();
  await page.goto('https://example.com/stores');
  await expect(page.getByText('Indiranagar')).toBeVisible();
});

Simulating a user in Bengaluru

The geolocation object takes latitude and longitude only, no altitude needed for most apps. I default every Indian-facing suite to Bengaluru coordinates (12.9716, 77.5946) because it matches the audience I write for and it is a real, recognizable pin. The key gotcha is ordering: you must grant the permission before the page calls navigator.geolocation.getCurrentPosition(), or the prompt logic auto-denies and your test fails with no obvious reason.

Granting and clearing permissions

await context.grantPermissions(['geolocation', 'clipboard-read'], {
  origin: 'https://example.com',
});

// revoke everything later in the same test
await context.clearPermissions();

Grant permissions per origin, not globally. A test that grants camera to every origin is hiding a real permission bug rather than finding it. I have watched an app pass CI for months because the permission was over-granted, then fail in production when real users got the actual prompt.

The permission names map to the Web API directly, so the common mobile set looks like this:

  • geolocation for store finders and delivery apps
  • camera and microphone for scan-to-pay and KYC flows
  • clipboard-read and clipboard-write for OTP copy buttons
  • notifications for push opt-in funnels

For Indian payment and OTP-heavy apps, the clipboard permission is the one I see tested least and broken most. A user taps “copy OTP”, the app writes to the clipboard, and the paste step silently fails if the read permission was never granted. Two lines in the context fix the whole flow and let you assert it end to end.

Touch, Tap, and Gesture Emulation

When hasTouch is true, Playwright exposes page.touchscreen, and the difference between a tap and a click becomes real. Mobile browsers fire a 300ms-style sequence of touchstart, touchend, then a synthesized click, and some widgets only respond to the touch sequence.

// Tap at a screen coordinate
await page.touchscreen.tap(180, 240);

// Locate first, then tap the element's center
const button = page.getByRole('button', { name: 'Add to cart' });
const box = await button.boundingBox();
if (box) {
  await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2);
}

touchscreen.tap vs mouse.click

page.click() and locator.click() dispatch mouse events. page.touchscreen.tap() dispatches touch events at a raw coordinate. Most modern widgets respond to both because browsers synthesize the click, but sliders, drag-to-reorder lists, and carousels that use touchstart/touchmove will not budge from a plain click. I covered drag-and-drop in detail on Day 39, and the same principle applies: match the input type to the handler the widget actually listens for.

hasTouch and isMobile flags

These two flags are independent for a reason. A touch-capable laptop is hasTouch: true but isMobile: false. A phone in “desktop site” mode can be the reverse. When a bug only reproduces on a specific combination, set the flags explicitly rather than assuming a device profile always gets it right.

const context = await browser.newContext({
  viewport: { width: 390, height: 844 },
  isMobile: true,
  hasTouch: true,
  deviceScaleFactor: 3,
});

If you need a swipe, Playwright does not ship a one-liner for it, but a short dispatchEvent sequence does the job for carousels and pull-to-refresh widgets that listen for touchstart and touchmove.

async function swipe(page: Page, from: {x: number, y: number}, to: {x: number, y: number}) {
  await page.dispatchEvent('body', 'touchstart', {
    touches: [{ identifier: 1, clientX: from.x, clientY: from.y }],
  });
  await page.dispatchEvent('body', 'touchmove', {
    touches: [{ identifier: 1, clientX: to.x, clientY: to.y }],
  });
  await page.dispatchEvent('body', 'touchend', { touches: [] });
}

// swipe left to advance a carousel
await swipe(page, { x: 300, y: 400 }, { x: 80, y: 400 });

This is approximate by design. Real momentum, inertia, and velocity tracking are not reproducible this way, so if the widget’s behavior depends on swipe velocity, treat it as a candidate for a real-device test instead of a CI assertion.

Locale, Timezone, and Color Scheme

Device profiles do not set locale or timezone. They are page-level preferences you add yourself, and for any app with dates, currency, or a dark-mode toggle, leaving them at the default is a missed bug.

Testing Indian locale and timezone

test.use({
  locale: 'en-IN',
  timezoneId: 'Asia/Kolkata',
});

test('checkout formats price in rupees', async ({ page }) => {
  await page.goto('/checkout');
  await expect(page.getByText('₹1,299')).toBeVisible();
});

Indian test suites should set locale: 'en-IN' and timezoneId: 'Asia/Kolkata' as a default, not an afterthought. Date pickers, delivery windows, and festival sale timers all render wrong when the server assumes UTC and the browser says UTC. This is one of the cheapest wins I add to every QA onboarding checklist.

Dark mode and reduced motion

test.use({ colorScheme: 'dark' });

// or flip it mid-test
await page.emulateMedia({ colorScheme: 'dark', reducedMotion: 'reduce' });

emulateMedia changes the preference without reloading, which is exactly what you want for a toggle test. Reduced motion matters more than most teams admit. An autoplaying carousel that respects prefers-reduced-motion is an accessibility requirement, not a nice-to-have, and it is trivial to assert once the preference is set.

Emulation vs Real Devices: Where Playwright Stops

I want to be straight about the limits, because overselling emulation is how teams ship a broken mobile launch. Device emulation changes the browser’s view of the device. It does not give you a real OS, a real GPU, a real cellular network, or real native webviews.

What emulation cannot do

  • Native webviews: a hybrid app’s in-app browser is not the same as a standalone browser, and Playwright cannot drive it.
  • Hardware sensors: accelerometer, gyroscope, and ambient light are not emulated in a meaningful way.
  • Real network conditions: you can throttle bandwidth with routing, but not carrier-level latency or signal loss.
  • Rendering fidelity: a Safari bug tied to iOS’s actual compositor may not reproduce on desktop WebKit.
  • App Store / Play Store flows: install, upgrade, and push-notification flows need a real device.

When to reach for Appium or real devices

My rule of thumb after running mobile suites across dozens of projects: emulation covers 80 to 90 percent of responsive and functional mobile bugs. When the remaining bugs are native-webview, hardware, or store-flow issues, that is when I add a small Appium or real-device farm layer on top. Do not build the whole suite on real devices first. Start with Playwright device emulation, keep it fast and in CI, and add real devices only for the slice that genuinely needs them. I break down that decision in the Appium 2.0 vs Playwright Mobile guide.

Common Pitfalls That Break Mobile Suites

After watching enough mobile suites fall over in CI, these are the six failures I see over and over:

  1. Viewport without user agent: page gets desktop HTML because the server never saw a mobile UA.
  2. Wrong device name: devices['iPhone 13 Pro Max'] typos throw at startup, but a near-miss name can silently match nothing if you destructure carelessly.
  3. Permission granted too late: the geolocation call already fired, so the auto-deny path ran.
  4. Clicking a touch-only widget: locator.click() on a touchmove-driven carousel does nothing.
  5. Forgetting deviceScaleFactor: canvas and image-diff tests pass locally but fail on the retina DPR in CI.
  6. Locale defaults left as en-US: date and currency assertions pass in UTC and explode for an Indian audience.

Every one of these is a config or input-type mistake, not a flaky test. Fix the setup and the flake disappears. That is the difference between a mobile suite you trust and one you keep re-running.

When a mobile-only failure does land, the fix starts the same way every time: open the trace and confirm the user agent, viewport, and permission state on the failing action. If the trace shows a desktop UA on a spec you expected to be mobile, the device profile was never applied, and the fix is in the config, not the assertion. If the UA is right but the layout is wrong, you found a genuine responsive bug. Knowing which bucket you are in before you start editing saves half a day of guessing.

Key Takeaways

  • Playwright mobile testing is device emulation: a user agent, viewport, DPR, and isMobile/hasTouch flags applied at the context level.
  • Use devices['iPhone 13'] style descriptors instead of hand-rolling widths so the server serves real mobile markup.
  • Set geolocation, permissions, locale, and timezone per context, and grant permissions before the page requests them.
  • Match input type to the widget: use touchscreen.tap() for touch-only handlers, click() for mouse-driven ones.
  • Emulation covers most responsive and functional bugs; layer real devices or Appium on top only for webviews, hardware, and store flows.

If you want the full mobile picture, start with my responsive breakpoint testing post, then the Appium vs Playwright decision guide, and finish with the WebKit and Firefox quirks deep dive. Together they cover the mobile layer end to end.

FAQ

Does Playwright mobile testing work on real phones?

No. Playwright device emulation runs desktop browser engines with a mobile profile. It does not control a physical phone. For real devices you need a device farm or Appium.

Which devices does Playwright emulate out of the box?

The built-in registry includes iPhone, Pixel, Galaxy, and iPad families plus desktop profiles like Desktop Chrome and Desktop Safari. Import them from @playwright/test with devices['Pixel 5'].

How do I spoof GPS location in a Playwright mobile test?

Pass geolocation: { latitude, longitude } and permissions: ['geolocation'] to browser.newContext(), then navigate to the page. Grant the permission before the page queries location.

Why does my mobile viewport test still get the desktop layout?

Because a viewport width alone does not change the user agent. Use a full device descriptor or set isMobile and hasTouch so the server and page both see a mobile client.

Can Playwright emulate touch gestures like swipe?

Partially. page.touchscreen.tap() covers taps, and you can drive swipe with a sequence of touch moves via page.dispatchEvent, but complex native gestures are better tested on a real device.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.