|

Responsive Breakpoint Testing in Playwright

Your app looks perfect on your 1440px laptop, then a user opens it on a 768px tablet and the navigation collapses into nothing, a pricing grid stacks into a broken column, and a “Buy” button slides off the right edge. Manually dragging the browser window to hunt for these breaks is slow and unrepeatable. This guide teaches Playwright responsive breakpoint testing end to end: how to drive real viewport sizes, assert what each breakpoint should show, parametrize across a device matrix, lock in visual baselines per width, and wire the whole thing into CI so a misplaced media query never ships again.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

What Responsive Breakpoint Testing Actually Verifies

A breakpoint is the viewport width at which your CSS changes layout — the min-width or max-width threshold in a media query where the mobile menu becomes a desktop bar, a sidebar appears, or a grid changes column count. Breakpoint testing confirms three things at and around each of those thresholds: the right elements are visible, the layout has the expected structure (column counts, stacking, wrapping), and nothing overflows the viewport horizontally.

Functional clicks rarely catch responsive bugs. A button reached by its accessible role works whether it is in a hamburger menu or a top bar, so getByRole stays green while the layout is visually broken. Responsive defects are about visibility and geometry, which means your assertions must look at what is shown, where it sits, and how wide it is — not just whether a control exists in the DOM.

The most important habit is to test the boundaries, not just comfortable middle widths. Bugs cluster at the exact pixel where a media query flips. If your breakpoint is 768px, you want a test at 767 (mobile side) and 768 (tablet side), because off-by-one errors in min-width vs max-width are one of the most common responsive defects.

Breakpoint bandTypical widthWhat should changeHow to assert in Playwright
Mobile320–480pxHamburger menu, single columntoBeVisible on menu toggle
Tablet768px2-column grid, condensed navCount grid children, check wrap
Laptop1024pxSidebar appears, full nav bartoBeHidden on hamburger
Desktop1280–1440px3–4 column grid, max-width containerAssert boundingBox width cap
Any widthNo horizontal scrollCompare scrollWidth to viewport

Setting the Viewport: setViewportSize vs Context vs Devices

Playwright gives you three ways to control viewport size, and choosing correctly keeps tests fast and honest. Use page.setViewportSize when you want to resize the same page across several widths inside one test — ideal for sweeping breakpoints. Use the context-level viewport option (or a project) when an entire spec should run at one fixed size. Use a device descriptor from playwright.devices when you also need the right user agent, device scale factor, and touch support, not just width and height.

Here is the simplest building block: resize a single page and assert the layout state at each width. Because setViewportSize triggers a real resize, your media queries and any resize listeners run exactly as they would for a user dragging the window.

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

test('navigation collapses on mobile, expands on desktop', async ({ page }) => {
  await page.goto('https://scrolltest.com/');

  // Mobile width: the hamburger toggle is shown, the full nav is hidden.
  await page.setViewportSize({ width: 375, height: 812 });
  await expect(page.getByRole('button', { name: 'Open menu' })).toBeVisible();
  await expect(page.getByRole('navigation', { name: 'Primary' })).toBeHidden();

  // Desktop width: the full nav is shown, the hamburger disappears.
  await page.setViewportSize({ width: 1280, height: 800 });
  await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
  await expect(page.getByRole('button', { name: 'Open menu' })).toBeHidden();
});

When a whole file should run at one device size — for example a suite of mobile-only flows — set it once with test.use rather than resizing in every test. Spreading a device descriptor pulls in the correct user agent and deviceScaleFactor, which matters when your app branches on touch capability or serves retina assets.

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

// Every test in this file runs as an iPhone 13: 390x844, touch, retina.
test.use({ ...devices['iPhone 13'] });

test('mobile menu opens on tap', async ({ page }) => {
  await page.goto('https://scrolltest.com/');
  await page.getByRole('button', { name: 'Open menu' }).tap();
  await expect(page.getByRole('navigation', { name: 'Primary' })).toBeVisible();
});

Parametrizing a Breakpoint Matrix

The real power of Playwright responsive breakpoint testing comes from data-driving the widths. Instead of copying assertions, define a table of named breakpoints with their expectations, then loop to generate one test per width. Each width becomes a separate, independently reported test, so when 768px fails you see exactly which band broke.

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

const breakpoints = [
  { name: 'mobile', width: 375, height: 812, columns: 1, hamburger: true },
  { name: 'tablet', width: 768, height: 1024, columns: 2, hamburger: true },
  { name: 'laptop', width: 1024, height: 768, columns: 3, hamburger: false },
  { name: 'desktop', width: 1440, height: 900, columns: 4, hamburger: false },
];

for (const bp of breakpoints) {
  test(`pricing grid layout at ${bp.name} (${bp.width}px)`, async ({ page }) => {
    await page.setViewportSize({ width: bp.width, height: bp.height });
    await page.goto('https://scrolltest.com/pricing');

    // The hamburger toggle should match the expected state for this band.
    const toggle = page.getByRole('button', { name: 'Open menu' });
    if (bp.hamburger) {
      await expect(toggle).toBeVisible();
    } else {
      await expect(toggle).toBeHidden();
    }

    // Read the rendered column count from the CSS grid template.
    const grid = page.locator('.pricing-grid');
    const columnCount = await grid.evaluate((el) => {
      const template = getComputedStyle(el).gridTemplateColumns;
      return template.split(' ').length;
    });
    expect(columnCount).toBe(bp.columns);
  });
}

Reading gridTemplateColumns and counting the tracks is a robust way to verify column count, because it reflects what the browser actually computed from your media queries — not what you assume the CSS does. If your layout uses flexbox wrapping instead of grid, count how many cards share the top row by comparing their bounding-box y values, which is shown later in this guide.

Testing the exact boundary pixel

Off-by-one breakpoint bugs hide at the threshold. If a media query reads @media (min-width: 768px), then 767px must still be mobile and 768px must be tablet. Assert both sides explicitly so a future change to max-width: 767px versus min-width: 768px cannot silently swap the behavior.

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

const BREAKPOINT = 768; // the tablet threshold under test

test('layout flips exactly at the 768px boundary', async ({ page }) => {
  await page.goto('https://scrolltest.com/');
  const sidebar = page.getByRole('complementary', { name: 'Filters' });

  // One pixel below the breakpoint: still the mobile layout.
  await page.setViewportSize({ width: BREAKPOINT - 1, height: 900 });
  await expect(sidebar).toBeHidden();

  // Exactly at the breakpoint: the tablet sidebar appears.
  await page.setViewportSize({ width: BREAKPOINT, height: 900 });
  await expect(sidebar).toBeVisible();
});

Catching Horizontal Overflow at Every Width

The single most common responsive bug is content wider than the viewport, producing a horizontal scrollbar and a “zoomed-out” feel on phones. A fixed-width image, an unbreakable long string, or a hard-coded pixel width is usually to blame. You can guard against all of them with one geometry check: the document’s scroll width must never exceed the viewport width.

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

const widths = [320, 375, 414, 768, 1024, 1280, 1440];

for (const width of widths) {
  test(`no horizontal overflow at ${width}px`, async ({ page }) => {
    await page.setViewportSize({ width, height: 900 });
    await page.goto('https://scrolltest.com/');

    const overflow = await page.evaluate(() => {
      const doc = document.documentElement;
      return {
        scrollWidth: doc.scrollWidth,
        clientWidth: doc.clientWidth,
      };
    });

    // A 1px tolerance absorbs sub-pixel rounding without hiding real bugs.
    expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth + 1);
  });
}

When that test fails, you usually want to know which element is the culprit. Extend the check to walk the DOM and report any node whose right edge spills past the viewport. Returning the offending selectors turns a vague “something overflows” failure into an actionable fix.

test('report elements that overflow the viewport at 375px', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto('https://scrolltest.com/');

  const offenders = await page.evaluate(() => {
    const vw = document.documentElement.clientWidth;
    const bad: string[] = [];
    for (const el of Array.from(document.querySelectorAll('*'))) {
      const rect = el.getBoundingClientRect();
      if (rect.right > vw + 1 || rect.left < -1) {
        const tag = el.tagName.toLowerCase();
        const cls = (el.className && typeof el.className === 'string')
          ? '.' + el.className.trim().split(/\s+/).join('.')
          : '';
        bad.push(tag + cls);
      }
    }
    return Array.from(new Set(bad)).slice(0, 10);
  });

  expect(offenders, `Overflowing elements: ${offenders.join(', ')}`).toHaveLength(0);
});

Asserting Layout Geometry: Columns, Stacking, and Wrapping

Beyond visibility and overflow, breakpoints change how elements arrange relative to each other. On mobile a row of feature cards should stack vertically; on desktop they should sit side by side. You can verify stacking deterministically by comparing bounding boxes: stacked cards share a similar x but differ in y, while side-by-side cards share a similar y but differ in x.

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

async function boxesOf(locator: Locator) {
  const count = await locator.count();
  const boxes = [];
  for (let i = 0; i < count; i++) {
    boxes.push(await locator.nth(i).boundingBox());
  }
  return boxes;
}

test('feature cards stack on mobile and align on desktop', async ({ page }) => {
  await page.goto('https://scrolltest.com/');
  const cards = page.locator('.feature-card');

  // Mobile: each card sits below the previous one (y increases, x roughly equal).
  await page.setViewportSize({ width: 375, height: 1200 });
  const mobile = await boxesOf(cards);
  expect(mobile[1]!.y).toBeGreaterThan(mobile[0]!.y + mobile[0]!.height - 1);
  expect(Math.abs(mobile[1]!.x - mobile[0]!.x)).toBeLessThan(2);

  // Desktop: the first two cards share a row (similar y, x increases).
  await page.setViewportSize({ width: 1280, height: 800 });
  const desktop = await boxesOf(cards);
  expect(Math.abs(desktop[1]!.y - desktop[0]!.y)).toBeLessThan(2);
  expect(desktop[1]!.x).toBeGreaterThan(desktop[0]!.x);
});

This bounding-box approach is far more resilient than asserting exact pixel coordinates. You are checking the relationship between elements — above/below versus left/right — so the test survives copy changes, padding tweaks, and minor restyles while still catching a layout that fails to stack or wrap.

Visual Regression Snapshots per Breakpoint

Some responsive bugs are purely visual: text that overlaps an icon at exactly 768px, a card with broken padding, a banner image that crops awkwardly on mobile. For these, Playwright’s built-in toHaveScreenshot assertion is the right tool. The key is to capture a separate baseline per width so each breakpoint has its own reference image that cannot be overwritten by another size.

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

const viewports = [
  { name: 'mobile', width: 375, height: 812 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1440, height: 900 },
];

for (const vp of viewports) {
  test(`homepage visual baseline (${vp.name})`, async ({ page }) => {
    await page.setViewportSize({ width: vp.width, height: vp.height });
    await page.goto('https://scrolltest.com/');

    // Freeze animation so the snapshot is deterministic across runs.
    await page.addStyleTag({
      content: `*, *::before, *::after {
        animation: none !important;
        transition: none !important;
      }`,
    });

    await expect(page).toHaveScreenshot(`home-${vp.name}.png`, {
      fullPage: true,
      maxDiffPixelRatio: 0.01,
    });
  });
}

Because the filename embeds the breakpoint name, the first run writes home-mobile.png, home-tablet.png, and home-desktop.png side by side. Generate or refresh every baseline with the update flag, then review each image by eye the first time — the initial render is exactly where a pre-existing responsive bug is likely to be hiding.

npx playwright test home.spec.ts --update-snapshots

Two caveats keep these snapshots stable. First, run visual tests in the official Playwright Docker image so font rendering matches between local and CI — antialiasing differs across operating systems and will flood your diffs with false positives. Second, mask or stub anything dynamic (carousels, ad slots, “time ago” labels) with the mask option on toHaveScreenshot so genuine content does not trip the comparison.

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Scaling Across Projects in playwright.config.ts

Hand-resizing in every test does not scale once you have dozens of specs. The clean approach is a Playwright project per device class, each pinned to a viewport, so the entire suite runs at multiple widths in one command. Projects also let you run a single class with --project=mobile when iterating locally and the full matrix in CI.

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.01 } },
  projects: [
    {
      name: 'mobile',
      use: { ...devices['iPhone 13'] }, // 390x844, touch, retina
    },
    {
      name: 'tablet',
      use: { viewport: { width: 768, height: 1024 }, isMobile: false },
    },
    {
      name: 'desktop',
      use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } },
    },
  ],
});

With projects defined, a plain spec that never touches setViewportSize automatically runs at each width. Use testInfo.project.name inside a test when you genuinely need branch logic for one device class, but prefer keeping assertions identical so the same behavior is verified everywhere.

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

test('hero CTA stays inside the viewport on every device', async ({ page }, testInfo) => {
  await page.goto('https://scrolltest.com/');

  const cta = page.getByRole('link', { name: 'Get started' });
  const box = await cta.boundingBox();
  const viewport = page.viewportSize()!;

  expect(box!.x).toBeGreaterThanOrEqual(0);
  expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width + 1);

  // Project name is available for device-specific expectations if ever needed.
  expect(['mobile', 'tablet', 'desktop']).toContain(testInfo.project.name);
});

Run a single class during development, or the whole matrix in CI, with the project flag. Each project reports independently, so a failure label tells you the exact device band that regressed.

# One device class while iterating, or all three in CI.
npx playwright test --project=mobile
npx playwright test --project=mobile --project=tablet --project=desktop

Best Practices for Playwright Responsive Breakpoint Testing

A maintainable suite is less about clever assertions and more about consistency and choosing the right level of strictness. Keep these principles in mind as coverage grows:

  • Test the boundaries, not just the middle. Add cases at breakpoint − 1 and the breakpoint itself to catch min-width versus max-width off-by-one bugs.
  • Set the viewport before goto when first paint matters. Resizing after navigation can briefly render the wrong layout and pollute a full-page screenshot.
  • Assert relationships, not exact pixels. Compare which row or column an element sits in, so tests survive copy and padding changes.
  • Guard horizontal overflow globally. One scrollWidth check per width catches a whole family of mobile bugs cheaply.
  • Keep one visual baseline per breakpoint and render snapshots in the Playwright Docker image so antialiasing stays consistent.
  • Prefer projects over per-test resizing once the suite grows, so widths live in config and tests stay focused on behavior.
  • Use device descriptors when touch or DPR matters, and a bare viewport when only width and height do — do not pay for emulation you do not need.

Conclusion

Playwright responsive breakpoint testing turns the slow, error-prone ritual of dragging a browser window into deterministic, CI-enforced checks. By driving real viewport sizes with setViewportSize, device descriptors, or projects, you can parametrize a breakpoint matrix, assert visibility and column counts at each band, prove the layout flips at the exact boundary pixel, guard against horizontal overflow at every width, and lock in per-breakpoint visual baselines for the bugs only an eye can catch. Start with one component — verify the hamburger appears below your tablet threshold and the full nav above it — then expand the matrix outward. Your users on phones, tablets, and ultrawide monitors will all get the layout you designed, and you will never again find out about a broken breakpoint from a support ticket.

FAQ

Should I use setViewportSize or device descriptors for breakpoint testing?

Use page.setViewportSize when you want to sweep several widths inside one test or when only width and height matter — it is the lightest way to drive a breakpoint matrix. Use a device descriptor from playwright.devices (spread into test.use or a project) when you also need the correct user agent, deviceScaleFactor, and touch support, such as testing retina images or tap-only interactions. For a whole suite running at fixed sizes, a project per device class is cleanest because the widths live in config instead of every test.

How do I detect horizontal overflow on mobile with Playwright?

Compare the document’s scrollWidth to its clientWidth via page.evaluate after setting a narrow viewport, and assert scrollWidth <= clientWidth + 1 to allow for sub-pixel rounding. If it fails, walk the DOM with getBoundingClientRect and collect any element whose right edge exceeds the viewport width, then surface those selectors in the assertion message. That turns a vague overflow failure into a list of exact culprit elements you can fix.

Why do my responsive screenshot tests fail in CI but pass locally?

Almost always it is font antialiasing and rendering differences between your operating system and the CI machine, which produce tiny per-pixel diffs across the whole image. Run visual tests inside the official Playwright Docker image so rendering is identical everywhere, disable animations and transitions with page.addStyleTag before capturing, mask dynamic regions with the mask option, and set a small maxDiffPixelRatio tolerance. Keep one baseline file per breakpoint so widths never overwrite each other.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.