Testland
Browse all skills & agents

playwright-snapshots

Authors Playwright `expect(page).toHaveScreenshot()` assertions, configures masks / clips / threshold / maxDiffPixels per test, manages the per-OS / per-browser snapshot directory, and runs the update flow with `--update-snapshots`. Use when the project ships self-hosted visual regression coverage in Playwright (no external snapshot service).

playwright-snapshots

Overview

Playwright ships first-party visual regression assertions through expect(page).toHaveScreenshot() (and the per-locator expect(locator).toHaveScreenshot()). Snapshots are stored in the repo under per-test, per-browser, per-OS PNG files; comparison happens locally and the test fails when the diff exceeds the configured threshold (playwright-snapshots).

This is the self-hosted option - no external service, no per-snapshot billing, but also no hosted UI for review (diffs are reviewed locally or via CI artifact uploads).

When to use

  • The project already uses @playwright/test.
  • The team prefers visual baselines committed to the repo (and reviewed in PR diffs) over a hosted UI.
  • Coverage is page-driven (full pages, full viewports) rather than story-driven (in which case chromatic-visual-regression-testing may be a better fit).
  • Snapshot determinism is high enough that pixel diffs are signal, not noise. If the page has chronic instability (animated SVGs, ads, A/B experiments), invest in masking before adopting.

Authoring

Page-level assertion

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

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

The first run generates the baseline PNG; subsequent runs compare against it (playwright-snapshots).

Locator-level assertion

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

A locator-level snapshot is preferred when the surrounding page has unrelated dynamic content (e.g. a ticker, a chat widget) - it scopes the comparison and avoids false positives.

toHaveScreenshot() options

Per the PageAssertions API:

OptionEffect
animations"disabled" (default) fast-forwards finite animations and cancels infinite ones; "allow" keeps them.
caret"hide" (default) removes the text cursor; "initial" preserves caret blinking.
clipRectangular area {x, y, width, height} to capture.
fullPageCapture the full scrollable page rather than just the viewport.
maskArray of locators whose elements are overlaid with a solid color (content hidden).
maskColorCSS color for the mask overlay; defaults to pink (#FF00FF).
maxDiffPixelsMaximum absolute number of differing pixels allowed.
maxDiffPixelRatioMaximum proportion (0 - 1) of differing pixels relative to total.
omitBackgroundHide the white background for transparent capture (PNG only).
scale"css" (default; one image px per CSS px) or "device" for HiDPI capture.
stylePathPath to a CSS file applied during capture to hide dynamic elements.
thresholdAcceptable per-pixel color difference in YIQ space (0 - 1); default 0.2.
timeoutMilliseconds to retry the assertion before failing.

Common patterns:

// Mask a chat widget that animates
await expect(page).toHaveScreenshot({
  mask: [page.locator('#intercom-container')],
});

// Allow up to 50 differing pixels (anti-aliasing tolerance)
await expect(page).toHaveScreenshot({ maxDiffPixels: 50 });

// Clip to a known-stable region of an otherwise-noisy page
await expect(page).toHaveScreenshot({
  clip: { x: 0, y: 0, width: 1280, height: 400 },
});

Snapshot directory layout

Playwright stores baselines in a sibling directory of the test file (playwright-snapshots):

tests/
  homepage.spec.ts
  homepage.spec.ts-snapshots/
    homepage-visual-1-chromium-darwin.png
    homepage-visual-1-chromium-linux.png
    homepage-visual-1-firefox-darwin.png
    ...

Naming: [test-name]-[index]-[browser]-[platform].png (playwright-snapshots).

This means baselines are platform-specific. Anti-aliasing, font rendering, and emoji bitmaps differ between macOS, Linux, and Windows; treat the platform suffix as load-bearing. The CI runner must match the platform whose baselines are committed (typically linux).

Project-wide configuration

Set defaults in playwright.config.ts so individual tests stay clean (playwright-snapshots):

import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixels: 100,
      maxDiffPixelRatio: 0.01,
      threshold: 0.2,
      animations: 'disabled',
    },
  },

  snapshotDir: 'tests/__snapshots__',   // optional override
  // snapshotPathTemplate: '{testFilePath}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}',

  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
  ],
});

projects controls which browsers run; each project's snapshots live in the same directory differentiated by the platform suffix.

Running

First run / update flow

# Run all tests including visual assertions
npx playwright test

# Update baselines (after intentional UI changes)
npx playwright test --update-snapshots

# Update baselines for a single test file
npx playwright test tests/homepage.spec.ts --update-snapshots

(Per playwright-snapshots.)

The --update-snapshots flag rewrites every PNG that the matched tests would produce. Always review the diff of the baselines in your PR - an over-broad update can hide a real regression.

CI matching

Baselines committed from a developer laptop (macOS/darwin) will fail on CI (linux) with platform-suffix mismatches. Two options:

  1. Run baseline updates in CI only. Have a manual or workflow_dispatch workflow that runs --update-snapshots and commits the result back; developers never commit baselines from their laptop.
  2. Use Docker locally with the official Playwright image (mcr.microsoft.com/playwright:v<version>-jammy) so local snapshots are bit-identical to CI.

Option 1 is the lower-friction default for most teams.

CI integration

# .github/workflows/playwright-visual.yml
name: visual

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run tests
        run: npx playwright test

      - name: Upload Playwright report (for diff triage)
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

if: always() is critical - when a snapshot diff fails the test, the HTML report is the only place to view the actual / expected / diff images.

References