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`; references/ carry the responsive-breakpoint viewport matrix (one project per breakpoint, cross-breakpoint matrix report, plus Chromatic / Percy / Storybook test-runner viewport syntax). Use when the project ships self-hosted visual regression coverage in Playwright (no external snapshot service), or needs a unified multi-viewport breakpoint matrix.

Install with skills.sh (any agent)

npx skills add testland/qa --skill playwright-snapshots
View source

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 (opens in new window)).

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 (opens in new window)).

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 (opens in new window):

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 (opens in new window)):

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 (opens in new window)).

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 (opens in new window)):

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.

For multi-viewport coverage, define one project per breakpoint (mobile-375, tablet-768, desktop-1280, wide-1920) and aggregate into a single cross-breakpoint matrix report - the full pattern, the other engines' viewport syntax, and the unified matrix row shape are in references/responsive-breakpoints.md.

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 (opens in new window).)

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

Responsive breakpoint matrix - per-engine viewport syntax

View source (opens in new window)

Responsive breakpoint matrix - per-engine viewport syntax

Companion reference for playwright-snapshots. Consult when the UI ships at three or more breakpoints (typical: 375 / 768 / 1280, often plus 1920) and the suite needs one unified pass/fail view across viewport widths instead of separate per-breakpoint reports. The Playwright pattern is primary; the other engines' viewport syntax is included so a mixed-engine project can run the same matrix everywhere.

Dispatcher: pick by engine

Is the project using Chromatic + Storybook?
├── Yes → Chromatic pattern.
└── No
    ├── Is the project using Percy?
    │   └── Yes → Percy pattern.
    └── No
        ├── Is the project using @storybook/test-runner without Chromatic?
        │   └── Yes → Storybook test-runner pattern.
        └── No  (project uses raw @playwright/test snapshots)
            └── Playwright pattern.

If the project uses two engines (e.g. Chromatic for stories + Playwright snapshots for full pages), apply the matching pattern to each independently and aggregate verdicts with visual-baseline-gate.

Playwright pattern (primary)

Per playwright-snapshots (opens in new window), the canonical pattern is one project per breakpoint, each with its own viewport:

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

export default defineConfig({
  projects: [
    { name: 'mobile-375',  use: { ...devices['Desktop Chrome'], viewport: { width: 375,  height: 667  } } },
    { name: 'tablet-768',  use: { ...devices['Desktop Chrome'], viewport: { width: 768,  height: 1024 } } },
    { name: 'desktop-1280',use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800  } } },
    { name: 'wide-1920',   use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } } },
  ],
});

Run the matrix:

npx playwright test --project=mobile-375
npx playwright test                          # all projects in parallel

Each project produces its own snapshot suffix so baselines stay isolated (see the naming convention in SKILL.md).

Chromatic pattern

Per the Chromatic viewports docs (opens in new window), viewports are configured per story via parameters.chromatic.viewports:

// Header.stories.ts
export default {
  title: 'Components/Header',
  component: Header,
  parameters: {
    chromatic: {
      viewports: [375, 768, 1280, 1920],
    },
  },
};

A story with multiple viewports produces one snapshot per viewport in the same Chromatic build. Pair with TurboSnap (--only-changed, see chromatic-visual-regression-testing) so a per-PR breakpoint matrix doesn't blow up snapshot quota.

Percy pattern

Per Percy CLI (opens in new window), project-wide widths are set in the Percy config file:

# .percy.yml
version: 2
snapshot:
  widths: [375, 768, 1280, 1920]
  min-height: 1024

For a single overridden snapshot, pass the widths in the SDK call:

await percySnapshot(page, 'Homepage', { widths: [375, 1280] });

(When in doubt, check the latest percy/cli (opens in new window) release for the current snapshot config schema.)

Storybook test-runner pattern

When using @storybook/test-runner without Chromatic, drive the viewport via the test-runner's lifecycle hook (per storybook-test-runner (opens in new window)):

// .storybook/test-runner.ts
import type { TestRunnerConfig } from '@storybook/test-runner';
import { expect } from '@playwright/test';

const VIEWPORTS = [375, 768, 1280, 1920];

const config: TestRunnerConfig = {
  async postVisit(page, context) {
    for (const width of VIEWPORTS) {
      await page.setViewportSize({ width, height: Math.round(width * 0.75) });
      await expect(page.locator('#storybook-root')).toHaveScreenshot(
        `${context.id}-${width}.png`
      );
    }
  },
};

export default config;

This multiplies snapshot count by VIEWPORTS.length - acceptable for a few hundred stories; reconsider above ~1000 stories where Chromatic's TurboSnap makes more economic sense.

Producing the unified matrix report

Normalize each engine's per-breakpoint result to a common row shape:

{
  "engine":      "playwright",
  "breakpoint":  "mobile-375",
  "story_or_url": "/dashboard",
  "status":      "fail",
  "diff_pixels": 1234,
  "diff_url":    "playwright-report/data/dashboard-mobile-375-diff.png"
}

Then render a markdown matrix (rows = pages/stories, columns = breakpoints):

| Page / Story | mobile-375 | tablet-768 | desktop-1280 | wide-1920 |
|--------------|:----------:|:----------:|:------------:|:---------:|
| /dashboard   |     ✅     |     ✅     |      ✅      |    ✅    |
| /pricing     |     ✅     |     ❌     |      ❌      |    ✅    |

A single failed cell tells the reviewer which breakpoint broke. Pipe the matrix into $GITHUB_STEP_SUMMARY (or the GitLab / Jenkins equivalent) for a clickable PR-side summary, and feed the same rows to visual-baseline-gate for a hard CI gate that fails on any red cell.

CI artifact upload

Upload every breakpoint's report artifact so a reviewer can see the diff for a specific cell:

- name: Upload all visual artifacts
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: visual-reports-all-breakpoints
    path: |
      playwright-report/
      test-results/
      .chromatic/
      .percy/
    retention-days: 14

Source docs

Related skills

chart-render-tests

Chart-render regression testing across the three chart-library families - Canvas (Chart.js: locator screenshot snapshot + `canvas.toDataURL()` diff with animations disabled), SVG (D3: `outerHTML` structural snapshot with generated-ID normalization + per-element data-binding tests), and declarative specs (Vega / Vega-Lite: JSON Schema validation + Vega-Lite → Vega compile test). Detects the family from package.json imports (chart.js / d3 / vega-lite), then applies the matching recipe; full per-library depth with citations in references/chartjs.md, references/d3.md, references/vega.md. Use when a dashboard or data product renders charts and their output needs regression coverage - before a chart-library major upgrade, after a theming change, or when runtime-generated Vega specs must be proven valid before render.

chromatic-visual-regression-testing

Authors and runs Chromatic visual tests on Storybook, Playwright, or Cypress projects via the `chromatic` CLI; configures baselines, TurboSnap, UI Review, and CI gating; reads exit codes for change-vs-error classification. Use when the project ships visual regression coverage to Chromatic Cloud.

percy-visual-regression-testing

Authors Percy visual snapshot tests via the @percy/cli + framework SDK (Playwright, Cypress, Selenium, Storybook), runs them with `percy exec -- {test command}`, configures viewports / masking / ignored regions, and reviews diffs in the Percy build UI. Use when the project ships visual regression coverage to BrowserStack Percy.

storybook-visual-regression-testing

Sets up visual regression coverage for a Storybook project - either via the official @chromatic-com/storybook addon (hosted) or via @storybook/test-runner with a postVisit hook that calls Playwright's toHaveScreenshot (self-hosted). Covers test-runner install, lifecycle hooks (setup / preVisit / postVisit), and CI integration. Use when a repo already has a working `.storybook/` config and the team wants per-story visual coverage rather than page-level snapshots.

visual-baseline-conventions

Reference catalog for visual regression coverage decisions - which Storybook stories or pages get baselines, how to choose breakpoints, when to mask vs adjust threshold, when to add or remove a baseline, and a decision matrix for picking among Percy / Chromatic / Playwright / Storybook test-runner. Use when designing visual coverage for a new project or auditing an existing baseline set.

visual-baseline-gate

Consumes pre-classified visual-diff JSON and a reviewer-signed acceptance log to produce a single go/no-go CI verdict for visual regression. Blocks when intentional baseline changes lack a non-author reviewer sign-off or when regressions are present, and emits the binding gate artifacts - visual-gate.json + visual-gate.md - with fail-closed handling of a missing classifier run and author-cannot-self-approve enforcement, so the pipeline can exit non-zero on BLOCK. Use when the gate's input is pre-classified diff data and the enforcement concern is reviewer approval and a binding CI verdict.