Responsive Design Testing Automation: Validate Viewports and Breakpoints

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">test

CI 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=html

Common 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.

Read more