level 17 / responsive-breakpoints

Responsive Breakpoint Testing

Test responsive layouts across breakpoints — visibility rules, layout shifts, and overflow detection.

What Responsive Tests Verify

Responsive tests check that the layout adapts correctly across screen sizes:

  • Elements hidden/shown at correct breakpoints (mobile menu vs nav bar)
  • No horizontal scroll at any breakpoint
  • Content doesn’t overflow its container
  • Images resize correctly (no distortion, no overflow)
  • Touch targets are large enough (WCAG: min 44×44 CSS px)

Testing Visibility at Breakpoints

import { test, expect } from '@playwright/test';

test.describe('Navigation', () => {
  test('desktop shows nav links, hides hamburger', async ({ page }) => {
    await page.setViewportSize({ width: 1280, height: 800 });
    await page.goto('/');

    await expect(page.getByRole('navigation').getByRole('link')).toHaveCount(5);
    await expect(page.getByRole('button', { name: 'Menu' })).not.toBeVisible();
  });

  test('mobile shows hamburger, hides nav links', async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto('/');

    await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible();
    // Nav links hidden until menu opens
    await expect(page.getByRole('navigation').getByRole('link').first()).not.toBeVisible();
  });
});

Testing for Horizontal Overflow

Horizontal scrollbars on mobile are a common layout bug. Detect programmatically:

test('no horizontal overflow at mobile viewport', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('/');

  // document.documentElement.scrollWidth > viewport width = horizontal overflow
  const hasHorizontalOverflow = await page.evaluate(() => {
    return document.documentElement.scrollWidth > document.documentElement.clientWidth;
  });

  expect(hasHorizontalOverflow).toBe(false);
});

// Check every page in your route list
const ROUTES = ['/', '/products', '/checkout', '/about'];

for (const route of ROUTES) {
  test(`no overflow on ${route} at 375px`, async ({ page }) => {
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto(route);
    await page.waitForLoadState('networkidle');

    const overflow = await page.evaluate(() =>
      document.documentElement.scrollWidth > document.documentElement.clientWidth
    );
    expect(overflow, `Horizontal overflow on ${route}`).toBe(false);
  });
}

Testing Touch Target Size

WCAG 2.5.5 (Level AAA) and 2.5.8 (Level AA in WCAG 2.2) require minimum touch target sizes:

test('interactive elements have adequate touch targets', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('/');

  const interactiveElements = page.locator('button, a, [role="button"], input, select');
  const count = await interactiveElements.count();

  const violations: string[] = [];
  for (let i = 0; i < count; i++) {
    const el = interactiveElements.nth(i);
    const box = await el.boundingBox();
    if (!box) continue;

    // WCAG 2.5.8: 24×24 minimum, 44×44 recommended
    if (box.width < 24 || box.height < 24) {
      const text = await el.textContent();
      violations.push(`"${text?.trim()}" ${Math.round(box.width)}×${Math.round(box.height)}px`);
    }
  }

  expect(violations, `Touch targets too small:\n${violations.join('\n')}`).toEqual([]);
});

Testing Image Responsiveness

test('images do not overflow their containers', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('/products');

  const images = page.locator('img');
  const count = await images.count();

  for (let i = 0; i < count; i++) {
    const img = images.nth(i);
    const imgBox = await img.boundingBox();
    const parentBox = await img.evaluate(el => el.parentElement?.getBoundingClientRect());

    if (imgBox && parentBox) {
      expect(imgBox.width).toBeLessThanOrEqual(parentBox.width + 1); // +1 for rounding
    }
  }
});

Parameterised Breakpoint Tests

// Test a behaviour at multiple breakpoints in one spec
const breakpoints = [
  { name: 'mobile', width: 375 },
  { name: 'tablet', width: 768 },
  { name: 'desktop', width: 1280 },
] as const;

for (const { name, width } of breakpoints) {
  test(`footer layout at ${name} (${width}px)`, async ({ page }) => {
    await page.setViewportSize({ width, height: 800 });
    await page.goto('/');

    const footer = page.locator('footer');
    await expect(footer).toBeVisible();

    // Footer columns stack on mobile, row on desktop
    const columns = footer.locator('.footer-column');
    const firstBox = await columns.first().boundingBox();
    const secondBox = await columns.nth(1).boundingBox();

    if (width < 768) {
      // Stacked: second column below first
      expect(secondBox!.y).toBeGreaterThan(firstBox!.y + firstBox!.height - 10);
    } else {
      // Side by side: same vertical position
      expect(Math.abs(secondBox!.y - firstBox!.y)).toBeLessThan(10);
    }
  });
}