Playwright visual regression testing: a working setup
Set up screenshot comparison in Playwright with toHaveScreenshot, stabilise the page so runs are deterministic, and wire baselines into CI without fighting the machine they were captured on.
Playwright ships visual comparison in the box, which makes it the cheapest way to try visual regression testing on a real project. The assertion is one line. Everything that follows in this guide exists because that one line is not the hard part — determinism is.
The minimum viable test
import { test, expect } from "@playwright/test";
test("pricing page has not changed", async ({ page }) => {
await page.goto("/pricing");
await expect(page).toHaveScreenshot("pricing.png");
});The first run writes pricing.png next to the spec and passes. Every later run compares against it. Delete the file to re-record, or run with --update-snapshots to refresh the ones that changed.
Configure the comparison once, globally
Per-test options are a maintenance trap. Set your tolerances in playwright.config.ts so every spec inherits them and you have one place to tune.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
expect: {
toHaveScreenshot: {
// Per-pixel colour tolerance (0–1). 0.2 absorbs antialiasing.
threshold: 0.2,
// Fail only when more than 1% of pixels differ.
maxDiffPixelRatio: 0.01,
// Stop CSS animations and freeze them at their end state.
animations: "disabled",
// Hide the caret so a focused input is not a diff.
caret: "hide",
scale: "css",
},
},
use: {
baseURL: "http://localhost:3000",
// Pin the rendering environment so screenshots are reproducible.
timezoneId: "UTC",
locale: "en-US",
colorScheme: "light",
},
projects: [
{ name: "desktop", use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 } } },
{ name: "mobile", use: { ...devices["iPhone 13"] } },
],
});Pinning timezoneId, locale, and colorScheme matters more than it looks. A test that renders a date passes locally in one timezone and fails in CI in another, and the diff tells you nothing useful about why.
Stabilising the page
Most Playwright visual flake comes from capturing a page that has not finished becoming itself. Four sources cover the overwhelming majority.
1. Fonts that load after first paint
A web font arriving after the screenshot shifts every line of text. Wait for the font set to be ready before capturing.
await page.goto("/pricing");
await page.evaluate(() => document.fonts.ready);
await expect(page).toHaveScreenshot("pricing.png");2. Animations and transitions
animations: "disabled" handles CSS animations and transitions. JavaScript-driven motion — a chart that eases in, a carousel, a count-up number — is not covered, so disable it at the source with a stylesheet or a query-string flag your app understands.
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
scroll-behavior: auto !important;
}`,
});3. Time, randomness, and live data
A relative timestamp reading 'a minute ago' is a guaranteed future failure. Freeze the clock with Playwright's clock API, seed any randomness, and stub the network responses that feed the page.
await page.clock.setFixedTime(new Date("2026-01-15T12:00:00Z"));
await page.route("**/api/metrics", (route) =>
route.fulfill({ json: { runs: 128, passRate: 0.97 } }),
);4. Regions that are meant to change
Anything genuinely dynamic that you cannot stub — a live counter, a user avatar, an embedded third-party widget — should be masked rather than fought.
await expect(page).toHaveScreenshot("dashboard.png", {
mask: [page.getByTestId("live-usage"), page.locator("iframe[title='Support']")],
maskColor: "#ff00ff",
});If you are masking more than a handful of regions per page, the page is telling you it is not a good visual-test target in its current form. Fixing flaky visual tests covers how to decide between stabilising, masking, and skipping.
The baseline problem in CI
This is where most Playwright visual setups fail, and it has nothing to do with your test code. Screenshots captured on macOS do not match screenshots captured on Linux. Font rendering, hinting, and emoji differ enough that a baseline recorded on a laptop fails immediately on a CI runner.
Playwright's answer is to name snapshots per platform, which it does automatically — you will see pricing-chromium-darwin.png and pricing-chromium-linux.png. That works, but it means committing two sets of images, and a developer updating a baseline locally does not update the one CI actually uses.
The practical fix is to record baselines in the same container CI uses:
docker run --rm --network host \
-v "$(pwd):/work" -w /work \
mcr.microsoft.com/playwright:v1.56.0-noble \
npx playwright test --update-snapshotsRepository growth
Baseline images are binary files that change often. A hundred routes across two viewports and two platforms is four hundred PNGs, re-committed on every intentional redesign. Repositories in the hundreds of megabytes are common, clones slow down, and reviewing a pull request that touches sixty images is not a review at all.
- Track baselines with Git LFS, or store them outside the repository entirely.
- Capture full-page screenshots sparingly — they are large and they diff on any content change above the fold.
- Prefer element-scoped captures for components:
expect(locator).toHaveScreenshot(). - Cap coverage by traffic, not by completeness.
When the hand-built suite stops paying for itself
Playwright's visual tooling is genuinely good, and for a stable design system with fifty component stories it may be all you need. The cost curve bends when coverage has to follow a real application: someone has to add a spec for every new route, keep authentication working, seed data, retune thresholds after a font change, and re-record four hundred images on Linux after a redesign.
At that point the question is no longer which assertion to use — it is whether authoring and maintaining a parallel description of your application is the best use of the team. How to evaluate visual regression testing tools walks through that decision.
Frequently asked questions
How do I do visual regression testing in Playwright?
Use the built-in await expect(page).toHaveScreenshot("name.png") assertion. The first run records a baseline image, and later runs compare against it. Set tolerances and animation handling globally in the expect.toHaveScreenshot block of playwright.config.ts.
Why do Playwright screenshots fail in CI but pass locally?
Font rendering and antialiasing differ between operating systems, so a baseline recorded on macOS will not match one rendered on a Linux CI runner. Record baselines inside the same Docker image CI uses, pinned to your Playwright version.
What is the difference between toHaveScreenshot and toMatchSnapshot?
toHaveScreenshot is purpose-built for visual comparison and retries until the rendered image is stable before diffing. toMatchSnapshot compares an arbitrary buffer once with no retry, so it is more prone to timing flake.
Referenced by
- Visual testing for engineering leaders
- Lost Pixel alternatives
- Vitest visual regression testing
- Visual regression testing in GitHub Actions
- Applitools alternatives
- Managing visual test baselines
- Meticulous alternatives
- Cross browser visual testing tools
- Free visual testing tools: what you actually get
- Open source visual regression testing tools
- Cypress visual regression testing
- Storybook visual regression testing in 2026
- Puppeteer visual regression testing
- Selenium visual testing
- Visual testing in CI/CD pipelines
- 10 visual regression testing tools, compared