Before any comparison logic, Playwright takes screenshots on demand. page.screenshot captures the current viewport:
await page.screenshot({ path: 'dashboard.png' });
// The whole scrollable page, not just the viewport
await page.screenshot({ path: 'dashboard-full.png', fullPage: true });
// A specific region
await page.screenshot({
path: 'chart.png',
clip: { x: 0, y: 200, width: 800, height: 400 },
});For a single element, call screenshot on its locator. Playwright waits for the element, scrolls it into view, and crops to its bounding box:
await page.getByTestId('uptime-chart').screenshot({ path: 'chart.png' });These are useful for debugging and documentation, but on their own they assert nothing. A test that only takes screenshots passes no matter what the page looks like.
Two places matter. Screenshots you take with an explicit path go wherever you point them. Screenshots Playwright takes for you go into test-results/, alongside traces and videos, and get attached to the HTML report. The common configuration captures one automatically when a test fails:
// playwright.config.ts
export default defineConfig({
use: {
screenshot: 'only-on-failure',
},
});With that setting, every failure comes with a picture of the page at the moment things went wrong, which shortens debugging more than almost any other config option.
expect(page).toHaveScreenshot() turns screenshots into assertions:
test('pricing page looks right', async ({ page }) => {
await page.goto('/pricing');
await expect(page).toHaveScreenshot('pricing.png');
});The first run has no baseline, so Playwright writes one and fails the test with a message saying the snapshot was missing and has been created. Commit that image after reviewing it. From then on, each run captures a fresh screenshot and compares it against the baseline pixel by pixel. Like other web-first assertions, it retries: Playwright takes screenshots until two consecutive captures match, which absorbs late renders, then compares against the baseline.
When the comparison fails, the report attaches three images, expected, actual, and a diff with changed pixels highlighted, so reviewing a failure takes seconds.
Baselines live next to the test file in a <test-file>-snapshots/ folder, and each file name carries the browser and platform:
tests/pricing.spec.ts-snapshots/
pricing-chromium-darwin.png
pricing-chromium-linux.pngThe platform suffix exists because rendering differs across operating systems: fonts, antialiasing, and subpixel decisions all shift between macOS and Linux. A baseline captured on your Mac will not match a capture from a Linux CI runner, and no tolerance setting fixes that cleanly.
The practical policy is to let CI own the baselines. Generate them in the same environment that runs the comparisons, usually the official Playwright Docker image, and treat locally generated Linux baselines from that same image as the source of truth. After an intentional UI change, refresh them:
npx playwright test --update-snapshotsReview updated baselines in the pull request like code. An unreviewed --update-snapshots run can quietly bless a genuine regression.
Three options control how different two images may be before the assertion fails:
await expect(page).toHaveScreenshot('pricing.png', {
maxDiffPixels: 100, // absolute count of differing pixels
maxDiffPixelRatio: 0.01, // fraction of total pixels, here 1%
threshold: 0.2, // per-pixel color distance, 0 to 1
});threshold decides when an individual pixel counts as different, using color distance in the YIQ space; the default of 0.2 tolerates minor antialiasing shifts. maxDiffPixels and maxDiffPixelRatio decide how many such pixels the whole image may contain, and both default to zero.
Start with the defaults. If a component flakes on antialiasing noise, allow a small budget, maxDiffPixels: 100 or a ratio of 0.01, rather than raising threshold, which blunts the comparison everywhere. Suite-wide defaults belong in config:
export default defineConfig({
expect: {
toHaveScreenshot: { maxDiffPixels: 100 },
},
});Timestamps, avatars, ads, and live counters change on every run, and a visual test that includes them fails forever. The mask option covers them with a solid overlay before capture and comparison:
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.getByTestId('last-updated'),
page.getByRole('img', { name: 'User avatar' }),
],
});Masked regions render as pink boxes in both baseline and actual, so they can never differ. When hiding is better done with CSS, point stylePath at a stylesheet that Playwright injects before the capture:
/* screenshot.css */
.ad-slot, .intercom-launcher { visibility: hidden; }await expect(page).toHaveScreenshot({ stylePath: './screenshot.css' });toHaveScreenshot ships with flake-reducing defaults. CSS animations and transitions are handled as if animations: 'disabled' were set: finite animations fast-forward to their end state, infinite ones are stopped. The text caret stops blinking thanks to caret: 'hide'. Both defaults exist because a capture taken mid-animation or mid-blink differs from the baseline through no fault of your UI.
What the defaults do not cover is JavaScript-driven movement, carousels on timers, charts that animate with requestAnimationFrame, videos. Pause or mask those yourself, and make sure the page has settled before asserting; the waits guide covers how Playwright decides a page is stable.
A full-page screenshot asserts everything, which means anything can fail it. A copy tweak in the footer breaks the baseline for a test that was guarding the checkout form. Scoped comparisons keep the blast radius small:
// Anti-pattern: one giant baseline for the whole page
await expect(page).toHaveScreenshot('checkout-page.png', { fullPage: true });
// Recommended: assert the component you actually care about
await expect(page.getByTestId('payment-summary')).toHaveScreenshot('payment-summary.png');Element screenshots also dodge whole categories of noise: scrollbar rendering, lazy-loaded images below the fold, and cookie banners never enter the frame. Reserve full-page baselines for pages that are genuinely static, like marketing or docs pages, and scope everything else to stable components.
Visual comparison is the only automated way to catch a broken layout, an invisible button, a dark-mode theme with unreadable text, or a CSS refactor that shifted everything two pixels left. Functional assertions pass through all of those, because the DOM is intact even though the page looks wrong. Pages worth the investment include design-system components, dark and light themes across emulated mobile viewports, and marketing pages where appearance is the product.
The cost is maintenance: baselines to store, updates to review, tolerance to tune. When the thing you care about is text or state, toHaveText and toBeVisible check it with none of that overhead and a far clearer failure message. A visual test that exists to verify a heading says the right words is paying screenshot prices for a text assertion's job.
Aria snapshots sit between the two. toMatchAriaSnapshot compares the accessibility tree as a YAML template, so it catches structural regressions, a button that disappeared, a heading demoted to a div, without any pixels involved:
await expect(page.getByRole('banner')).toMatchAriaSnapshot(`
- heading "Hyperping" [level=1]
- navigation:
- link "Pricing"
- link "Docs"
`);It survives styling changes that break screenshots and rendering differences between machines, which makes it a cheap first line of defense for structure.
Screenshots keep working after the tests ship. Hyperping Browser Checks run Playwright scripts against your live site on a schedule and capture the page when a check fails, so an alert arrives with visual evidence of what your users were seeing at that moment.