· Tutorials · By Simon Chiu

Playwright toHaveScreenshot: Visual Tests That Hold

Set up Playwright visual comparisons with toHaveScreenshot: thresholds that mean something, OS font drift, Docker baselines and CI artifacts. Measured diffs.

toHaveScreenshot takes a screenshot, compares it with a stored baseline image and fails the test when they differ:

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

test('pricing page', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('pricing.png', { fullPage: true });
});

The first run writes the baseline. Every run after that compares. That part takes five minutes. Making the test stable across laptops and CI is the hard part, and it is what this post is about: what the tolerance options measure, why a baseline from macOS fails on Linux (we measured by how much), and a Docker-plus-CI setup where the same image makes and checks every baseline. It is part of our Playwright screenshot guide.

Tested on 2026-10-18 with Node.js 22.18.0, @playwright/[email protected] (Chromium 156.0.8078.4), on macOS (Apple M1 Pro) and in the mcr.microsoft.com/playwright:v1.64.1-noble Docker image (linux/arm64). The GitHub Actions workflow at the end was not run; everything else was.

The test project

We used one small static page: a heading, a timestamp written by JavaScript, three pricing cards and a spinning loader. It has the three things that break visual tests in real apps: text (fonts), time and animation. The config:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 0,
      animations: 'disabled',
      caret: 'hide',
    },
  },
  reporter: [['html', { open: 'never' }]],
  use: {
    baseURL: 'http://localhost:4174',
    ...devices['Desktop Chrome'],
  },
  webServer: {
    command: 'npx http-server site -p 4174 --silent',
    url: 'http://localhost:4174',
    reuseExistingServer: !process.env.CI,
  },
});

And the tests:

// tests/pricing.spec.ts
import { test, expect } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-10-18T09:00:00Z'));
});

test('pricing page', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('pricing.png', { fullPage: true });
});

test('enterprise card', async ({ page }) => {
  await page.goto('/');
  await expect(page.getByTestId('enterprise')).toHaveScreenshot('enterprise-card.png');
});
npm i -D @playwright/[email protected] [email protected]
npx playwright test --update-snapshots   # write baselines
npx playwright test                      # compare

Four choices in that config matter more than they look:

  • snapshotPathTemplate without {platform}. The default template adds the OS to the file name: our first run wrote pricing-darwin.png. That hides cross-OS drift by giving each OS its own baseline, so a Mac developer and CI never compare against the same file. We dropped {platform} on purpose, so there is one baseline per test, and then made sure only one OS ever writes it (see the Docker section).
  • maxDiffPixels: 0. Strict on purpose. Once the environment is fixed, the same build renders the same pixels, and any tolerance only hides real changes. We justify that with numbers below.
  • animations: 'disabled' fast-forwards finite CSS animations and transitions to their end state and cancels infinite ones, like our spinner. caret: 'hide' removes the blinking text cursor. Both are already the defaults for toHaveScreenshot; we set them in the config so the intent is visible in code review.
  • page.clock.setFixedTime freezes Date, so the timestamp text is the same on every run.

What the tolerance options measure

There are two different questions, and Playwright has separate options for each:

Option Question it answers Range Default
threshold Is this one pixel different? Color distance in YIQ space 0 (strict) to 1 (lax) 0.2
maxDiffPixels How many different pixels may the image have? Integer Not set (0 allowed)
maxDiffPixelRatio The same, as a share of all pixels 0 to 1 Not set

threshold is applied first, per pixel. The pixel counts are applied to what is left. A common mistake is to raise threshold to fix a flaky test. Our measurements show why that does not work: when the cause is a different font engine, the diff pixels are glyph edges in different places, and a threshold high enough to ignore them also ignores changed text.

Measured: macOS baseline vs Linux run

We wrote baselines on macOS, then ran the same tests unchanged in the Playwright Docker image. The page, the browser version and the test code were identical. Only the OS and its fonts changed.

Run Full page (1280 x 720) Enterprise card (270 x 209)
macOS baseline, macOS run, x3 0 pixels different, pass 0 pixels different, pass
macOS baseline, Linux Docker run, threshold: 0.2 4,458 pixels different (0.48%) 1,258 pixels different (2.2%)
macOS baseline, Linux Docker run, maxDiffPixelRatio: 0.01 Pass Fail
Linux baseline, Linux Docker run, x3 0 pixels different, pass 0 pixels different, pass
Linux baseline, macOS run, threshold: 0.2 4,458 pixels different 1,258 pixels different
Linux baseline, macOS run, threshold: 0.5 3,795 pixels different 1,139 pixels different
Linux baseline, macOS run, threshold: 0.9 0 counted, pass 0 counted, pass

Then we checked what threshold: 0.9 costs. We changed one price on the page from $29 to $39 and ran the Linux check in Docker against the Linux baseline:

Real change: $29 becomes $39 Full page Enterprise card (unchanged)
threshold: 0.2, maxDiffPixels: 0 Fail, 101 pixels different Pass
threshold: 0.9, maxDiffPixels: 0 Pass: the regression was missed Pass

Three conclusions:

  1. threshold does not separate drift from change. At 0.5 it removed about 15% of the font diff. At 0.9 it removed all of it, and it also let a wrong price through. The font difference is structural, so a color tolerance large enough to hide it is large enough to hide real text changes.
  2. A ratio hides real problems on large images. With maxDiffPixelRatio: 0.01, the full page passed although every line of text on it was rendered differently, because 4,458 pixels is under 1% of 921,600. The small card, with the same kind of change, failed. One ratio cannot be right for both sizes.
  3. In a fixed environment, the diff is exactly zero. Three runs on each OS against its own baseline gave 0 different pixels. That is why maxDiffPixels: 0 is safe once the environment is pinned, and why the environment is the thing to fix.

The flake we found: a masked timestamp

Our first version masked the timestamp instead of freezing the clock:

await expect(page).toHaveScreenshot('pricing.png', {
  fullPage: true,
  mask: [page.locator('#updated')],
});

On the same Mac, with no code change, the next run failed with 72 different pixels. The mask paints a box over the element's bounding box, and the box width depends on the text. 2026-10-18T03:09:41.123Z and 2026-10-18T03:11:07.456Z have different widths in a proportional font, so the mask edge moved. Use mask for content with a fixed size (an avatar, a map, an ad slot). For text that changes length, remove the cause: freeze time with page.clock, mock the API response with page.route, or hide the element with a stylesheet.

A screenshot-only stylesheet

stylePath applies CSS only while Playwright takes the screenshot. It does not change the page for the rest of the test:

await expect(page).toHaveScreenshot('pricing.png', {
  stylePath: './tests/screenshot.css',
});
/* tests/screenshot.css */
.spinner,
[data-testid="live-chat"] { visibility: hidden !important; }

visibility: hidden keeps the element's space, so the layout does not shift. Three runs with this file and the frozen clock gave 0 different pixels. You can also set stylePath once under expect.toHaveScreenshot in the config.

Make baselines in Docker

The fix for OS drift is not a tolerance, it is one rendering environment. Run every update and every check inside the same image, pinned to the same Playwright version as your package.json:

docker run --rm --ipc=host -e CI=1 \
  -v "$PWD":/work -w /work \
  mcr.microsoft.com/playwright:v1.64.1-noble \
  npx playwright test --update-snapshots

Then commit the PNGs. Developers run the same command without --update-snapshots to check. Notes from our runs:

  • Pin the image tag to the exact @playwright/test version. A different Playwright version ships a different Chromium build, which can move pixels by itself.
  • --ipc=host gives Chromium enough shared memory. Without it, Chromium can crash on large pages.
  • CPU architecture. Our Mac ran the arm64 variant of the image, and GitHub's standard runners are amd64. Our zero-diff results were within one architecture. If you mix architectures, generate the baselines on the architecture CI uses, for example with docker run --platform linux/amd64.
  • The mounted node_modules worked because @playwright/test and http-server have no native code. Projects with native modules should run npm ci inside the container.
  • --update-snapshots takes a mode: all, changed, missing or none. changed rewrites only baselines that fail, which keeps the Git diff small.

CI: run in the same image and keep the evidence

A GitHub Actions job that uses the same container and keeps the diff images when a test fails:

# .github/workflows/visual.yml
name: visual
on: [pull_request]

jobs:
  visual:
    runs-on: ubuntu-24.04
    container:
      image: mcr.microsoft.com/playwright:v1.64.1-noble
      options: --ipc=host
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx playwright test
        env:
          CI: '1'
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diffs
          path: |
            playwright-report/
            test-results/
          retention-days: 14

When a comparison fails, Playwright writes three files per assertion into test-results/: the expected image, the actual image and a diff image with changed pixels in red. The HTML report in playwright-report/ shows them side by side with a slider. Open it locally with npx playwright show-report playwright-report after you download the artifact.

We did not run this workflow for this post; the container and commands are the ones we ran locally.

What to put under visual test

Visual tests are expensive to review, so be selective:

  • Components, not whole pages, where you can. An element screenshot of a card is small, and a diff points at one thing. Our card was 270 x 209 pixels.
  • Pages with stable content. Marketing pages, design system stories, email templates and PDFs. Not dashboards with live data unless you mock the data.
  • One viewport per breakpoint you care about. Each viewport doubles the baselines to review.
  • Not third-party pages. You cannot freeze their clocks, fonts or A/B tests.

Troubleshooting

Symptom Cause Fix
Passes locally, fails in CI by thousands of pixels Baseline made on another OS Make and check baselines in the same Docker image
Fails by a few dozen pixels on the same machine Dynamic text under a mask, or a time-based value Freeze time with page.clock, mock data, or hide with stylePath
A snapshot doesn't exist at ... writing actual First run, or the baseline path changed Run with --update-snapshots and commit the files
Every baseline "missing" after a config change snapshotPathTemplate changed the file names Move the files or regenerate them once
Diff only around a spinner or carousel Animation captured mid-frame Keep animations: 'disabled'; hide JS-driven animations with stylePath
Image sizes differ (Expected an image 1280px by 720px, received ...) Page height changed, or a scrollbar appeared Check content changes; fix the viewport in config
Chromium crashes in Docker Not enough shared memory --ipc=host
Diffs after a Playwright upgrade New Chromium build Upgrade the Docker tag in the same commit and update baselines with --update-snapshots=changed
Flaky only on large pages Lazy images or late web fonts Wait for content and images first; see what to wait for

For the font side of drift (why Linux and macOS draw the same font differently, and which packages to install), see consistent fonts in headless rendering. The glossary entry on pixel diffs explains how diff algorithms count changes.

When a screenshot API fits

toHaveScreenshot is the right tool for your own app in CI. A different job is watching pages you do not control (a competitor's pricing page, a partner's landing page) for visual change. There, you need stable captures from a fixed environment on a schedule, without a test runner. RenderScreenshot renders every request in one managed Chromium setup in the cloud, so you do not mix captures from different laptops and OS font stacks. Browser updates can still move pixels, so compare with a small pixel-count tolerance rather than an exact match. A daily capture of one element with the Node SDK:

import { writeFile } from 'node:fs/promises';
import { Client, TakeOptions } from 'renderscreenshot';

const client = new Client(process.env.RS_API_KEY);

const image = await client.take(
  TakeOptions.url('https://example.com/pricing')
    .element('[data-testid="enterprise"]')
    .blockCookieBanners()
    .cacheRefresh()
);
await writeFile(`pricing-${new Date().toISOString().slice(0, 10)}.png`, image);

cacheRefresh() forces a fresh render instead of a cached image, which is what you want when you look for change. Feed the result into the same pixelmatch step you would use for any two PNGs. The element screenshot docs list the selector rules. You can start with a free key.

Related in this topic