Responsive Design Testing Automation: Validate Viewports and Breakpoints
Responsive design testing is one of the most neglected areas of web QA. Teams manually resize their browser window during development, assume it works, and ship. Then users report that the navigation is broken on iPad, the checkout button is hidden on iPhone SE, or text overflows its container on ultrawide displays.
Automating viewport and breakpoint testing catches these issues before they reach production, without manual browser resizing.
Setting Up Viewport Testing in Playwright
Playwright makes viewport testing straightforward. You can set the viewport size per-test, per-project, or use one of Playwright's built-in device profiles:
import { test, expect } from '@playwright/test';
// Set viewport for a specific test
test('homepage layout on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 }); // iPhone 12
await page.goto('/');
await expect(page.locator('nav[data-testid="main-nav"]')).toBeVisible();
});
// Use device profiles
test('iPad layout', async ({ browser }) => {
const context = await browser.newContext({
...devices['iPad Pro'],
});
const page = await context.newPage();
await page.goto('/');
// Test iPad-specific layout
});Defining Your Breakpoint Matrix
Start by defining the viewports that matter for your application. A typical modern breakpoint matrix:
// viewports.ts
export const VIEWPORTS = {
mobileSm: { width: 320, height: 568 }, // iPhone SE (old)
mobileMd: { width: 375, height: 812 }, // iPhone 12/13
mobileLg: { width: 414, height: 896 }, // iPhone 11 Pro Max
tablet: { width: 768, height: 1024 }, // iPad portrait
tabletLg: { width: 1024, height: 768 }, // iPad landscape
desktop: { width: 1280, height: 800 }, // Common laptop
desktopLg: { width: 1440, height: 900 }, // Common desktop
ultrawide: { width: 2560, height: 1440 }, // 2K/4K
} as const;
export type ViewportName = keyof typeof VIEWPORTS;Parametrized Viewport Tests
Run the same tests across all viewports:
import { test, expect } from '@playwright/test';
import { VIEWPORTS } from './viewports';
Object.entries(VIEWPORTS).forEach(([viewportName, size]) => {
test(`navigation visible on ${viewportName} (${size.width}x${size.height})`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto('/');
// On mobile, hamburger should be visible; desktop nav should be hidden
if (size.width < 768) {
await expect(page.locator('[data-testid="hamburger-button"]')).toBeVisible();
await expect(page.locator('[data-testid="desktop-nav"]')).not.toBeVisible();
} else {
await expect(page.locator('[data-testid="desktop-nav"]')).toBeVisible();
await expect(page.locator('[data-testid="hamburger-button"]')).not.toBeVisible();
}
});
});Testing Breakpoint-Specific Behavior
Beyond visibility, test that interactive elements work correctly at each breakpoint:
test.describe('mobile navigation', () => {
test.use({ viewport: { width: 375, height: 812 } });
test('hamburger opens and closes menu', async ({ page }) => {
await page.goto('/');
// Menu should be closed initially
await expect(page.locator('[data-testid="mobile-menu"]')).not.toBeVisible();
// Open menu
await page.click('[data-testid="hamburger-button"]');
await expect(page.locator('[data-testid="mobile-menu"]')).toBeVisible();
// Menu items should be clickable
await expect(page.locator('[data-testid="mobile-menu"] a')).toHaveCount({ min: 3 });
// Close by clicking hamburger again
await page.click('[data-testid="hamburger-button"]');
await expect(page.locator('[data-testid="mobile-menu"]')).not.toBeVisible();
});
test('touch targets are large enough', async ({ page }) => {
await page.goto('/');
// All interactive elements should be at least 44x44px (Apple HIG / WCAG guideline)
const buttons = await page.locator('button, a[href], [role="button"]').all();
for (const button of buttons) {
const box = await button.boundingBox();
if (!box) continue;
const isTooSmall = box.width < 44 || box.height < 44;
if (isTooSmall) {
const text = await button.textContent();
console.warn(`Touch target too small: "${text}" (${box.width}x${box.height}px)`);
}
}
});
});Detecting Layout Overflow
Horizontal scrollbars on mobile are almost always unintentional. Detect them automatically:
async function detectHorizontalOverflow(page: any): Promise<string[]> {
return page.evaluate(() => {
const overflowing: string[] = [];
const docWidth = document.documentElement.clientWidth;
document.querySelectorAll('*').forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.right > docWidth) {
const selector = el.tagName.toLowerCase() +
(el.id ? `#${el.id}` : '') +
(el.className ? `.${el.className.split(' ').join('.')}` : '');
overflowing.push(`${selector} (right: ${rect.right.toFixed(0)}px, doc: ${docWidth}px)`);
}
});
return [...new Set(overflowing)]; // Deduplicate
});
}
const PAGES_TO_CHECK = ['/', '/pricing', '/features', '/contact'];
PAGES_TO_CHECK.forEach(path => {
test(`no horizontal overflow on mobile: ${path}`, async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto(path);
await page.waitForLoadState('networkidle');
const overflowing = await detectHorizontalOverflow(page);
expect(overflowing, `Overflowing elements on ${path} at 375px:\n${overflowing.join('\n')}`).toHaveLength(0);
});
});Testing Typography at Breakpoints
Text that's readable on desktop can be too small on mobile or too large on ultrawide:
async function checkFontSizes(page: any, minSize: number, maxSize: number) {
return page.evaluate(({ min, max }: { min: number; max: number }) => {
const issues: string[] = [];
document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, span').forEach(el => {
const style = window.getComputedStyle(el);
const fontSize = parseFloat(style.fontSize);
const text = el.textContent?.trim().substring(0, 30);
if (!text) return;
if (fontSize < min) {
issues.push(`Too small (${fontSize}px): "${text}"`);
}
if (fontSize > max) {
issues.push(`Too large (${fontSize}px): "${text}"`);
}
});
return issues;
}, { min: minSize, max: maxSize });
}
test('typography is readable on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/');
const issues = await checkFontSizes(page, 14, 48); // Min 14px, max 48px on mobile
expect(issues, `Font size issues on mobile:\n${issues.join('\n')}`).toHaveLength(0);
});Visual Regression Across Viewports
Combine viewport testing with screenshot comparison for a visual regression baseline:
const CRITICAL_PAGES = ['/', '/pricing', '/features'];
const CRITICAL_VIEWPORTS = {
mobile: { width: 375, height: 812 },
tablet: { width: 768, height: 1024 },
desktop: { width: 1280, height: 800 },
};
Object.entries(CRITICAL_VIEWPORTS).forEach(([viewportName, size]) => {
CRITICAL_PAGES.forEach(path => {
test(`visual: ${path} on ${viewportName}`, async ({ page }) => {
await page.setViewportSize(size);
await page.goto(path);
await page.waitForLoadState('networkidle');
// Wait for fonts and images
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot(`${viewportName}-${path.replace(/\//g, '-')}.png`, {
fullPage: true,
animations: 'disabled',
});
});
});
});Using Playwright Projects for Viewport Coverage
Configure viewport-based projects in playwright.config.ts for clean separation:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
// Desktop
{
name: 'desktop',
use: { viewport: { width: 1280, height: 800 } },
},
// Tablet
{
name: 'tablet',
use: { viewport: { width: 768, height: 1024 } },
},
// Mobile — using real device profiles
{
name: 'mobile-ios',
use: { ...devices['iPhone 14'] },
},
{
name: 'mobile-android',
use: { ...devices['Pixel 7'] },
},
// Small mobile (important for accessibility)
{
name: 'mobile-small',
use: { viewport: { width: 320, height: 568 } },
},
],
});Run specific viewport groups:
# Test only mobile viewports
npx playwright <span class="hljs-built_in">test --project=mobile-ios --project=mobile-android --project=mobile-small
<span class="hljs-comment"># Test all viewports
npx playwright <span class="hljs-built_in">testCI Strategy: Which Viewports to Test When
Testing all viewports on every commit is expensive. Use a tiered strategy:
# .github/workflows/test.yml
jobs:
# Run on every PR — fast, essential viewports only
smoke-test:
steps:
- run: npx playwright test --project=desktop --project=mobile-ios
# Run nightly — full viewport coverage
full-viewport:
if: github.event_name == 'schedule'
steps:
- run: npx playwright test # All projects
# Run before releases — full coverage + visual regression
release-test:
if: startsWith(github.ref, 'refs/tags/')
steps:
- run: npx playwright test --reporter=htmlCommon Responsive Failures to Test For
Build specific tests for the most common responsive failures:
test('images have explicit dimensions (prevent layout shift)', async ({ page }) => {
await page.goto('/');
const imagesWithoutDimensions = await page.evaluate(() => {
return Array.from(document.querySelectorAll('img'))
.filter(img => !img.width || !img.height)
.map(img => img.src.split('/').pop());
});
expect(imagesWithoutDimensions,
`Images without explicit dimensions (causes layout shift): ${imagesWithoutDimensions.join(', ')}`
).toHaveLength(0);
});
test('tables are scrollable on mobile (not cut off)', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/pricing');
const tables = await page.locator('table').all();
for (const table of tables) {
const parent = await table.evaluateHandle(el => el.parentElement);
const overflow = await page.evaluate(el =>
window.getComputedStyle(el).overflowX,
parent
);
// Tables that overflow should be in a scrollable container
const tableBox = await table.boundingBox();
const docWidth = page.viewportSize()!.width;
if (tableBox && tableBox.width > docWidth) {
expect(['auto', 'scroll'],
`Table wider than viewport (${tableBox.width}px) but parent is not scrollable`
).toContain(overflow);
}
}
});
test('forms are usable on mobile keyboard', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 568 }); // Small phone
await page.goto('/contact');
// Focus input — keyboard should appear, form should not be hidden
await page.focus('input[name="email"]');
// After keyboard appears, submit button should still be reachable
await expect(page.locator('[type="submit"]')).toBeVisible();
});Measuring Responsive Testing Coverage
Track which breakpoints and pages you've covered:
// Generate a coverage report
const coverage: Record<string, Record<string, boolean>> = {};
CRITICAL_PAGES.forEach(page => {
coverage[page] = {};
Object.keys(CRITICAL_VIEWPORTS).forEach(viewport => {
coverage[page][viewport] = false; // Mark as tested when test runs
});
});Responsive design testing automation is not about testing every possible pixel width — it's about testing the critical breakpoints where your layout changes behavior. Define those breakpoints, write tests that verify the right elements appear and work correctly at each one, add overflow and visual regression checks, and run them in CI. Your users on mobile devices will notice immediately when you break something; your automated tests should notice first.