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:
- 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.
- 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.
- 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.
| Option | What it does | Default |
|---|---|---|
| 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:
- macOS, Windows and Linux render text differently. Fonts, hinting and anti-aliasing differ, so a golden written on a laptop rarely matches a Linux CI runner. That is why the OS is in the file name.
- Docker does not fully fix it. A Mac with Apple silicon runs the arm64 build of a Linux image, and GitHub's standard runners are x64. Issue #13873, "Visual testing in docker on different CPU architecture", reports differences between the two.
- The comparison is noisy at the edges. Issue #24312, "The problem with visual comparison", describes rendering noise failing a small share of runs.
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:
- Repository weight. Every update adds a new copy of every changed PNG to git history.
- Merge conflicts. Two branches that both update one golden conflict on a binary file that git cannot merge.
- Stale files. Renaming or deleting a test leaves its goldens behind. --update-snapshots does not remove them. Playwright closed the request to make it remove them, #16582, as not planned in August 2026.
- Review happens in a file diff. Approving a change means rerunning with --update-snapshots and committing the result. Reviewers see binary files changed, not a before and after.
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:
- Grades every PNG against the version it replaces. A pull request is compared with the channel of the branch it merges into, such as @main, and falls back to @latest. Both sides came from your CI, so a laptop never takes part.
- Publishes to the pull request's own channel, @pr-12, and comments on the pull request: how many screenshots changed, were added or stayed the same, with up to four of them shown, changed ones first. See Pull-request comments.
- Moves @latest only from the default branch. Merging is how a change is accepted. The next pull request is compared with what you merged. There is no --update-snapshots step and no PNG to commit. See How @latest moves.
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:
- Without failAbove, a changed screenshot does not fail the job. A person reads the comment and decides. There is no per-screenshot approve button that updates a baseline.
- Screenery grades only PNGs. A JPEG or a video counts as changed or unchanged by its bytes alone.
- A pull request from a fork gets no OIDC token from GitHub, so it publishes nothing. Its screenshots publish once it merges. See Fork pull requests.
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:
- Chromatic's Playwright guide captures an archive of the page during each test and uploads it to Chromatic's cloud. Its snapshots are interactive, and it has a review app for people who do not read code.
- Argos's Playwright comparison picks baselines from your git history in its cloud, and turns each visual change into a check you approve on the pull request. It also says plain toHaveScreenshot is enough for a solo project or one that must run offline.
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
- Playwright screenshots in GitHub Actions: a complete workflow, and artifacts compared with published URLs.
- playwright --update-snapshots: what each mode does, the wrong-OS trap, and workflows that avoid it.
- Getting started: create a project and push your first screenshots.