Playwright deep diveLayered emulationPick what you needSkip what you don't

Playwright mobile testing, one layer at a time

Most guides on this keyword hand you use: { ...devices['iPhone 13'] } and stop. That descriptor flips seven things at once. Half of them you wanted, half of them quietly drift your screenshot baselines and confuse your analytics. This guide breaks the descriptor into its actual layers, explains when each one matters and when it adds noise, and shows the minimal viewport: 'mobile' preset that Assrt collapses the common case to: two keys, one resize call, no other flags.

A
Assrt Engineering
11 min read
4.9from Assrt engineering
Playwright `devices` registry: 143 device descriptors in deviceDescriptorsSource.json
iPhone 13 descriptor: 7 fields (UA, screen, viewport, DPR=3, isMobile, hasTouch, webkit)
Pixel 7 descriptor: 7 fields (UA, screen, viewport, DPR=2.625, isMobile, hasTouch, chromium)
Assrt mobile preset: 2 fields (width=375, height=812). agent.ts:402, applied via browser.resize
iPhone 13 390x664 DPR=3iPhone 14 Pro 393x659 DPR=3iPhone SE 320x568 DPR=2Pixel 7 412x839 DPR=2.625Pixel 8 412x892 DPR=2.625Galaxy S9+ 320x658 DPR=4.5iPad Mini 768x1024 DPR=2iPad Pro 11 834x1194 DPR=2Assrt 'mobile' 375x812 DPR=1

One row in deviceDescriptorsSource.json. The full registry ships 143 of these. Each one flips seven flags. Assrt's preset flips two. Pick deliberately.

0devices in Playwright's descriptor JSON
0fields the iPhone 13 descriptor sets
0fields Assrt's 'mobile' preset sets
0MCP call the preset compiles to

What every other "Playwright mobile testing" guide says

Search "playwright mobile testing" and the first ten results say almost the same thing. Step one: import devices. Step two: spread devices['iPhone 13'] into your project config. Step three: optionally pay for a real-device cloud. None of them tell you what is actually inside that descriptor or which layers you can skip. That is the gap this page fills.

What every top result covers
Spread the descriptor, hope for the best

Use the full bundle. Cross fingers your screenshot baselines, analytics, and UA-gated code paths all behave the way you expected. Add BrowserStack on top.

The gap this guide fills
Read the descriptor. Pick the layers.

Each field in the descriptor exists for a specific bug class. Add the layer that catches your bug. Skip the layers that do not. Most teams need exactly one layer: viewport.

What `devices['iPhone 13']` actually contains

This is the literal JSON. It lives at node_modules/playwright-core/lib/server/deviceDescriptorsSource.json in any Playwright install. Every key in this object is a flag you opt into when you write use: { ...devices['iPhone 13'] }. There is no version of this descriptor that gives you only the viewport. It is a bundle.

playwright-core/lib/server/deviceDescriptorsSource.json (excerpt)

Pixel 7 is structurally identical: a different UA, a different screen size, deviceScaleFactor 2.625 instead of 3, and Chromium instead of WebKit. The full registry is 143 such bundles, all shaped the same way. None of them is "just resize."

What Assrt's "mobile" preset actually contains

Two keys. The whole definition is a record literal at line 401 of src/core/agent.ts, inside the agent's run loop. When the agent receives viewport: 'mobile', it calls this.browser.resize(375, 812). That is the entire mobile mode. No UA spoof, no DPR change, no touch flag, no engine switch.

assrt-mcp/src/core/agent.ts:399-410

And the resize wrapper itself, in src/core/browser.ts. Three lines of body. One MCP tool call.

assrt-mcp/src/core/browser.ts:595-598

Side by side: full descriptor vs viewport only

Same goal: "test the app at iPhone-class width." Two ways to write it. Toggle between them and read what each actually does.

What you write to opt into mobile

// playwright.config.ts, the "use everything" answer the SERP gives you
// Three projects, three full descriptor bundles. Touch, UA, DPR, all of it.

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

export default defineConfig({
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },   // 5 fields
    },
    {
      name: "Mobile Safari",
      use: { ...devices["iPhone 13"] },        // 7 fields
    },
    {
      name: "Mobile Chrome",
      use: { ...devices["Pixel 7"] },          // 7 fields
    },
  ],
});
19% fewer config lines

What each descriptor field actually changes at runtime

Seven flags going in. Seven distinct things changing in the browser context that runs your test. If you do not need a given downstream behavior, the matching flag is noise.

One descriptor, seven things flipped

userAgent
viewport
deviceScaleFactor
isMobile
hasTouch
defaultBrowserType
devices['iPhone 13']
Server-side UA branching
CSS media queries match
Screenshot baseline = 1170x1992
Touch events fire
WebKit engine, not Chromium

Compare to Assrt's mobile preset, which has one downstream effect: a browser_resize(375, 812) MCP call. Every other lane in this diagram stays at its host default.

The six layers of mobile emulation, in order of how often you need them

From the layer that catches the most production bugs to the one you almost never need. Each card names the field, what it changes, and the bug class it is for.

Layer 1: viewport only

Resize the window to a phone-class width (Assrt picks 375x812, the iPhone X / 13 mini class). Pass `viewport: { width: 375, height: 812 }` in playwright.config or call `--viewport mobile` in Assrt. This catches every CSS @media breakpoint, every flex-wrap collapse, every fixed-width element that overflows the screen. It is the cheapest change with the highest catch rate.

Layer 2: deviceScaleFactor

Change `deviceScaleFactor` to 2 or 3 if your app branches on `window.devicePixelRatio` (Retina image swaps, canvas drawing buffers, sharp-text logic). Otherwise it is screenshot-baseline noise.

Layer 3: hasTouch

Set `hasTouch: true` if your app has touchstart / touchmove / touchend handlers, or queries `(pointer: coarse)`. Off by default in plain Playwright projects.

Layer 4: isMobile

Chromium-only flag that flips the viewport meta-tag handling and overscroll behavior. Makes `window.innerWidth` honor the meta-viewport tag. Not respected by WebKit projects.

Layer 5: userAgent spoof

Only matters if your code reads `navigator.userAgent` (analytics, feature gating, app-store install banners). Never matters for CSS — that is what media queries are for. The most over-applied layer in mobile testing.

Layer 6: defaultBrowserType

iPhone descriptors force `webkit`, Pixel descriptors force `chromium`. Useful for cross-engine smoke tests, but desktop WebKit still differs from real iOS Safari on momentum scroll, fixed positioning, and viewport-fit. The only truly faithful Safari is a real device.

A decision tree for picking the right layer

Walk these in order. Stop at the layer that catches your bug class. Adding more layers below your bug class is decoration that drifts your baselines and slows your suite.

1

Start with viewport only at 375x812

If your goal is 'does the responsive layout still work,' a window resize is the entire test. Assrt's `--viewport mobile` is exactly this. In raw Playwright, set `use: { viewport: { width: 375, height: 812 } }` in playwright.config and run your existing tests against that project. You will catch most reported mobile bugs at this layer alone.

2

Add deviceScaleFactor only if your code reads it

Search your repo for `devicePixelRatio`, `getContext('2d').scale`, `srcset`, `image-set`, or any sharp-text or canvas drawing path. If those exist, add `deviceScaleFactor: 2` or `3` to your project config so screenshots match what your users actually see. Otherwise, leave it at 1 and keep your snapshot diffs portable.

3

Add hasTouch only for touch handlers

Grep for `touchstart`, `touchmove`, `touchend`, `pointerdown`, `'(pointer: coarse)'`, swipe libraries (Hammer, Swiper, embla). If your app has none of those, hasTouch changes nothing for the test. If it does, set `hasTouch: true` and write touch-event-driven test steps; click() alone will not exercise the swipe path.

4

Spoof the User-Agent only if your server branches on it

If you serve different HTML to Mobile Safari (App Store smart banners, native-app deep-link banners, mobile-specific hero copy), then yes, use the full `devices['iPhone 13']` bundle so the UA gets sent. If your server is UA-agnostic, the spoof is harmless decoration that drifts your screenshot baselines and confuses analytics.

5

Cross-engine: run a Mobile Safari project too, but read its label honestly

`devices['iPhone 13']` runs WebKit, which is the same engine family as iOS Safari. It is closer to the real thing than Chromium-pretending-to-be-iPhone. But it is still desktop WebKit: the JIT differs, the viewport-fit handling differs, momentum scroll differs. Use it as a smoke test, not as proof your iOS users will not hit a bug.

6

Real devices only when something demands them

Real-device clouds (BrowserStack, Sauce Labs, LambdaTest) cost money and add minutes per run. Reach for them when (a) you are debugging a bug that only repros on iOS Safari, (b) you need to verify Apple Pay / Face ID / camera flows, or (c) your customers are paying you to certify on real hardware. Otherwise, layered emulation is faster and cheaper.

Full descriptor vs Assrt's preset, line by line

Same row, different columns. The left is what you get when you spread devices['iPhone 13'] or devices['Pixel 7']. The right is Assrt's preset.

FeaturePlaywright `devices` descriptorAssrt mobile preset
Width / heightiPhone 13: 390x664. Pixel 7: 412x839. (Playwright `devices` table)Assrt mobile preset: 375x812. Pass `{width, height}` directly to override.
User-Agent stringiPhone 13 spoofs Mobile Safari iOS 15. Pixel 7 spoofs Chrome on Android 14.No UA injection. Server sees the real browser the agent is driving.
deviceScaleFactoriPhone 13: 3. Pixel 7: 2.625. Changes screenshot pixel dimensions and any DPR-based logic.Stays at 1 (the host's). Screenshot baselines stay portable across runs.
isMobile flagtrue. Enables touch viewport meta-tag handling and overscroll behavior in Chromium.Not set. Pages render with desktop viewport semantics at 375px width.
hasTouchtrue. Adds `Touch` events to the page; window.matchMedia('(pointer: coarse)') matches.Not set. Mouse-only. Hover states still trigger on click.
Browser engineiPhone 13 forces WebKit. Pixel 7 forces Chromium. (Real iOS Safari is still different.)Whatever @playwright/mcp is launching (Chromium by default). One engine across runs.
What you write to opt in`use: { ...devices['iPhone 13'] }` (or `--project Mobile Safari` per-run)`viewport: 'mobile'` (string preset) or `viewport: { width, height }` (object)
Catches responsive layout bugsYes (viewport changes alone do this).Yes. This is what 90% of mobile tests are actually checking for.
Catches mobile-only feature flags via navigator.userAgentYes (UA spoof + isMobile flip both contribute).No. Use the full devices descriptor when your app branches on UA.
Catches touch-event handlers (touchstart, swipes)Yes (hasTouch enables Touch events).No. Use `hasTouch: true` in playwright.config or the full descriptor.
Catches DPR-sensitive bugs (Retina images, canvas pixel ratio)Sometimes (deviceScaleFactor=3 forces it).No. Run a separate project with explicit `deviceScaleFactor`.
Catches mobile Safari-only quirks (fixed positioning, momentum scroll)No. Desktop WebKit ≠ iOS Safari. Real device is the only honest answer.No. Same caveat. Use BrowserStack / Sauce / Lambda for the last 1%.

Running the same plan at mobile and desktop widths

Same URL, same prose plan, same agent. The only difference is the --viewport flag. The agent re-snapshots the DOM at the new size and adapts its clicks to whatever the responsive layout produced. It does not need a separate mobile script.

One plan, two viewports
2 keys

The mobile preset is two keys, applied as one resize call. Everything else stays at the host default. That is the design.

agent.ts:399-410, browser.ts:595-598

Running a real Assrt mobile scenario, end to end

The plan file is English. The viewport is 375x812. The agent drives @playwright/mcp calls live. Every step is an MCP tool invocation you could trace yourself. No proprietary script format, no hidden recording file.

assrt run --viewport mobile

What "viewport only" actually catches

Before you reach for a real-device cloud or the full descriptor bundle, here is the bug list a viewport-only mobile run already catches. It is longer than most teams expect.

Bugs caught by a 375x812 viewport alone

  • Hero text wrapping mid-word on small screens
  • Hero image overflowing horizontal axis, scrollbar appearing
  • Pricing cards stacking incorrectly under flex-wrap
  • Hamburger menu button missing because container too narrow
  • Sidebar nav pushing main content off-screen
  • Modal content overflowing because width: 600px is hard-coded
  • Form fields too small to tap (target size below 44px)
  • Footer columns wrapping into a broken zigzag
  • CTA button cut off by safe-area when viewport is tall and narrow
  • Image carousel arrows hidden behind scaled-down content

What it will NOT catch: code that branches on navigator.userAgent, swipe gestures wired to touchstart/touchend, Retina image swaps via srcset/image-set, and iOS-Safari-only rendering quirks. Add the matching layer for those, in that order.

Bring a flaky mobile suite. Leave with a layer plan.

Thirty minutes. Share one mobile test that flakes or one bug that does not repro under your current Playwright config. We walk through which descriptor field is missing, which one is noise, and what a minimal Assrt mobile scenario for the same flow looks like in prose.

Book a call

Playwright mobile testing FAQ

What is the simplest possible Playwright mobile test?

One line: set the viewport to a phone-class width and run your existing tests against it. In playwright.config.ts: `use: { viewport: { width: 375, height: 812 } }`. In a single test: `test.use({ viewport: { width: 375, height: 812 } })`. In Assrt: `--viewport mobile`. That is it. No userAgent, no devicePixelRatio, no `isMobile` flag. This catches every CSS media-query bug, every flex-wrap collapse, every overflowing fixed-width element. Most production mobile bugs report as 'this looks broken on my phone' and that exact set of bugs surfaces at this layer.

What does Playwright's `devices['iPhone 13']` actually set?

Seven fields. From node_modules/playwright-core/lib/server/deviceDescriptorsSource.json: userAgent (a Mobile Safari iOS 15 string), screen { width: 390, height: 844 }, viewport { width: 390, height: 664 }, deviceScaleFactor 3, isMobile true, hasTouch true, defaultBrowserType 'webkit'. When you write `use: { ...devices['iPhone 13'] }` you flip all seven at once. That is convenient when you want a fast cross-platform smoke test, and it is noisy when you only wanted the viewport change. The full descriptor list ships 143 devices in the JSON file.

How is Assrt's `viewport: 'mobile'` preset different from Playwright's `devices['iPhone 13']`?

Two keys vs seven. agent.ts:402 defines `mobile: { width: 375, height: 812 }`. agent.ts:407 calls `await this.browser.resize(dims.width, dims.height)`. browser.ts:595-598 defines `resize` as a single `browser_resize` MCP call. There is no UA spoof, no deviceScaleFactor change, no `isMobile`, no `hasTouch`, no engine switch. The design choice is: pick the layer that actually matters for the bug class you care about, not the whole bundle. If you do want full descriptor behavior, drive raw `@playwright/test` with `devices['iPhone 13']`. Assrt also accepts an explicit `{ width, height }` object in the same `viewport` slot if you want a different size.

Will viewport-only emulation catch the bug a real iPhone user reported?

Most of the time, yes. The dominant class of 'broken on mobile' bugs is layout: text wrap, image overflow, button stack, modal overflow, hamburger menu missing, sidebar pushed off-screen. All of those are width-driven and reproduce at viewport 375. The bugs that need more layers are: (a) feature flags read from `navigator.userAgent`, (b) touch-only interactions like swipe, (c) Retina image handling, (d) mobile-Safari-specific scroll quirks. Add the relevant layer for the bug. If after all those layers the bug still only appears on the user's actual iPhone, you have hit (e): real-device behavior the desktop engine cannot reproduce, and that is what BrowserStack / Sauce / LambdaTest exist for.

Why does deviceScaleFactor matter for screenshots?

If you set `deviceScaleFactor: 3`, Playwright captures screenshots at 3x the logical viewport pixel count. A 390x664 viewport produces a 1170x1992 PNG. Your golden baseline must match. If you mix one project that uses `devices['iPhone 13']` (DPR=3) with another that uses raw `viewport: { width: 390, height: 664 }` (DPR=1), the same page produces two different baseline files and any visual-regression diff between them is meaningless. Pick one DPR per project and stick to it. Assrt's mobile preset stays at the host's DPR (typically 1 in headless CI) so baselines stay portable.

Does `devices['iPhone 13']` actually run mobile Safari?

It runs Playwright's bundled WebKit, which shares the same engine family as iOS Safari but is not identical. The desktop WebKit binary differs from the iOS binary on JIT specifics, momentum scroll behavior, fixed positioning under address-bar collapse, viewport-fit and safe-area-inset handling, and PWA install hooks. For a fast cross-engine smoke test it is the closest open-source approximation. For 'will this work on a real iPhone running iOS 17.4 in Safari?' you still need a real device. Apple's mobile Safari has unique scrolling and viewport handling that desktop WebKit cannot fully replicate.

How do I run a Playwright mobile test in CI without a real device?

Three options, increasing in fidelity. (1) Viewport-only: `viewport: { width: 375, height: 812 }` in your config. Catches layout bugs. Runs in seconds. No extra deps. (2) Full Playwright project: define a `Mobile Safari` project with `use: { ...devices['iPhone 13'] }`. Catches layout, UA-gated, touch, and DPR. Same headless CI box. (3) Assrt with an English plan: `assrt run --url <ci-deploy-url> --plan-file mobile.md --viewport mobile`. Same headless CI box, but the test scenario is a markdown file written in English instead of selectors. None of these require a device farm subscription.

Can I test touch gestures (swipe, pinch, tap-and-hold) with Playwright?

You can test touch events. Set `hasTouch: true` in the project config (it is on by default in any device descriptor with `hasTouch: true`, like iPhone 13 and Pixel 7). Then use `page.touchscreen.tap(x, y)` for taps and the locator's `dispatchEvent('touchstart' | 'touchmove' | 'touchend', ...)` for compound gestures. For complex swipes, libraries that read `Touch` events directly (Hammer, Swiper, embla-carousel) need full touch event sequences with proper changedTouches arrays; that is more code than a click() but it does work. Pinch-zoom is harder because it depends on browser native gesture handling that Chromium does not fully expose under hasTouch. Real device is still the honest answer for pinch.

What viewport size should I use as my mobile baseline?

375 wide is the widely-used default because it covers the iPhone X / 11 Pro / 12 mini / 13 mini / SE 3rd gen class. That is what Assrt's `mobile` preset picks (375x812). 390 covers iPhone 13 / 14 / 15 standard. 393 covers Pixel 7 / 8. Many teams pick 360 (the smallest common Android width) as a 'worst-case responsive' check. Pick one as your default mobile baseline (375 or 360 are both defensible) and add a second project for the larger size if your design has issues at the bigger breakpoint. The 'iPhone Plus / Max' widths (414-430) are the same layout as a tablet for most CSS — they rarely surface unique bugs.

Why might I prefer the simpler viewport-only approach to a full descriptor?

Three reasons. First, fewer flags flipped means fewer things drifting in your snapshot baselines. The iPhone 13 descriptor flips deviceScaleFactor to 3, which changes every screenshot pixel dimension; if you ever switch a baseline back to DPR=1 you re-bake every image. Second, the UA spoof confuses your analytics and any honest 'who actually visits this URL' reporting from your CI runs. Third, fewer layers means less to reason about when a test fails. If a viewport-only test fails, the bug is layout. If a full-descriptor test fails, you have to ask which of seven flips caused it. Assrt's preset is the layer-1-only answer; `devices['iPhone 13']` is the all-seven answer. Pick deliberately.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.