TestRelic AI
Go to App

Visual Testing

Catch unintended UI changes with visual baselines — how TestRelic captures Playwright's screenshot comparisons, what lands in the report, and how to keep baselines stable.

Already using toHaveScreenshot?

Then there is nothing to change. Upgrade the reporter and your existing screenshot comparisons start appearing in the report — baseline, actual and pixel diff, with the differing-pixel count. No new import, no rewritten assertions.

A visual test asserts that a page still looks the way it did. Playwright compares the render against a committed baseline image; TestRelic captures that comparison and shows you what moved.

How it works

TestRelic does not compare images itself. Playwright already ships a pixel comparator, so the reporter reads the comparison Playwright performed and turns it into a report section:

  1. Your assertion renders the page and Playwright compares it against the baseline.
  2. On a mismatch Playwright writes three images: the baseline, what this run rendered, and a diff.
  3. The reporter collects all three, records the differing-pixel count and ratio, and shows them together.

Pixel comparison works on any Playwright from 1.35 onwards and adds no dependencies to your project. DOM comparison — naming the elements that changed — additionally needs Playwright 1.51 or later; on an older version it announces itself as unavailable, once, and everything else carries on unaffected.

Writing a visual assertion

AI Prompt — add visual coverage
Add visual regression coverage to my Playwright suite using
@testrelic/playwright-analytics.

For each critical page:
  - assert the full page against a baseline with expect(page).toHaveScreenshot()
  - give every snapshot an explicit, stable name
  - mask any region that changes between runs (clocks, carousels, live data)

Set a shared tolerance in playwright.config.ts under expect.toHaveScreenshot
(maxDiffPixelRatio: 0.002, animations: 'disabled') rather than per-assertion,
and pin use.viewport so baselines stay valid.

Page-level and element-level comparisons are captured the same way.

Recording passing comparisons

Playwright attaches nothing when a screenshot matches, so a green visual check leaves no trace in any report — you can see what broke, never what held.

toMatchVisualBaseline fixes that. It runs the same comparator with the same options, and additionally records the baseline it asserted against and the name you chose:

tests/dashboard.spec.ts
import { test, expect } from '@testrelic/playwright-analytics/fixture';

test('release health dashboard', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page).toMatchVisualBaseline('release-health-dashboard', {
    maxDiffPixelRatio: 0.01,
    mask: [page.locator('.live-ticker')],
    tags: ['critical-path'],
  });
});

Every toHaveScreenshot option applies unchanged — threshold, maxDiffPixels, maxDiffPixelRatio, mask, clip, fullPage, animations, caret, stylePath. Two additions:

OptionPurpose
tagsLabels recorded with the comparison
nameThe .png extension is optional here; toHaveScreenshot requires it

If you use Playwright's own expect rather than the TestRelic fixture, import @testrelic/playwright-analytics/visual once anywhere in your test setup to register the matcher.

What the report shows

Open a test with visual assertions and the drawer gains a Visual comparisons section — one card per snapshot, with:

  • Statuspassed, failed, new baseline or baseline updated. A new or updated baseline is not a pass: nothing was compared, because there was nothing to compare against.
  • Metrics — differing pixel count, the exact ratio, and the image dimensions. A size change is called out separately (900x560 -> 900x600), since a comparison can fail purely because the render changed shape.
  • Four ways to look at it — side by side, a wipe slider, an onion-skin blend, and Playwright's diff image.

The summary strip counts visual checks and failures across the run, and the filter drawer gains Has visual and Visual failed chips.

Naming the elements that changed

A pixel diff tells you 80,858 pixels moved. It cannot tell you which card gained a margin.

Alongside each screenshot, the SDK captures a DOM snapshot — element geometry, a curated set of computed styles, visible text, and identity attributes — and diffs it against a committed baseline. The report's Elements tab names what differs, and hovering a row draws that element's box over the screenshot.

11 elements differ · 1 changed, 10 only shifted position

div.card.accent          restyled · moved         7.9%
    class                card → banner
    background-color     rgb(3, 183, 156) → rgb(224, 108, 79)
    margin-top           (initial) → 24px
    rect                 317, 102, 265, 150 → 317, 126, 265, 150

MOVED OR RESIZED ONLY
div.grid                 resized                 28.9%
div.bar                  moved                   19.9%

Seven kinds of change are reported: added, removed, moved, resized, restyled, text, and attrs — an id or class list changing, which is usually the real edit behind a page full of restyles.

Root causes versus knock-on movement

One card gaining a 24px margin moves everything below it. A flat list would show eleven findings for one edit, so elements that only moved or resized are grouped separately from those that actually changed, and the report leads with the element that did something.

That grouping is a statement about each element — that nothing about it changed except its position — and deliberately not a claim to have proven what moved it.

Turning it on

Import expect from the TestRelic fixture. Both matchers then capture DOM:

tests/dashboard.spec.ts
import { test, expect } from '@testrelic/playwright-analytics/fixture';

await expect(page).toHaveScreenshot('dashboard.png');    // pixels + DOM
await expect(page).toMatchVisualBaseline('dashboard');   // pixels + DOM

Importing expect from @playwright/test keeps working and compares pixels only. The DOM has to be captured before the screenshot is taken, and that needs a hook Playwright does not expose.

Name your snapshots. toHaveScreenshot() with no name generates one from the test title, which cannot be resolved ahead of the assertion, so DOM capture is skipped there.

DOM comparison needs Playwright 1.51

Resolving the baseline path uses testInfo.snapshotPath(name, { kind }), which arrived in Playwright 1.51. The SDK's peer range stays at >=1.35.0 deliberately — everything else works there, and raising the floor would lock those projects out of the whole reporter over one feature. On an older Playwright this feature prints a single line to stderr saying it is unavailable, rather than silently doing nothing.

DOM baselines

A <name>.dom.json is written beside every image baseline and committed with it:

tests/dashboard.spec.ts-snapshots/
  dashboard-chromium-linux.png        (binary)
  dashboard-chromium-linux.dom.json   (text — readable in a PR diff)

A restyle touches both files, and the JSON one is the reviewable half: margin-top: 24px turning up in a diff is a far clearer signal than a changed PNG. --update-snapshots rewrites both together.

Masked regions are excluded from the DOM comparison too. The same mask option covers both, which is what keeps text changes usable on a page with a clock on it.

Captures are capped at 1,500 elements and 1 MiB. Anything trimmed is counted and shown as a capture truncated badge rather than quietly dropped.

Where baselines live

In Playwright's snapshot directory, next to the spec that uses them, committed to git:

tests/
  dashboard.spec.ts
  dashboard.spec.ts-snapshots/
    dashboard-chromium-linux.png
    summary-card-chromium-linux.png

Commit them. A baseline change then arrives as a binary diff in the pull request, which is where a reviewer decides whether the new look is correct. Accept a change with:

Terminal
npx playwright test --update-snapshots

Run artifacts land separately, under test-results/artifacts/<timestamp>/<test>/visual/<snapshot>/ as baseline.png, actual.png and diff.png. That directory is gitignored and is cleared when old runs are pruned — never keep baselines there.

Keeping baselines stable

A flaky visual suite gets ignored, and an ignored suite catches nothing. Four things cause almost all of it:

Pin the viewport. A baseline is a picture of one specific window size.

playwright.config.ts
use: {
  viewport: { width: 1280, height: 720 },
}

Leave a tolerance. Anti-aliasing differs slightly between runs on the same machine, so a zero threshold makes the suite flaky rather than strict.

playwright.config.ts
expect: {
  toHaveScreenshot: {
    maxDiffPixelRatio: 0.002,
    animations: 'disabled',
  },
},

Mask what you do not control — clocks, relative timestamps, carousels, ads, randomised content, anything fetched live.

await expect(page).toHaveScreenshot('feed.png', {
  mask: [page.locator('.timestamp'), page.locator('.ad-slot')],
});

Generate baselines on the platform that will check them. A baseline written on macOS will not match a run on Linux CI — fonts and sub-pixel rendering differ. Playwright encodes the platform in the filename so both can coexist, but each has to be produced where it will be used. The usual approach is to generate them in CI and commit what CI produced.

In the cloud

Baseline, actual and diff images upload alongside screenshots, videos and traces, through the same path and the same artifactMaxSizeMb limit (50 MB by default). Nothing extra to configure — if artifact upload is on, visual images ride with it.

Troubleshooting

Every test fails on the first run. Expected. No baseline existed, so Playwright wrote one and reported a failure because nothing was compared. Run again.

A comparison passes but shows no images. Native toHaveScreenshot attaches nothing on success. Use toMatchVisualBaseline if you want the baseline recorded on passing checks too.

Baselines pass locally and fail in CI. Different platform. Generate the baselines in CI and commit those.

A test fails with must have '.png' extension. toHaveScreenshot requires the suffix in the snapshot name. toMatchVisualBaseline adds it for you.

Nothing appears in the report. Visual capture rides on includeArtifacts, which is on by default. Check it has not been disabled in the reporter options.

Next steps

Was this page helpful?

On this page