Mobile Viewport Testing Beyond Simple Resizing: Touch, Gestures, and Device Emulation
The classic QA mistake: drag a browser window to 375 pixels wide, see the layout reflow, check "mobile testing: done." Then users on iPhones report that the dropdown menu doesn't close on tap, the swipe-to-dismiss gesture conflicts with scroll, and the floating action button is hidden under the iOS home indicator.
None of these would be caught by window resizing. Real mobile QA requires testing actual touch interactions, device-specific quirks, and the full constraint set of mobile browsers. Playwright makes this accessible without requiring physical devices for most scenarios.
What Desktop Viewport Resizing Misses
| Aspect | Window Resize | Device Emulation |
|---|---|---|
| Screen pixel density (DPR) | ❌ Uses desktop DPR | ✅ 2×, 3× correctly emulated |
| Touch events vs mouse events | ❌ Mouse events only | ✅ Touch events fired |
| Mobile browser chrome (address bar) | ❌ Not present | ✅ Viewport height accounting |
| iOS safe area insets | ❌ Not applied | ✅ Applies notch/home bar insets |
| User-Agent string | ❌ Desktop UA | ✅ Mobile UA (affects feature detection) |
| Hover states | ❌ Still triggered | ✅ No hover on touchscreens |
pointer-events media query |
❌ Shows coarse | ✅ pointer: coarse correctly set |
Device Emulation in Playwright
Playwright ships with a device descriptor library matching real-world devices:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Desktop browsers
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
// Mobile devices
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 15'] },
},
{
name: 'mobile-safari-plus',
use: { ...devices['iPhone 15 Plus'] }, // Larger screen
},
{
name: 'tablet',
use: { ...devices['iPad Pro 11'] },
},
],
});
You can inspect what each device descriptor sets:
import { devices } from '@playwright/test';
console.log(devices['iPhone 15']);
// {
// userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...',
// viewport: { width: 393, height: 852 },
// deviceScaleFactor: 3,
// isMobile: true,
// hasTouch: true,
// defaultBrowserType: 'webkit',
// }
Testing Touch Interactions
Tap vs Click
Mouse clicks fire mousedown → mouseup → click. Touch fires touchstart → touchend → click (with a 300ms delay historically, removed in modern mobile Chrome). Test that your touch targets work with actual touch events:
// tests/mobile/touch.test.ts
import { test, expect, devices } from '@playwright/test';
test.use({ ...devices['iPhone 15'] });
test('dropdown opens and closes on touch', async ({ page }) => {
await page.goto('/');
// Tap the nav menu button
const menuButton = page.locator('[data-testid="mobile-menu-btn"]');
await menuButton.tap(); // Uses touch event, not click
await expect(page.locator('[data-testid="nav-dropdown"]')).toBeVisible();
// Tap outside should close dropdown
await page.locator('main').tap();
await expect(page.locator('[data-testid="nav-dropdown"]')).not.toBeVisible();
});
test('touch targets meet minimum 44×44px requirement', async ({ page }) => {
await page.goto('/');
const touchTargets = await page.locator('[data-testid^="touch-target"]').all();
for (const target of touchTargets) {
const box = await target.boundingBox();
if (!box) continue;
expect(box.width, `Touch target too narrow: ${await target.textContent()}`).toBeGreaterThanOrEqual(44);
expect(box.height, `Touch target too short`).toBeGreaterThanOrEqual(44);
}
});
Swipe Gestures
test('swipeable carousel advances on swipe', async ({ page }) => {
await page.goto('/products/featured');
const carousel = page.locator('[data-testid="product-carousel"]');
const startSlide = await page.locator('[data-testid="carousel-current"]').textContent();
// Get carousel bounds
const box = await carousel.boundingBox();
if (!box) throw new Error('Carousel not found');
const centerY = box.y + box.height / 2;
const startX = box.x + box.width * 0.8; // Start near right
const endX = box.x + box.width * 0.2; // Swipe to left
// Simulate swipe-left gesture
await page.touchscreen.tap(startX, centerY);
await page.mouse.move(startX, centerY);
await page.mouse.down();
await page.mouse.move(endX, centerY, { steps: 10 }); // Smooth swipe
await page.mouse.up();
// Alternative using Playwright's native touch API
// await page.touchscreen.move(startX, centerY, endX, centerY);
const nextSlide = await page.locator('[data-testid="carousel-current"]').textContent();
expect(nextSlide).not.toBe(startSlide);
});
iOS Safe Area and Notch Testing
iOS devices with notches and home indicators inset content using CSS environment variables. Test that your fixed elements don't overlap system UI:
test('bottom navigation is not hidden under iOS home indicator', async ({ page }) => {
await page.goto('/dashboard');
const bottomNav = page.locator('[data-testid="bottom-navigation"]');
const box = await bottomNav.boundingBox();
if (!box) throw new Error('Bottom nav not found');
// Get viewport dimensions
const viewport = page.viewportSize()!;
// The bottom of the nav element should not extend off-screen
// Safe area bottom for iPhone 15 is ~34px
expect(box.y + box.height).toBeLessThanOrEqual(viewport.height);
// Verify the CSS uses env(safe-area-inset-bottom) via computed style
const paddingBottom = await bottomNav.evaluate((el) => getComputedStyle(el).paddingBottom);
// Should have padding that accounts for safe area
expect(parseFloat(paddingBottom)).toBeGreaterThan(0);
});
Check that CSS is configured correctly:
/* Always specify viewport-fit=cover AND use safe-area-inset */
/* In your global CSS: */
.bottom-navigation {
padding-bottom: max(16px, env(safe-area-inset-bottom));
}
.fixed-header {
padding-top: env(safe-area-inset-top);
}
Input Handling on Mobile
Mobile keyboards, date pickers, and numeric inputs behave differently from desktop:
test('numeric input shows numeric keyboard on mobile', async ({ page }) => {
await page.goto('/checkout');
const quantityInput = page.locator('[data-testid="quantity-input"]');
// Check that the input type triggers numeric keyboard
const inputType = (await quantityInput.getAttribute('inputmode')) ?? (await quantityInput.getAttribute('type'));
// Should use inputmode="numeric" or type="number"
expect(['numeric', 'number', 'tel']).toContain(inputType);
});
test('date fields use native date picker on mobile', async ({ page }) => {
await page.goto('/account/edit');
const birthdateField = page.locator('[data-testid="birthdate-input"]');
const type = await birthdateField.getAttribute('type');
// Native date input on mobile opens the OS date picker
// Better than custom JS datepicker widgets
expect(type).toBe('date');
});
Responsive Breakpoint Snapshot Testing
Catch layout issues across all breakpoints with visual regression tests:
// tests/mobile/visual-regression.test.ts
const VIEWPORTS = [
{ name: 'mobile-sm', width: 320, height: 568 }, // iPhone SE
{ name: 'mobile-lg', width: 393, height: 852 }, // iPhone 15
{ name: 'tablet', width: 768, height: 1024 }, // iPad
{ name: 'desktop', width: 1280, height: 800 },
];
for (const viewport of VIEWPORTS) {
test(`homepage renders correctly at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
await page.waitForLoadState('networkidle');
// Hide dynamic content that changes between runs
await page.addStyleTag({
content: '[data-testid="live-chat"], [data-testid="timestamp"] { visibility: hidden !important; }',
});
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`, {
fullPage: false, // Test viewport, not full page
threshold: 0.02, // 2% pixel difference tolerance
});
});
}
Related articles: Also see Playwright full mobile emulation capabilities beyond viewport changes, PWA offline testing as a critical part of mobile quality assurance, and a cross-browser and cross-device strategy for complete mobile coverage.
Mobile Performance Considerations
Mobile CPUs are slower. A JavaScript bundle that performs fine on desktop can feel sluggish on mobile. Test with CPU throttling:
test('dashboard interaction is responsive on mid-range mobile', async ({ page, browser }) => {
const context = await browser.newContext({
...devices['Pixel 7'],
});
const cdpSession = await context.newCDPSession(await context.newPage());
// Apply 4× CPU throttling (simulates mid-range Android)
await cdpSession.send('Emulation.setCPUThrottlingRate', { rate: 4 });
const page2 = await context.newPage();
await page2.goto('/dashboard');
const startTime = Date.now();
await page2.click('[data-testid="load-report-btn"]');
await page2.waitForSelector('[data-testid="report-table"]');
const duration = Date.now() - startTime;
// Interaction should complete within 3s even on throttled CPU
expect(duration).toBeLessThan(3000);
await context.close();
});
True mobile testing requires device emulation, touch event simulation, safe area handling, and performance profiling under realistic mobile constraints. Playwright's device emulation makes all of this reproducible and CI-friendly.
Add mobile layout testing to your QA pipeline: Try ScanlyApp free and run automated checks across mobile, tablet, and desktop viewports on every deploy.
