Snapshot Testing with Playwright Screenshots
You have functional coverage on a component, but a CSS refactor just shipped a broken card layout to production because no assertion looked at how it rendered. This guide is for frontend and platform engineers on Playwright 1.4x who want pixel-level regression coverage for individual components using the built-in toHaveScreenshot assertion — no external service, baselines committed alongside the code. We will capture a first baseline, make it deterministic enough to survive CI, tune the diff threshold so it catches real regressions without firing on antialiasing noise, mask the parts of a component that are legitimately dynamic, and wire the whole thing into a reviewable update workflow. This is the foundational technique of the broader visual regression testing topic area; the other guides build on the mechanics established here.
Root Cause Analysis
Visual regressions escape functional suites because functional assertions read the DOM, not the pixels. expect(card).toContainText('Total') passes whether the total is legibly styled or rendered in white-on-white with a collapsed margin. The rendered appearance of a component is a separate output channel from its DOM structure, and nothing in a getByRole or toBeVisible assertion samples that channel. The gap is structural: the properties that determine appearance — computed styles, layout geometry, font rendering, stacking order — are resolved by the browser’s layout and paint pipeline after the DOM the functional test inspects, so a test operating on the DOM tree is blind to them by construction.
Screenshot snapshot testing closes the gap by sampling the paint output directly. But sampling pixels introduces a new problem the DOM channel never had: pixels are sensitive to inputs that have nothing to do with the component. The same component paints differently depending on antialiasing, subpixel font hinting, in-flight animations, and any time- or randomness-derived content. So the root cause of a flaky screenshot test is never the screenshot mechanism itself — it is an uncontrolled rendering input leaking into the capture. The whole implementation below is therefore two jobs braided together: capture the right region, and eliminate every input that could change the capture for a reason other than a genuine component change.
Reproducible Setup
Install Playwright’s component-testing package and its browsers, pinned so the engine never drifts.
npm install -D @playwright/experimental-ct-react
npx playwright install --with-deps chromium
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './src',
snapshotPathTemplate: '{testDir}/__screenshots__/{projectName}/{arg}{ext}',
use: { ctViteConfig: { resolve: {} } },
projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }],
});
The component under test is an ordinary presentational unit; nothing about it is special beyond being renderable in isolation.
// PriceTag.tsx
export function PriceTag({ amount, currency }: { amount: number; currency: string }) {
const formatted = new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
return <span className="price-tag" data-testid="price">{formatted}</span>;
}
Implementation
Step 1 — Capture the first baseline. Mount the component and assert toHaveScreenshot. The first run has no baseline, so Playwright writes one and reports the test as failed-then-created; run it once, review the written PNG by eye, and only then commit it.
import { test, expect } from '@playwright/experimental-ct-react';
import { PriceTag } from './PriceTag';
test('price tag renders correctly', async ({ mount }) => {
const component = await mount(<PriceTag amount={4200} currency="USD" />);
await expect(component).toHaveScreenshot('price-tag.png');
});
Step 2 — Freeze the render before capturing. Disable animations and wait for fonts so the captured frame is stable. toHaveScreenshot already accepts animations: 'disabled', and Playwright waits for the image to stabilise across two consecutive frames, but web fonts must be explicitly awaited.
test('price tag renders correctly', async ({ mount, page }) => {
const component = await mount(<PriceTag amount={4200} currency="USD" />);
await page.evaluate(() => document.fonts.ready);
await expect(component).toHaveScreenshot('price-tag.png', {
animations: 'disabled',
});
});
Step 3 — Tune the threshold. Demand near-exactness but leave headroom for subpixel antialiasing. maxDiffPixelRatio caps the fraction of differing pixels; threshold sets how different a single pixel must be to count. Start strict and loosen only if a verified-stable render still fails.
await expect(component).toHaveScreenshot('price-tag.png', {
animations: 'disabled',
threshold: 0.2,
maxDiffPixelRatio: 0.01,
});
Step 4 — Mask genuinely dynamic regions. When a component contains content that legitimately varies — a live relative timestamp, a random avatar — mask that element so its pixels are excluded from the comparison instead of relaxing the threshold for the whole component. Masking paints a solid box over the region in both the baseline and the actual capture.
test('activity row ignores the live timestamp', async ({ mount, page }) => {
const component = await mount(<ActivityRow user="ada" />);
await expect(component).toHaveScreenshot('activity-row.png', {
mask: [page.getByTestId('relative-time')],
animations: 'disabled',
});
});
Step 5 — Establish the update workflow. When a change to the component is intentional, regenerate the baseline with the update flag and commit the new image in the same pull request as the code change, so a reviewer sees the pixel change next to its cause.
# regenerate only the baselines that changed, then review the git diff of the PNGs
npx playwright test --update-snapshots src/PriceTag.spec.tsx
For components with multiple visual states, drive each state and give it a distinct baseline name so one story maps to one image. This mirrors how a Storybook interaction test enumerates states through play functions.
for (const variant of ['default', 'sale', 'sold-out'] as const) {
test(`price tag ${variant}`, async ({ mount, page }) => {
const component = await mount(<PriceTag amount={4200} currency="USD" variant={variant} />);
await page.evaluate(() => document.fonts.ready);
await expect(component).toHaveScreenshot(`price-tag-${variant}.png`, {
animations: 'disabled',
maxDiffPixelRatio: 0.01,
});
});
}
Verification
Prove both directions of the assertion before you trust it. First, confirm it catches a regression: change a single style token on the component — a padding value or a background colour — run the test, and confirm it fails with a diff image that highlights exactly the region you touched. Revert the change and confirm the test goes green again. Second, confirm it does not flake: run the unchanged test ten times consecutively and require ten passes. A single spurious failure means an uncontrolled input is still leaking into the capture and no threshold will fix it — chase the input, as the deterministic rendering guide describes.
# 1. catches a real change — expect a failure with a diff artifact
npx playwright test src/PriceTag.spec.tsx
# 2. does not flake — expect ten green runs
npx playwright test --repeat-each=10 src/PriceTag.spec.tsx
The stability run is the more important of the two. A visual test that passes intermittently trains reviewers to click “re-run,” and once that habit forms the suite stops gating anything. Ten clean repeats is the minimum bar before a screenshot assertion earns a place in the merge gate. Treat the two checks as a contract: the sensitivity check proves the assertion can see a regression at all, and the stability check proves it will not cry wolf. A test that passes only one of the two is not ready — a stable-but-insensitive test is a false negative waiting to happen, and a sensitive-but-flaky one poisons trust in the whole lane.
Troubleshooting
Symptom: the test fails on CI but passes locally with a near-identical diff. Diagnosis: the baseline was captured on your OS and CI renders on a different one, so antialiasing and font hinting differ across the whole image. Fix: never commit a locally generated baseline — generate baselines in CI (run the update job on the CI image and download the artifacts) so the baseline and the verifier share a rendering environment. Managing that matrix is covered in managing visual baselines across browsers.
Symptom: the diff highlights a small, wandering region that moves between runs. Diagnosis: a dynamic value — a timestamp, counter, or randomized element — is being captured. Fix: mask that element with the mask option rather than raising the global threshold, so the rest of the component stays under a strict tolerance while the volatile region is excluded.
Symptom: the first pixel row or a thin edge always differs. Diagnosis: the capture is including anti-aliased sub-pixel borders at the component’s clip boundary, or a box-shadow bleeding past the element box. Fix: add a small deterministic amount of padding around the mounted component or screenshot a stable wrapper element, so the clip edge falls on a solid region rather than through a soft shadow.
FAQ
Should I commit screenshot baselines to git or use a service?
For a suite up to a few hundred images, commit them to git — ideally through git LFS so the binary blobs do not bloat the packfile — because it keeps the baseline change reviewable in the same pull request as the code that changed it. Past a few hundred images, or once multiple browser and OS variants multiply the count, an external snapshot service that stores baselines out of band and posts diffs to the pull request scales better than a repository carrying thousands of PNGs. The crossover is about review ergonomics and repo size, not correctness.
What threshold values should I start with?
Begin with threshold: 0.2 and maxDiffPixelRatio: 0.01, which tolerates subpixel antialiasing while still catching any visible change. Resist the urge to loosen these when a test flakes; a threshold wide enough to absorb a font swap is also wide enough to hide a real regression. If a stable, deterministic render still exceeds the ratio, the render is not actually deterministic yet — fix the input rather than the number.
How does toHaveScreenshot decide the capture is stable?
Playwright captures repeatedly until two consecutive frames are pixel-identical, then uses that frame, which handles most in-flight rendering automatically. It does not, however, wait for web fonts or freeze a Date-derived value, so you still explicitly await document.fonts.ready and freeze the clock. The built-in stabilisation removes animation jitter; you remove the semantic sources of variance.
Can I screenshot a single element instead of the whole component?
Yes — call toHaveScreenshot on any locator, such as page.getByTestId('header'), and only that element’s bounding box is captured. Narrower captures produce smaller baselines and more local diffs, so a change invalidates one element’s image rather than the whole component’s. Prefer the narrowest capture that still contains the behaviour you want to guard.
Related
- Back to Visual Regression Testing — the topic overview for pixel-level component coverage.
- Managing visual baselines across browsers — where baselines are generated and stored so they match the verifier.
- Reducing flaky screenshots with deterministic rendering — the determinism that makes a stable capture possible.
- Playwright component testing — the mount-and-drive substrate these screenshots build on.