Exit course
Chapter 5 of 10 Intermediate 7 min read 2 quick checks Updated

Full Page, Element and Component Snapshots

On this page
  1. Three scopes
  2. Choosing a scope
  3. Testing responsive breakpoints
  4. Viewport size is not device emulation
  5. Component snapshots
  6. Summary

What you put in the frame decides how useful a visual test is. A full-page screenshot catches everything, including every unrelated change anyone makes to that page. An element screenshot catches only what you pointed it at. This chapter covers the three scopes, how to choose between them, and how to test a layout at several screen widths without multiplying your maintenance work.

Three scopes#

Viewport is the default: await expect(page).toHaveScreenshot() captures what is visible in the browser window, 1280x720 with the Desktop Chrome preset. Good for "above the fold" checks of landing pages.

Full page captures the whole scrollable page:

test('full page', async ({ page }) => {
  await page.goto('https://the-internet.herokuapp.com/');
  await expect(page).toHaveScreenshot('internet-home-full.png', { fullPage: true });
});

With a 1280x720 viewport this produced a 1280x1605 image: the page's full height. Full-page screenshots catch layout problems far down the page, but they have two costs. Any change anywhere on the page, a new footer link, a longer product description, fails the test. And if content above a section changes height, everything below shifts and the whole lower half of the diff turns red, which hides the actual cause.

Element captures one locator's bounding box:

await expect(page.locator('.footer')).toHaveScreenshot('footer-one-item.png');

This gave a 550x42 image of just the TodoMVC footer. Element screenshots are small, fast to compare, and fail only when that element changes. Because the image is small, a pixel allowance (chapter 3) is also easy to reason about: 20 pixels of a 550x42 image is a tiny share; 20 pixels of a full page means nothing at all.

The same mask, stylePath, threshold and maxDiffPixels options work for all three.

The marketing team changes the newsletter text in the footer every week, and your full-page checkout test fails every week. What is the better test design?

A test should fail for the thing it is named after. Scoping the screenshot to the checkout form removes the unrelated noise without weakening the comparison. Weekly re-approvals train people to approve without looking, and a 10% allowance hides real checkout regressions.

Choosing a scope#

A rule of thumb that holds up well:

  • Element or component for most tests: a navigation bar, a pricing table, a form with its validation errors, a modal, a chart.
  • Viewport for a small number of key landing views where overall composition matters (hero section, first screen of the app).
  • Full page sparingly, for pages that are mostly static and where you genuinely want to know about any change, such as legal pages or a printed invoice layout.

Element screenshots also make reviews easier. When a test called pricing table fails, the reviewer knows what to look at before opening the diff.

One practical detail: the element must be visible and have a size. Assert the state you want first, then take the screenshot, as in this footer test:

test('footer component', async ({ page }) => {
  await page.goto('https://demo.playwright.dev/todomvc/');
  const input = page.getByPlaceholder('What needs to be done?');
  await input.fill('Buy milk');
  await input.press('Enter');
  await expect(page.getByTestId('todo-count')).toHaveText('1 item left');
  await expect(page.locator('.footer')).toHaveScreenshot('footer-one-item.png');
});

Testing responsive breakpoints#

Layouts change with width, and many visual bugs exist only at one size: a menu that wraps at 768 pixels, a table that overflows on phones. Run the same test at several viewports by generating a describe block per breakpoint:

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

const breakpoints = [
  { name: 'mobile', width: 375, height: 812 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1280, height: 800 },
];

for (const bp of breakpoints) {
  test.describe(`at ${bp.name} width`, () => {
    test.use({ viewport: { width: bp.width, height: bp.height } });

    test('todo app with items', async ({ page }) => {
      await page.goto('https://demo.playwright.dev/todomvc/');
      const input = page.getByPlaceholder('What needs to be done?');
      for (const item of ['Buy milk', 'Pay rent', 'Book the dentist']) {
        await input.fill(item);
        await input.press('Enter');
      }
      await page.getByRole('checkbox', { name: 'Toggle Todo' }).first().check();

      await expect(page.locator('.todoapp')).toHaveScreenshot(`todoapp-${bp.name}.png`);
    });
  });
}

Put the breakpoint in the snapshot name, so each width gets its own baseline file and a failure tells you which width broke.

When we ran this, the mobile image was 375x317 and both the tablet and desktop images were 550x287 and byte-for-byte identical. TodoMVC caps its app at 550 pixels wide, so 768 and 1280 render the same layout. The tablet test costs run time and review time and can never catch anything the desktop test would miss.

The lesson: take breakpoints from your CSS, not from a list of popular device sizes. Look at the @media queries in your stylesheets and test one width inside each range, plus the widths right at the edges if you have had bugs there before.

Your stylesheet has media queries at 640px and 1024px. Which set of viewport widths gives the most coverage for the fewest baselines?

Two breakpoints create three layouts, and one width per layout tests each of them. More widths inside the same range mostly produce duplicate baselines. Widths exactly on a breakpoint are useful edge checks, but alone they miss the layout in between.

Viewport size is not device emulation#

Setting viewport changes the window size only. Phones also differ in device pixel ratio, touch support and user agent, which some sites use to serve different markup. Playwright's device presets set all of these:

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

projects: [
  { name: 'iphone-13', use: { ...devices['iPhone 13'] } },
]

That is still a desktop browser engine pretending to be a phone. It catches most layout bugs, but not those caused by the real mobile browser, such as iOS Safari's address bar behaviour or a fonts difference on Android. Those need a real device or simulator, which a cloud grid provides (chapter 6). Keep scale: 'css' (the default) for emulated devices so the image stays at CSS size, as chapter 2 explained.

Component snapshots#

The most stable visual tests run on a component in isolation, outside the noisy app around it. If your team uses Storybook, every story already is an isolated component state with its own URL, iframe.html?id=<story-id>&viewMode=story, and Storybook publishes the list of stories as index.json. A small spec can turn that list into one visual test per story. This one runs against IBM's public Carbon Storybook; replace the URL with your own:

// tests/stories.spec.mts (.mts so the top-level await works)
import { test, expect } from '@playwright/test';

const STORYBOOK_URL = process.env.STORYBOOK_URL ?? 'https://react.carbondesignsystem.com';

const index = await (await fetch(`${STORYBOOK_URL}/index.json`)).json();
const stories = Object.values(index.entries as Record<string, { id: string; type: string }>)
  .filter((entry) => entry.type === 'story' && entry.id.startsWith('components-button--'))
  .slice(0, 4);

for (const story of stories) {
  test(`story ${story.id}`, async ({ page }) => {
    await page.goto(`${STORYBOOK_URL}/iframe.html?id=${story.id}&viewMode=story`);
    const root = page.locator('#storybook-root');
    await expect(root).not.toBeEmpty();
    await expect(root).toHaveScreenshot(`${story.id}.png`);
  });
}

The filter and slice keep the example to four button stories; drop them to cover every story. This ran green three times in a row (--repeat-each=3). Our Storybook testing guide shows the same approach on remote browsers and real devices, plus how to use the Storybook Test Runner.

Without Storybook, you can get similar isolation with page.setContent() for small pieces of markup (the button in chapter 3 is an example), or with a test-only route in your app that renders one component with fixed data.

Summary#

Default to the smallest frame that answers the question the test is named after. Add viewports per real breakpoint, not per device. And when a page is too dynamic to screenshot as a whole, test its components instead. The next chapter adds a dimension that multiplies everything here: browsers.