Screenery

Home / Guides / Playwright visual testing

Playwright visual regression testing, from toHaveScreenshot to CI

Playwright can compare a page with a stored screenshot and fail the test when the pixels change. The assertion is the easy part. The stored screenshots are where teams get stuck, and this guide covers both.

toHaveScreenshot basics

Visual testing in Playwright is one assertion. It needs no plugin and no account:

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

test('dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page).toHaveScreenshot('dashboard.png');
});

This guide calls the stored image a golden. Playwright's docs say snapshot or expected screenshot. Here is what happens on each run:

  1. First run. There is no golden yet. Playwright takes screenshots until two in a row are identical, writes the last one as the golden, and fails the test, so a new golden never passes silently.
  2. Every later run. Playwright waits for two identical screenshots again, then compares the last one with the golden. Too many changed pixels fail the test. The HTML report shows the expected image, the actual image and a diff.
  3. An intended change. Rerun with --update-snapshots to overwrite the golden, then commit it. That step has its own traps. See playwright --update-snapshots.

Goldens live in a {test file}-snapshots/ folder next to the test. Their names include the Playwright project and the operating system, for example dashboard-chromium-linux.png. The OS in that name explains most of the trouble below.

expect(locator).toHaveScreenshot() works the same way for one element. It is often steadier than a full page, because less of the page can change.

Thresholds and masks

These options go on one assertion, or under expect.toHaveScreenshot in playwright.config.ts for every assertion. Defaults are from the PageAssertions reference, checked October 2026.

OptionWhat it doesDefault
threshold How different one pixel's colour may be before it counts as changed, from 0 to 1. 0.2
maxDiffPixels How many pixels may change before the test fails. unset
maxDiffPixelRatio The same limit as a share of all pixels, from 0 to 1. unset
mask Locators to cover with a solid box before the capture. none, drawn in #FF00FF
stylePath A CSS file applied during the capture, to hide or freeze parts of the page. none
animations Stops CSS animations and transitions during the capture. "disabled"
fullPage Captures the whole scrollable page, not just the viewport. false

Mask what changes by itself, such as clocks, avatars and relative dates. Set tolerances once for the whole suite:

await expect(page).toHaveScreenshot('dashboard.png', {
  fullPage: true,
  mask: [page.getByTestId('last-synced'), page.locator('.avatar')],
});
// playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.001,
      stylePath: './tests/screenshot.css',
    },
  },
});
/* tests/screenshot.css: applied only while Playwright captures */
iframe,
.live-chart {
  visibility: hidden;
}

Be careful with tolerances. A limit loose enough to absorb font smoothing on another machine is also loose enough to pass a changed word in a button. A mask or a stylesheet removes one known source of noise. A higher threshold hides every small change, wanted or not.

Why committed goldens break across machines

Playwright's visual comparisons docs say that browser rendering varies with the host OS, its version and settings, the hardware and headless mode. A golden is only valid on the machine type that wrote it. In practice:

On 2026-05-12 Playwright's maintainers closed both issues as not planned. Goldens that differ by OS, fonts and CPU are part of how toHaveScreenshot works, and that will not change soon.

On #13873, a maintainer said the tolerances needed to cover those differences "would make the tests much less useful". Committed goldens bring other costs too:

Fix the environment first

Whatever you do with the goldens, make one environment the only one that writes them. Playwright's CI docs recommend running in its Docker image for a consistent environment. Pin the tag to the same version as your @playwright/test:

jobs:
  test:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.64.0-noble
      options: --user 1001

Then write and update goldens only in that image, never on a laptop's own browsers. The update-snapshots guide has two ways to do that.

Baselines outside git, with Screenery

Screenery stores the screenshots your CI takes and serves each one at a stable URL. It runs after your tests, as one step in GitHub Actions, and signs in with GitHub's OIDC token, so there is no secret to store. It can work with your goldens or replace them. Pick one of three setups.

1. Keep toHaveScreenshot, and publish the goldens

Your assertion stays the gate. Screenery publishes the goldens it accepted, so each one gets a URL you can put in a README:

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: tests
    playwright-snapshots: true

Screenery publishes only files in -snapshots folders and skips fixture images. Playwright writes your goldens as before, so the section above still applies. Details are on Playwright goldens and Argos folders.

2. Grade changes instead of failing on any pixel

Screenery's own gate compares your screenshots with a baseline folder before anything is published. It grades each changed region minor, moderate or major. A minor change is a region that moved up to 8 pixels or a slight shade change. You choose which grade fails the job:

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: screenshots
    diff-baseline: ./screenery-baseline
// screenery.config.json
{
  "diff": {
    "failOn": "moderate"
  }
}

The baseline is still a committed folder. The gain is that small rendering noise can pass while edited text or a recoloured button still fails. A failing gate pushes nothing. See The visual-diff gate.

3. No committed PNGs at all

Take plain screenshots and let the previous build be the baseline. Capture with page.screenshot() into a folder of your own:

test('dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await page.screenshot({
    path: 'screenshots/dashboard/home.png',
    fullPage: true,
    animations: 'disabled',
    mask: [page.getByTestId('last-synced')],
  });
});

page.screenshot() does not wait for a stable page the way the assertion does, and it lets animations run by default. Wait for something visible first, and pass animations: 'disabled'. Then push the folder on every pull request and every default-branch build:

permissions:
  id-token: write
  contents: read
  pull-requests: write
steps:
  # … check out, install, run your Playwright tests …
  - uses: screenery/push@v1
    with:
      project: acme/dashboard
      path: screenshots

Here is what each push does with no baseline folder:

To warn about big changes, or fail on them, set a percentage of changed pixels:

// screenery.config.json
{
  "diff": {
    "alertAbove": 5,
    "failAbove": 40
  }
}

alertAbove adds a warning to the job and a "Big changes" list at the top of the pull-request comment. failAbove fails the push before anything is published. Both are off by default. See Graded changes.

Know the limits before you choose this setup:

The full workflow, with installs, the Playwright report and the screenshot step, is in Playwright screenshots in GitHub Actions.

Other hosted tools

Screenery is not the only way to move baselines out of git. Two hosted tools publish Playwright guides of their own. Both were checked October 2026:

Both have a stronger review flow than Screenery's comment and console. What Screenery adds is a URL for every accepted screenshot, such as https://cdn.screenery.dev/acme/dashboard/dashboard/home@latest.png, that updates itself on the next default-branch build. The same captures that guard your UI can sit in your README and docs. See Screenshot URLs.

Next