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
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
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:
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:
# 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
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:
- 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
- Snapshot testing catches visual regressions that functional tests completely miss.
- Always disable animations and mask dynamic content before capturing baselines.
- Store baselines in version control — they are part of your test suite, not build artifacts.
- Never automate baseline updates in CI without human review — it defeats the purpose.
- Use component-level snapshots for UI components and full-page snapshots for critical pages.