level 16 / screenshot-testing
Screenshot & Visual Regression Testing
Use Playwright's built-in toHaveScreenshot and toMatchSnapshot to catch visual regressions automatically.
What Visual Testing Catches
Functional tests verify behaviour — “the button submits the form.” Visual tests verify appearance — “the button is still red, 16px, and right-aligned.” A CSS regression can break the layout while every functional test still passes.
Visual testing compares screenshots pixel-by-pixel against a saved baseline.
Playwright Built-in Screenshot Testing
import { test, expect } from '@playwright/test';
// Full-page screenshot comparison
test('home page looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('home.png');
});
// Element-level screenshot
test('product card looks correct', async ({ page }) => {
await page.goto('/products');
const card = page.locator('.product-card').first();
await expect(card).toHaveScreenshot('product-card.png');
});
First run: No baseline exists → Playwright creates it and FAILS. Run again → passes.
Correct workflow:
# 1. Generate baselines
npx playwright test --update-snapshots
# 2. Commit baselines to git
git add tests/__screenshots__/
git commit -m "chore: add visual test baselines"
# 3. Future runs compare against committed baselines
npx playwright test
Snapshot Storage
tests/
├── visual.spec.ts
└── visual.spec.ts-snapshots/
├── home-1-chromium-win32.png # browser + OS in filename
├── home-1-chromium-linux.png # different baseline per platform!
└── product-card-1-chromium-linux.png
Platform problem: Screenshots differ between macOS, Linux, and Windows due to font rendering and anti-aliasing. Solutions:
- Generate baselines in CI (Linux) and only run visual tests in CI
- Use Docker to ensure consistent rendering environment
- Use a cloud visual testing service (Percy, Chromatic)
Threshold and Masking
// Allow small pixel differences (e.g., animation frames)
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixelRatio: 0.02, // 2% pixel difference allowed
});
// Or absolute pixel count
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixels: 100,
});
// Mask dynamic content (dates, ads, user avatars)
await expect(page).toHaveScreenshot('dashboard.png', {
mask: [
page.locator('.user-avatar'),
page.locator('.last-login-date'),
page.locator('.ad-banner'),
],
});
// Clip to specific region
await expect(page).toHaveScreenshot('header.png', {
clip: { x: 0, y: 0, width: 1280, height: 80 },
});
Stabilising Screenshots Before Capture
test('animated hero looks correct', async ({ page }) => {
await page.goto('/');
// Wait for animations to complete before screenshot
await page.waitForLoadState('networkidle');
await page.evaluate(() => {
// Disable CSS animations and transitions
document.documentElement.style.setProperty('--animation-duration', '0s');
document.documentElement.style.setProperty('--transition-duration', '0s');
});
// Wait for lazy images to load
await page.waitForSelector('img[loading="lazy"]', { state: 'visible' });
await expect(page).toHaveScreenshot('hero.png');
});
Updating Baselines
# Update all snapshots
npx playwright test --update-snapshots
# Update specific test's snapshot
npx playwright test visual.spec.ts --update-snapshots
# After update: review the diff before committing
git diff tests/__screenshots__/
toMatchSnapshot vs toHaveScreenshot
toHaveScreenshot | toMatchSnapshot | |
|---|---|---|
| Target | Page or element | Any value (Buffer, string) |
| Threshold options | Yes (maxDiffPixels, mask, clip) | No |
| Retry on diff | Yes (retries until stable) | No |
| Recommended for | Visual UI testing | Binary data comparison |