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:
- Your assertion renders the page and Playwright compares it against the baseline.
- On a mismatch Playwright writes three images: the baseline, what this run rendered, and a diff.
- 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
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:
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:
| Option | Purpose |
|---|---|
tags | Labels recorded with the comparison |
| name | The .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:
- Status —
passed,failed,new baselineorbaseline 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:
import { test, expect } from '@testrelic/playwright-analytics/fixture';
await expect(page).toHaveScreenshot('dashboard.png'); // pixels + DOM
await expect(page).toMatchVisualBaseline('dashboard'); // pixels + DOMImporting 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.pngCommit 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:
npx playwright test --update-snapshotsRun 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.
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.
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
Unified Testing (Browser + API)
How do I combine E2E browser testing with API testing in a single TestRelic test, using both the page and request fixtures?
Browser Test Reports
What does a TestRelic browser test report contain, and how do I analyze the navigation timeline, network stats, and failure diagnostics?