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:

  1. Generate baselines in CI (Linux) and only run visual tests in CI
  2. Use Docker to ensure consistent rendering environment
  3. 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

toHaveScreenshottoMatchSnapshot
TargetPage or elementAny value (Buffer, string)
Threshold optionsYes (maxDiffPixels, mask, clip)No
Retry on diffYes (retries until stable)No
Recommended forVisual UI testingBinary data comparison