Your tests can be green and your UI can still be broken. A button that shifted 4px to the right. A font that loaded as fallback. A layout that collapsed on a specific viewport. These are visual regressions — and functional tests will never catch them. That is what snapshot testing is for.

How Playwright Snapshot Testing Works

Playwright has built-in visual comparison via toHaveScreenshot(). The first time a test runs, Playwright captures a baseline screenshot and stores it. On every subsequent run, it compares the current screenshot against the baseline pixel by pixel. If the difference exceeds a configurable threshold, the test fails and Playwright generates a diff image showing exactly what changed.

Your First Snapshot Test

tests/ui/visual.spec.ts
import { test, expect } from '@playwright/test';

test('inventory page matches visual baseline', async ({ page }) => {
  await page.goto('/');
  await page.fill('[data-test="username"]', 'standard_user');
  await page.fill('[data-test="password"]', 'secret_sauce');
  await page.click('[data-test="login-button"]');
  await page.waitForURL(/inventory/);

  // Full page screenshot comparison
  await expect(page).toHaveScreenshot('inventory-page.png', {
    maxDiffPixelRatio: 0.02, // Allow 2% pixel difference
  });
});

test('product card matches visual baseline', async ({ page }) => {
  await page.goto(/inventory/);
  const firstCard = page.locator('.inventory_item').first();

  // Component-level screenshot comparison
  await expect(firstCard).toHaveScreenshot('product-card.png');
});

Configuring Snapshot Options

playwright.config.ts
export default defineConfig({
  expect: {
    toHaveScreenshot: {
      maxDiffPixelRatio: 0.02,   // 2% pixel difference allowed
      threshold: 0.2,              // Per-pixel colour sensitivity
      animations: 'disabled',      // Disable CSS animations for stable shots
    },
  },
  // Snapshots stored per OS and browser to avoid cross-platform diffs
  snapshotPathTemplate: '{testDir}/__snapshots__/{testFilePath}/{arg}-{projectName}{ext}',
});

Handling Dynamic Content

Dynamic content — timestamps, user avatars, ad banners, animated elements — will cause snapshot tests to fail even when there is no real regression. Mask them before taking the screenshot:

masking dynamic content
await expect(page).toHaveScreenshot('dashboard.png', {
  mask: [
    page.locator('[data-testid="user-avatar"]'),
    page.locator('[data-testid="timestamp"]'),
    page.locator('[data-testid="live-chart"]'),
  ],
});

Updating Baselines When UI Changes Intentionally

When a designer deliberately updates the UI, your baselines need to be updated too. This is not a failure — it is expected behaviour. Update all snapshots in one command:

terminal
# Update all snapshot baselines
npx playwright test --update-snapshots

# Update only a specific test file's snapshots
npx playwright test tests/ui/visual.spec.ts --update-snapshots
Important: Always review the diff before updating baselines in CI. Automated baseline updates without human review defeat the purpose of visual testing. In your pull request workflow, make snapshot updates a deliberate, reviewed action.

CI Integration

Store your baseline screenshots in version control so the entire team shares the same baselines. In CI, always upload the diff report as an artifact so failures can be reviewed:

.github/workflows/quality.yml
- name: Run visual tests run: npx playwright test tests/ui/visual.spec.ts - name: Upload visual diff report if: failure() uses: actions/upload-artifact@v4 with: name: visual-diff-report path: | playwright-report/ test-results/

// Key Takeaways