This recipe shows the smallest useful mobile-viewport Playwright test against QA Shop.
It applies the iPhone-15 device descriptor at the file level, lands on the home page, and asserts the mobile drawer hamburger replaces the desktop navigation. The descriptor handles the viewport, user agent, device scale factor, and touch capability together — the five axes that together convince an app it is running on a real phone.
Mobile emulation with devices[…] is the right tool when you want to verify responsive behavior, touch-only UI, or layout breakpoints without provisioning a real iOS or Android device.
§ 01 · THE TEST
The full spec
Save this file as tests/recipes/playwright/mobile-viewport-responsive.spec.ts and run it with npx playwright test --grep mobile-viewport-responsive.
// tests/recipes/playwright/mobile-viewport-responsive.spec.ts
import { test, expect, devices } from "@playwright/test";
test.use({ ...devices["iPhone 15"] });
test("mobile drawer replaces the desktop nav on an iPhone viewport", async ({ page }) => {
// 1. Land on the home page in mobile emulation mode.
await page.goto("/");
// 2. The hamburger trigger is mobile-only (CSS class `show-mobile`); on
// the iPhone 15 viewport (390 x 844) it must be visible.
const trigger = page.getByTestId("mobile-menu-trigger");
await expect(trigger).toBeVisible();
// 3. Tap to open the drawer; the SheetContent slides in from the left.
await trigger.click();
await expect(page.getByTestId("mobile-menu-sheet")).toBeVisible();
});Three details deserve a callout.
test.use({ ...devices["iPhone 15"] }) lives at the top of the file, not inside the test body. Playwright reads test.use during fixture resolution — calling it inside a test throws at runtime. The spread is required because the descriptor is a plain object, and test.use accepts a partial test options object.
The visibility assertion runs before the click. Playwright's auto-wait will retry the click on an invisible element until the action timeout, then fail with a generic "element is not visible" error 30 seconds later. The explicit toBeVisible() short-circuits the wait and fails immediately if the responsive CSS class show-mobile ever regresses1.
The drawer assertion uses the testid on SheetContent itself, not a child element. Shadcn's Sheet portals SheetContent outside the trigger's DOM tree, so a relative selector from the trigger would not find it. The testid mobile-menu-sheet is set on the portal root and is the canonical handle for this drawer.
§ 02 · WHY DEVICE EMULATION
Why device emulation, not just viewport
A common shortcut is test.use({ viewport: { width: 390, height: 844 } }). It looks equivalent — the iPhone 15 has a 390 x 844 viewport. It is not.
The descriptor sends a Safari-on-iOS user agent. Bare viewport keeps the test's headless Chromium UA. UA-sniffing analytics, A/B targeting, or feature flags will see a desktop client on the bare viewport and fire the wrong branch.
@media (hover: none) and (pointer: coarse) only match when these are true. Hover-activated UI (FABs, tooltips, reveal-on-hover patterns) keeps its desktop behavior on a bare viewport. Frontend rules on this codebase explicitly require @media (hover: none) fallbacks — those branches stay untested without the descriptor.
iPhone 15 is 3. Bare viewport is 1. Screenshots and layout-shift measurements diverge by 9x in pixel area, which matters the moment you add visual regression on top of this spec.
§ 03 · FAQ
FAQ
Common questions when extending this pattern to other devices and breakpoints.
Wrap each viewport in test.describe and call test.use({ ...devices['Pixel 7'] }) inside the describe block. Playwright scopes test.use to the enclosing block. Add test.describe.configure({ mode: 'parallel' }) for independent execution per device.
The mobile-menu-trigger element is always in the DOM but carries className="show-mobile", hiding it above the md breakpoint (768 px). getByTestId resolves; toBeVisible() fails. Resize the window or use a mobile device descriptor.
Emulation covers viewport, UA, touch, and DPR. It does NOT reproduce iOS Safari quirks (smooth-scroll inertia, 100vh address-bar drift, WebKit-only CSS bugs). For those, run against a BrowserStack or Sauce Labs real-device farm — the spec body stays identical, only the Playwright project config changes.
Related
For the cart-and-checkout starter test that exercises real network traffic end-to-end, see the add-to-cart Playwright recipe. For the broader Playwright vs Selenium vs Cypress picture, see the QA Shop testing guide.
The Selenium, Cypress, and WebdriverIO mobile-viewport counterparts are coming soon — placeholder slugs are /recipes/selenium/mobile-viewport-responsive, /recipes/cypress/mobile-viewport-responsive, and /recipes/webdriverio/mobile-viewport-responsive.
Footnotes
-
Playwright toBeVisible — https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible (verified 2026-05-03, Playwright 1.58) ↩
Frequently asked questions
Last verified: