Every browser on iOS, including Chrome, runs on WebKit under the hood: a CSS bug in Safari affects all iOS browsers, and a test that passes on Android Chrome says nothing about iOS behavior. Most web teams cover 80% of mobile testing value with Playwright's built-in device emulation without a physical device lab, since it sets viewport size, user agent, pixel ratio, and touch support in one configuration line. This guide covers responsive layout verification, network throttling via CDP, the tap target size and iOS input zoom bugs that consistently escape desktop testing, and when real device services like BrowserStack are worth the cost.
Mobile Testing Challenges
Device fragmentation: Android runs on thousands of different devices with different screen sizes, OS versions, and hardware. iOS is more controlled but still has multiple screen sizes and versions. Touch interactions: Mobile users tap, swipe, pinch, and rotate. Desktop mouse-click tests don't cover these. Network conditions: Mobile users often have slow, unreliable connections. 3G, 4G, spotty wifi — your app needs to handle these gracefully. Performance constraints: Mobile devices have less RAM, slower CPUs, and smaller batteries than desktops. What's fast on desktop may be slow on mobile. Platform differences: iOS and Android handle things differently — notifications, deep links, file sharing, camera access, push notifications. The iOS WebKit issue: ALL browsers on iOS use WebKit under the hood. Chrome on iPhone is not Chrome — it's Safari's engine in a Chrome wrapper. Bugs in WebKit affect all iOS browsers.Types of Mobile Testing
Responsive Web Testing
Testing your web app in mobile viewports — no app install needed.
// Playwright — test mobile viewport
test('login page works on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 }); // iPhone SE
await page.goto('/login');
// Mobile-specific UI should appear
await expect(page.getByTestId('mobile-header')).toBeVisible();
await expect(page.getByTestId('desktop-nav')).not.toBeVisible();
// Core functionality works
await page.fill('[data-testid="email"]', 'user@test.com');
await page.fill('[data-testid="password"]', 'ValidPass1');
await page.tap('[data-testid="submit"]'); // tap instead of click
await expect(page).toHaveURL('/dashboard');
});Native App Testing
Apps installed from App Store / Google Play. Requires Appium, Detox (React Native), or XCUITest/Espresso directly.
Progressive Web App (PWA) Testing
Web apps that behave like native apps — installable, work offline, send push notifications.
Playwright for Mobile Web Testing
Playwright's device emulation covers the most important mobile testing scenarios for web apps:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Desktop
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
// Mobile
{ name: 'mobile-chrome', use: { ...devices['Pixel 7'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 14'] } },
{ name: 'tablet', use: { ...devices['iPad Pro'] } },
],
});Available devices (see npx playwright show-devices):
iPhone 14,iPhone 14 Pro,iPhone SEPixel 7,Galaxy S21iPad Pro,iPad Mini
Device emulation sets:
- Viewport size
- User agent string
- Device pixel ratio
- Touch event support
Touch Events vs Mouse Events
Mobile users tap, not click. Playwright's click() works for both, but sometimes you need explicit touch:
test('swipe carousel works', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/products');
const carousel = page.getByTestId('product-carousel');
const box = await carousel.boundingBox();
if (box) {
// Simulate swipe left (finger moves left to right)
await page.touchscreen.tap(box.x + box.width - 50, box.y + box.height / 2);
await page.mouse.move(box.x + box.width - 50, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + 50, box.y + box.height / 2, { steps: 20 });
await page.mouse.up();
}
// Verify next slide is visible
await expect(page.getByTestId('slide-2')).toBeVisible();
});Testing Responsive Layouts
Verify that UI adapts correctly at different breakpoints:
const viewports = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'mobile-large', width: 414, height: 896 },
{ name: 'tablet', width: 768, height: 1024 },
{ name: 'desktop', width: 1280, height: 720 },
];
for (const viewport of viewports) {
test(`navigation renders correctly at ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await page.goto('/');
if (viewport.width < 768) {
// Mobile: hamburger menu
await expect(page.getByTestId('hamburger')).toBeVisible();
await expect(page.getByTestId('desktop-nav')).not.toBeVisible();
} else {
// Desktop/tablet: full nav
await expect(page.getByTestId('hamburger')).not.toBeVisible();
await expect(page.getByTestId('desktop-nav')).toBeVisible();
}
});
}Network Throttling
Test how your app performs on slow mobile networks:
test('app loads on slow 3G', async ({ page, context }) => {
// Throttle to 3G speeds
const cdpSession = await context.newCDPSession(page);
await cdpSession.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: 780 * 1024 / 8, // 780 kbps 3G download
uploadThroughput: 330 * 1024 / 8, // 330 kbps 3G upload
latency: 100, // 100ms latency
});
const startTime = Date.now();
await page.goto('/');
const loadTime = Date.now() - startTime;
// Page should load within 10 seconds on 3G
expect(loadTime).toBeLessThan(10000);
// Loading state should be shown while waiting
// (verify by checking if a loading skeleton or spinner was present)
await expect(page.getByTestId('main-content')).toBeVisible();
});
test('app works offline (PWA)', async ({ page, context }) => {
await page.goto('/');
// Go offline
await context.setOffline(true);
await page.reload();
// PWA should show cached content or offline page
await expect(page.getByTestId('offline-message')).toBeVisible();
// OR
await expect(page.getByTestId('main-content')).toBeVisible(); // Cached content
// Go back online
await context.setOffline(false);
});Real Device Testing with BrowserStack
Emulation covers most scenarios. For real devices:
- BrowserStack — real iOS and Android devices in the cloud
- Sauce Labs — similar offering
- Firebase Test Lab — Google's device farm (Android-focused)
// Playwright config for BrowserStack real devices
export default defineConfig({
projects: [
{
name: 'real-iphone-14',
use: {
connectOptions: {
wsEndpoint: `wss://cdp.browserstack.com/playwright?caps=${encodeURIComponent(JSON.stringify({
'browserstack.user': process.env.BS_USER,
'browserstack.key': process.env.BS_KEY,
'browserName': 'safari',
'bstack:options': {
deviceName: 'iPhone 14',
osVersion: '16',
}
}))}`,
},
},
},
],
});Common Mobile-Specific Bugs to Test
Tap target size: Buttons too small to tap reliably (less than 44×44px on iOS, 48×48dp on Android)test('all interactive elements are large enough to tap', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
const buttons = await page.locator('button, a, [role="button"]').all();
for (const button of buttons) {
const box = await button.boundingBox();
if (box) {
expect(box.width, `Button too narrow: ${await button.textContent()}`).toBeGreaterThanOrEqual(44);
expect(box.height, `Button too short: ${await button.textContent()}`).toBeGreaterThanOrEqual(44);
}
}
});test('no horizontal scrolling on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/');
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBe(clientWidth); // No horizontal overflow
});font-size: 16px minimum.
Mobile Testing Checklist
Before every release:
- [ ] Core flows tested on iPhone (Safari) and Android (Chrome)
- [ ] Responsive layout verified at 375px, 768px, 1280px
- [ ] Touch targets are at least 44×44px
- [ ] No horizontal scroll on mobile viewports
- [ ] Forms work with mobile keyboards
- [ ] Loading states shown on slow connections
- [ ] Images have proper sizes for mobile (not desktop-sized images)
- [ ] Text is readable without zooming (minimum 16px)
Summary
| Testing type | Tool | When to use |
|-------------|------|------------|
| Responsive web | Playwright viewport | Every sprint |
| Cross-browser mobile | Playwright devices preset | Before release |
| Network throttling | Chrome DevTools Protocol in Playwright | Mobile-heavy features |
| Real devices | BrowserStack, Sauce Labs | Before major releases |
| Native apps | Appium, Detox | Separate native app project |
Most web apps need responsive web testing (80% of mobile testing value) plus occasional real device checks for iOS Safari behavior. Save real device farms for pre-release validation and known platform-specific bugs.
→ See also: Mobile Emulation in Playwright: Responsive and Touch Testing | Cross-Browser Testing Strategies: When and How to Test Multiple Browsers | Accessibility Testing with Playwright: Automated a11y Checks