Home / Blog / Playwright mobile emulation
Playwright mobile emulation: what a device descriptor covers
Playwright's mobile emulation configures one of its own browser builds to behave like a phone
browser: a user agent, a viewport, a screen size, a device scale factor, touch input, and the
meta viewport handling that isMobile switches on. It runs on the machine
your tests run on. It does not run your iOS or Android application, the browser build shipping on
the phone, or the device's hardware.
Met in the wrong order, the documentation looks like a yes. The installation page says Playwright supports the three engines "with native mobile emulation for Chrome (Android) and Mobile Safari". The emulation guide hands you a config whose second project is named Mobile Safari. There is a real Android API reference a few clicks away. Nobody who reads those three in sequence is being careless when they tell a manager the iPhone is covered.
The material that sets the boundary is spread over four pages, and none of them is titled mobile.
playwright.dev/docs/mobile returns a 404 while its neighbours answer, so there is no
official page whose job is this question.
What does a Playwright device descriptor set?
playwright.devices is a registry you spread into a project's use block.
In 1.62.1 it holds 207 entries, and each one is a plain object of browser-context options.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
projects: [
{
name: 'desktop-chromium',
use: { ...devices['Desktop Chrome'] },
},
{
// Chromium configured with a Pixel 7's parameters. The engine is the same
// one the desktop project runs; only the context options differ.
name: 'chromium-as-pixel-7',
use: { ...devices['Pixel 7'] },
},
{
// WebKit configured with an iPhone 13's parameters. Playwright's WebKit
// build, not Safari.
name: 'webkit-as-iphone-13',
use: { ...devices['iPhone 13'] },
},
],
});
Playwright 1.62 · TypeScript. Every sample on this page was run against 1.62.1.
Nothing in that file reaches outside the process. All three projects launch a browser build Playwright downloaded onto the machine running the tests. The second and third launch it with a different set of context options.
Do not copy a descriptor's contents out of an article; the parameters move between releases. Print the one you are about to use:
$ node -e "console.log(require('@playwright/test').devices['Pixel 7'])"
{
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36',
viewport: { width: 412, height: 839 },
screen: { width: 412, height: 915 },
deviceScaleFactor: 2.625,
isMobile: true,
hasTouch: true,
defaultBrowserType: 'chromium'
}
Playwright 1.62 · shell
On 1.62.1 that returns seven keys: userAgent, viewport at 412×839,
screen at 412×915, deviceScaleFactor of 2.625, isMobile,
hasTouch, and defaultBrowserType of chromium. Six of those
are options browser.newContext() already accepts. The seventh names which engine the
project launches.
So the descriptor is shorthand, and it decomposes. The test below builds a second context by hand out of those six values and compares what the two report about the page inside them:
import { test, expect, devices, type Page } from '@playwright/test';
const probe = `<!doctype html>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
#desktop-nav { display: block }
#mobile-nav { display: none }
@media (max-width: 700px) {
#desktop-nav { display: none }
#mobile-nav { display: block }
}
</style>
<nav id="desktop-nav">Desktop</nav>
<nav id="mobile-nav">Mobile</nav>`;
async function measure(page: Page) {
await page.setContent(probe);
return page.evaluate(() => ({
innerWidth: window.innerWidth,
devicePixelRatio: window.devicePixelRatio,
screenWidth: screen.width,
pointerCoarse: matchMedia('(pointer: coarse)').matches,
hoverNone: matchMedia('(hover: none)').matches,
userAgent: navigator.userAgent,
mobileNav: getComputedStyle(document.getElementById('mobile-nav')!).display,
}));
}
test.use({ ...devices['Pixel 7'] });
test('the descriptor is six context options and nothing else', async ({ page, browser }) => {
const fromTheRegistry = await measure(page);
const writtenOut = await browser.newContext({
userAgent: 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 '
+ '(KHTML, like Gecko) Chrome/151.0.7922.34 Mobile Safari/537.36',
viewport: { width: 412, height: 839 },
screen: { width: 412, height: 915 },
deviceScaleFactor: 2.625,
isMobile: true,
hasTouch: true,
});
const byHand = await measure(await writtenOut.newPage());
await writtenOut.close();
expect(byHand).toEqual(fromTheRegistry);
expect(fromTheRegistry.innerWidth).toBe(412);
expect(fromTheRegistry.devicePixelRatio).toBe(2.625);
expect(fromTheRegistry.pointerCoarse).toBe(true);
expect(fromTheRegistry.mobileNav).toBe('block');
});
Playwright 1.62 · TypeScript
It passes. The context spread from the registry and the context written out by hand agree on
layout width, device pixel ratio, screen width, pointer: coarse,
hover: none, the user-agent string and which of the two navigation elements the CSS
renders. There is no device anywhere in that list.
isMobile is the option that changes what a responsive assertion means, and the
documentation defines it as whether the meta viewport tag is taken into account and
touch events are enabled. Here is the first half of that, on a page that asks for a 980-pixel
layout inside a 412-pixel window:
import { test, expect } from '@playwright/test';
const asksForA980pxLayout = `<!doctype html>
<meta name="viewport" content="width=980">
<style>
#desktop-nav { display: block }
#mobile-nav { display: none }
@media (max-width: 700px) {
#desktop-nav { display: none }
#mobile-nav { display: block }
}
</style>
<nav id="desktop-nav">Desktop</nav>
<nav id="mobile-nav">Mobile</nav>`;
test('isMobile decides whether the meta viewport tag is read', async ({ browser }) => {
const rendered: Record<string, string> = {};
for (const isMobile of [false, true]) {
const context = await browser.newContext({
viewport: { width: 412, height: 839 },
isMobile,
});
const page = await context.newPage();
await page.setContent(asksForA980pxLayout);
rendered[`isMobile=${isMobile}`] = await page.evaluate(() =>
getComputedStyle(document.getElementById('mobile-nav')!).display === 'block'
? 'mobile nav'
: 'desktop nav');
await context.close();
}
expect(rendered).toEqual({
'isMobile=false': 'mobile nav',
'isMobile=true': 'desktop nav',
});
});
Playwright 1.62 · TypeScript
The same browser, the same window, the same stylesheet, and the flag decides which navigation the user gets. If your suite asserts on a mobile menu, that assertion is downstream of a boolean most people never set deliberately, because it arrives inside the descriptor.
The documentation puts both halves on isMobile. Set on their own against 1.62.1 they
came apart: a context with isMobile and nothing else read the meta tag and reported no
touch, and a context with hasTouch and nothing else did the reverse. Chromium, WebKit
and Firefox all behaved that way.
Two of those options have documented defaults. A context with no viewport set is 1280×720, and
deviceScaleFactor — which the options table says can be thought of as dpr — defaults to
1. A bare Chromium context here reported exactly those two numbers.
What is mobile emulation good for?
For a responsive web front end this catches most of what breaks, and it catches it on every commit.
Layout at a breakpoint. The 412-pixel context above resolves the same CSS a
412-pixel phone browser resolves, and the max-width: 700px branch in the sample is the
branch that runs. Width is an ordinary input to a layout engine, and this one is doing the same
arithmetic either way.
Touch-conditional behaviour. hasTouch flips
pointer: coarse and hover: none — measured true in all three engines on
1.62.1 — so the code paths behind those media queries are the ones under test. A hover menu with no
tap equivalent fails here, which is why you run it.
Anything the application itself decides from the viewport or the user agent. A mobile navigation, a different component tree, a server-side breakpoint keyed on the user-agent string: all exercised as they would be in the field, because the application cannot tell the difference.
Cost. One extra project in playwright.config.ts runs the mobile pass
in CI on machines you already pay for. A mobile-only regression caught on every commit is worth more
than a truer render checked once a month, and this is also where
screenshot comparison at a mobile viewport belongs,
with the caveat that the screenshot is of an emulated browser.
If layout coverage was the whole of what you needed, that is the answer, and the rest of this page is about a question you do not have.
Where does emulation stop being enough?
The network. The context sits on your CI network. The emulation guide documents
offline: true and stops there; neither that guide nor the network guide — both read to
their footer on this pass — documents bandwidth or latency shaping. Radio latency, packet loss and a
captive portal that half-answers are not modelled by a descriptor.
The hardware. The run is a CI vCPU with desktop memory behind it. A phone's CPU, its thermal behaviour and its GPU are not in the loop, so a timing number out of this suite is a fact about your runner.
The browser build on the phone. The engine under the emulation is the one Playwright downloaded, not the one the handset updates through its app store. The next section takes that one apart.
Gestures past the primitives. Touch input is available; a gesture vocabulary is
not handed to you. Playwright's page for this is titled Touch events (legacy), and its two
worked sections emulate a pan and a pinch by dispatching Touch points through
locator.dispatchEvent(). The same page notes that dispatchEvent() does not
set Event.isTrusted, so an application that checks it needs that check disabled for the
test. That is a fair statement of the ceiling: the primitives are there and the gestures are
something you assemble.
Everything the operating system draws. Safe-area insets on a notched screen, the URL bar collapsing as you scroll, the software keyboard shrinking the visual viewport, platform font substitution, native form-control chrome. None of them is a context option, so none of them is under test.
An emulated pass is evidence about your CSS and your JavaScript. It is not evidence about a handset, and that distinction has to survive being written into a coverage document. The other limits — load, Internet Explorer, the JavaScript-only test runner — are collected in the rest of what Playwright does not do.
Is an emulated iPhone the same as Safari on an iPhone?
No, and the documentation says why. Under its WebKit heading, the browsers guide says: "Playwright doesn't work with the branded version of Safari since it relies on patches. Instead, you can test using the most recent WebKit build." The patches are the mechanism: they make Playwright's build drivable and put the branded one out of reach.
Running that build is still worth doing. The same section says it is derived from the latest WebKit main-branch sources, often before those changes reach Apple Safari, so a WebKit project meets engine-level breakage early. The section also warns that features depending heavily on the underlying platform vary between operating systems, and that for the closest-to-Safari experience you should run WebKit on a Mac — which is itself a reason a WebKit job on Linux CI is not an iOS statement.
That sentence is about Safari specifically. Playwright reaches branded Google Chrome and Microsoft
Edge through the channel option, so do not read it as a rule about shipped browsers in
general.
In practical terms: if your coverage document has a Safari row and the evidence under it is a
project named webkit, rename the row. WebKit is what ran.
Can Playwright test on Android?
There is an Android API, it is documented, and its reference opens with its own scope: "Playwright has experimental support for Android automation. This includes Chrome for Android and Android WebView."
Chrome and WebView — a browser and a browser component — driven on hardware over ADB. The
requirements list wants an Android device or an AVD emulator, an ADB daemon running and
authenticated, Chrome 87 or newer installed, and "Enable command line on non-rooted devices"
switched on in chrome://flags. The known-limitations list says raw USB operation is not
supported yet so ADB is required, that the device has to be awake to produce screenshots, and, in
Microsoft's words, "We didn't run all the tests against the device, so not everything works."
The entry point is underscore-prefixed, which is the vendor's own signal about how settled it is,
so this page describes the scope instead of publishing a snippet somebody would build on. There is
no iOS counterpart on that surface either. The Android class reference is all the documentation has
for a platform; /docs/mobile 404s and nothing equivalent sits beside it.
What do you do when you need real devices?
Keep the emulated pass first. Layout, touch behaviour and mobile-only regressions in CI are cheap coverage, and they do not stop being worth having because they are not everything.
Above that sits a device cloud or a device lab, which is where browsers on hardware, network conditions with a radio in them and Safari as Apple ships it live. We are naming a category. This site has no relationship with a vendor in it to disclose, so it names none of them.
And if the thing under test is a native application at all, that is a native driver's job. Appium is the name of the category rather than a product we are pointing you at; naming it is a fact about the market, recommending one would be a claim nobody here has sourced.
That sets our own boundary too. We build and run Playwright suites, so the mobile coverage we deliver is mobile web: layout, touch behaviour and viewport-conditional code, running in CI. Native mobile app testing is not something Firm86 sells, and a Playwright suite is not the place it could be added. If the emulated pass is the part you want and there is nothing in the repository to run it yet, that is building the suite that runs this pass in CI.
Questions
Can Playwright test a native mobile app?
No. Playwright drives browsers, so a device descriptor covers your mobile web front end and never reaches a screen the operating system draws. A native iOS or Android application needs a native driver such as Appium. What Playwright covers takes the question in full.
Does devices['iPhone 13'] run Safari?
No. It configures a browser context with that device's parameters - user agent, viewport, screen size, scale factor, touch - and the engine underneath is Playwright's own WebKit build. The browsers documentation says Playwright does not work with the branded version of Safari because it relies on patches, so no configuration reaches the browser Apple ships.
Does mobile emulation work on Firefox?
Partly, and the part that matters is what you are allowed to claim. The browser.newContext() options table says isMobile defaults to false and is not supported in Firefox. On Playwright 1.62.1 we measured the opposite behaviour: a Firefox context with isMobile set read the meta viewport tag exactly as Chromium and WebKit did, in three separate runs. Take the documented sentence as the contract rather than the run, and keep Firefox out of a mobile coverage claim.
Can Playwright run tests on a real Android phone?
It can drive Chrome for Android and Android WebView on one, over ADB, through an API its own reference calls experimental. That is a browser on a phone rather than your application, and it is a different mechanism from the device emulation the rest of this page is about.
Which viewports does your suite claim to cover?
Send the projects array out of your playwright.config.ts, or the
coverage row somebody has already signed, and say which line of it is emulated. We will read it
back to you as what the config supports. If there is no suite there yet, that is the ordinary
starting point: we build end-to-end Playwright suites for teams that have none, or that started
one and stalled — the tests, the fixtures, the test data and the CI job that runs them on every
commit.