11Visual Regression Testing
What you will master here
- What visual regression catches that DOM assertions can't
toHaveScreenshot— baseline, diff, threshold- Masking dynamic regions (dates, ads, animations)
- Stabilising rendering before snapshot (fonts, animations, lazy images)
- Per-project baselines (browser, OS, viewport)
- Updating baselines safely and reviewing diffs in CI
- When NOT to use visual tests
11.1 Why visual testing exists
DOM assertions catch structural bugs ("the button exists, has text 'Buy'"). They miss:
- CSS regressions — button is now invisible white-on-white
- Layout breaks — text overflowing, modal cut off, mobile flow broken
- Icon swaps — wrong icon, wrong colour theme
- Z-order — overlay covered by another element
- Subtle spacing changes that look wrong but pass functional tests
Visual regression takes a pixel snapshot and diffs it against an approved baseline. If they differ beyond a threshold, the test fails with a diff image showing exactly what changed.
11.2 The basics — toHaveScreenshot
test('homepage looks right', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png'); // full page
await expect(page.locator('.invoice')).toHaveScreenshot('invoice.png'); // element only
});
First run creates the baseline in __snapshots__/home.spec.ts/home.png. Later runs compare against it. Diff stored next to the failure as home-diff.png.
11.3 Stabilising the page before snapshot
The single biggest source of flake. Common sources of pixel noise:
| Source | Fix |
|---|---|
| Fonts still loading | await page.evaluate(() => document.fonts.ready); |
| CSS animations | Use animations: 'disabled' option (default since 1.20) |
| Cursor blink, video autoplay | caret: 'hide'; pause media in beforeEach |
| Lazy images | Scroll page or use fullPage: true which triggers lazy loaders |
| Date / "2 minutes ago" text | Mask the element (next section) |
| Random ads / banners | Block via page.route or mask |
| Anti-aliasing across OS | Per-project baselines; run on identical OS in CI |
test('product card snapshot', async ({ page }) => {
await page.goto('/products/42');
/* wait for everything to settle */
await page.evaluate(() => document.fonts.ready);
await page.waitForLoadState('networkidle');
await expect(page.locator('.product-card')).toHaveScreenshot('card.png', {
animations: 'disabled',
caret: 'hide',
maxDiffPixelRatio: 0.01, // allow 1% pixel drift
});
});
11.4 Masking dynamic regions
Some elements legitimately change every run (dates, prices fetched live, user avatar). Mask them so the screenshot diff ignores those areas.
await expect(page).toHaveScreenshot('orders.png', {
mask: [
page.locator('.order-date'),
page.locator('.live-price'),
page.getByTestId('promo-banner'),
],
/* default mask colour is magenta — override if needed */
maskColor: '#000',
});
11.5 Threshold tuning
| Option | What it does |
|---|---|
maxDiffPixels: 100 | Allow up to 100 different pixels (absolute count) |
maxDiffPixelRatio: 0.01 | Allow up to 1% of pixels to differ |
threshold: 0.2 | Per-pixel sensitivity (0 = exact, 1 = max). Lower = stricter |
maxDiffPixelRatio to 0.005 or 0.01 rather than disabling the test.11.6 Per-project baselines
Pixel rendering differs by browser engine, OS, and font availability. Don't share one baseline across all projects — Playwright already names baselines by project automatically.
// playwright.config.ts
export default defineConfig({
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled', caret: 'hide' },
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
/* baseline path becomes:
__snapshots__/page.spec.ts-snapshots/home-chromium-linux.png
__snapshots__/page.spec.ts-snapshots/home-firefox-linux.png
...
*/
mcr.microsoft.com/playwright) locally to match.11.7 Updating baselines
# After an intentional UI change, regenerate baselines locally npx playwright test --update-snapshots # Or just for a specific file npx playwright test tests/home.spec.ts --update-snapshots
Reviewing baseline changes in PRs
Commit baseline files (PNGs) alongside the code change. PR reviewer must see the new baselines. Tooling tip: use a service like Argos, Percy, or a custom GitHub action that renders side-by-side baseline / actual / diff in PR comments.
11.8 CI artifact diffing
When a visual test fails on CI, Playwright drops three PNGs in the test-results folder:
name-expected.png— the baselinename-actual.png— what was captured this runname-diff.png— pixel diff highlighting changes in red
Upload them as artifacts; the HTML report also embeds them.
# GitHub Actions step (add to your workflow)
- name: Upload visual diffs
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: test-results/**/*.png
11.9 When NOT to use visual tests
- Pages with heavy random/dynamic content (charts, dashboards) — masking gets unwieldy
- Frequently restyled prototypes — baselines become a chore
- Tests that should validate logic, not appearance — use DOM/text assertions
- Cross-OS pixel parity is mandatory but CI / dev OS differ — fix the env first
11.10 A typical visual suite layout
// tests/visual/components.spec.ts
import { test, expect } from '@playwright/test';
const COMPONENTS = ['button', 'input', 'badge', 'modal', 'tooltip'] as const;
for (const name of COMPONENTS){
test(`${name} matches baseline`, async ({ page }) => {
await page.goto(`/storybook?path=/story/${name}`);
await page.evaluate(() => document.fonts.ready);
await expect(page.locator('#root')).toHaveScreenshot(`${name}.png`);
});
}
Module 13 — Interview Q&A bank
What does visual regression catch that DOM tests miss?
How does toHaveScreenshot work?
What stabilisation do you do before a snapshot?
What is masking and when do you use it?
What's the difference between threshold and maxDiffPixelRatio?
Why do visual tests pass locally but fail on CI?
How do you update baselines after an intentional UI change?
npx playwright test --update-snapshots regenerates them. Commit the new PNGs in the same PR as the UI change so reviewers see both the code and visual delta.Should you commit baseline images to git?
Where do you draw the line between visual and functional tests?
What's a good first place to add visual tests?
What if a visual test diffs by 3 pixels every run?
How do you handle dark-mode in visual tests?
use: { colorScheme: 'dark' } per project or per file. Baselines are per-project, so dark-mode and light-mode get their own snapshots.