Playwright Visual Regression Testing: Screenshot Diffing Built In
Visual regressions are the silent killers of web applications. A CSS change that looks fine in isolation can break the layout of a page you never manually checked. Playwright's built-in screenshot comparison (toHaveScreenshot) gives you pixel-level visual regression testing without any external service — just snapshots stored in your repository and automatic diffing on every run.
How Playwright Screenshot Comparison Works
When you call expect(page).toHaveScreenshot('name.png'), Playwright:
- Takes a screenshot of the current page or element
- Compares it to the stored baseline snapshot at
__snapshots__/name.png - Fails the test if the pixel difference exceeds your configured threshold
- Generates a diff image showing what changed
On first run (when no baseline exists), Playwright writes the screenshot and the test passes — establishing the baseline. On subsequent runs, it compares against that baseline. To update baselines after intentional visual changes, run with --update-snapshots.
Basic Usage
import { test, expect } from '@playwright/test';
test('homepage looks correct', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png');
});
// Capture a specific element instead of the full page
test('navigation bar matches design', async ({ page }) => {
await page.goto('/');
const navbar = page.locator('nav[data-testid="main-nav"]');
await expect(navbar).toHaveScreenshot('navbar.png');
});
// Full page screenshot (scrolls and stitches)
test('full landing page', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('landing-full.png', { fullPage: true });
});Snapshot files are stored at tests/__snapshots__/ by default (configurable). Commit them to your repository — they're the visual baseline that CI compares against.
Configuring Comparison Thresholds
Pixel-perfect comparison is too strict for real applications. Fonts render differently across OSes, anti-aliasing varies, and animations cause pixel-level differences that aren't real regressions. Set appropriate thresholds:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
// Allow 1% of pixels to differ
maxDiffPixelRatio: 0.01,
// Or allow a fixed number of different pixels
// maxDiffPixels: 100,
// Tolerance per-channel (0-255) — handles anti-aliasing
threshold: 0.2,
// Animation handling
animations: 'disabled', // Pause CSS animations before screenshot
},
},
});Per-test overrides when specific screenshots need looser tolerances:
test('chart with animation', async ({ page }) => {
await page.goto('/analytics');
await expect(page).toHaveScreenshot('analytics.png', {
maxDiffPixelRatio: 0.05, // Charts vary more
animations: 'allow', // Don't pause animations
timeout: 10000, // Give chart time to render
});
});Handling Dynamic Content
Pages with dynamic content — timestamps, randomized content, ads — will always differ from baselines. Mask dynamic regions:
test('user profile with masked dynamic content', async ({ page }) => {
await page.goto('/profile/123');
await expect(page).toHaveScreenshot('profile.png', {
mask: [
page.locator('[data-testid="last-login-time"]'), // Dynamic timestamp
page.locator('[data-testid="session-id"]'), // Random session data
page.locator('.ad-banner'), // Third-party ads
],
});
});Masked regions are replaced with a solid color in both the captured screenshot and the baseline during comparison, so they never cause failures.
For content that loads asynchronously, wait for stability before capturing:
test('data table after loading', async ({ page }) => {
await page.goto('/reports');
// Wait for loading indicator to disappear
await page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
// Wait for network to be idle
await page.waitForLoadState('networkidle');
// Wait for animations to complete
await page.waitForTimeout(500);
await expect(page).toHaveScreenshot('reports.png');
});Multi-Viewport Visual Testing
Responsive design means you need visual tests at multiple viewport sizes:
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'Desktop Chrome',
use: { viewport: { width: 1280, height: 720 } },
},
{
name: 'Tablet',
use: { viewport: { width: 768, height: 1024 } },
},
{
name: 'Mobile',
use: { ...devices['iPhone 12'] },
},
],
});Playwright automatically namespaces screenshots by project — homepage-Desktop-Chrome.png, homepage-Mobile.png, etc. Each viewport gets its own baseline.
Cross-Browser Visual Testing
Visual tests across browsers catch rendering inconsistencies:
export default defineConfig({
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});Firefox and WebKit render fonts and shadows slightly differently. Increase your threshold for cross-browser tests or maintain separate baselines per browser.
CI/CD Integration
Visual tests in CI require consistent rendering environments. Browser rendering differs between macOS (development) and Linux (CI), making baselines taken on macOS fail on Linux.
Solution: Generate baselines in CI.
Run snapshot updates in your CI environment to create correct baselines:
# In CI (first time or after intentional visual changes)
npx playwright <span class="hljs-built_in">test --update-snapshots
<span class="hljs-comment"># Commit the updated snapshots
git add tests/__snapshots__/
git commit -m <span class="hljs-string">"chore: update visual baselines"Or use Docker to ensure local and CI environments match:
# Run tests and update snapshots inside the official Playwright Docker image
docker run --<span class="hljs-built_in">rm --ipc=host \
-v $(<span class="hljs-built_in">pwd):/work \
-w /work \
mcr.microsoft.com/playwright:v1.44.0-jammy \
npx playwright <span class="hljs-built_in">test --update-snapshotsGitHub Actions with Visual Tests
name: Visual Tests
on:
pull_request:
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Run visual tests
run: npx playwright test --project=chromium
- name: Upload diff screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-visual-diffs
path: test-results/
retention-days: 7When a visual test fails, the diff images in test-results/ show exactly what changed — the expected baseline, the actual screenshot, and a highlighted diff.
Organizing Visual Tests
Keep visual tests separate from functional tests:
tests/
functional/
auth.spec.ts
checkout.spec.ts
visual/
pages.spec.ts
components.spec.tsRun them separately in CI:
# Functional tests — fast, run on every commit
npx playwright <span class="hljs-built_in">test tests/functional/
<span class="hljs-comment"># Visual tests — slower, run on PRs and main branch
npx playwright <span class="hljs-built_in">test tests/visual/Component-Level Visual Testing
Beyond page-level screenshots, test individual components in isolation:
test('button states', async ({ page }) => {
await page.goto('/design-system/buttons');
const defaultBtn = page.locator('[data-testid="button-default"]');
const hoverBtn = page.locator('[data-testid="button-hover"]');
const disabledBtn = page.locator('[data-testid="button-disabled"]');
await expect(defaultBtn).toHaveScreenshot('button-default.png');
// Test hover state
await defaultBtn.hover();
await expect(defaultBtn).toHaveScreenshot('button-hovered.png');
await expect(disabledBtn).toHaveScreenshot('button-disabled.png');
});This is especially valuable for design system components where visual consistency is a requirement.
Updating Baselines After Intentional Changes
When a UI change is intentional, update the baselines:
# Update all snapshots
npx playwright <span class="hljs-built_in">test --update-snapshots
<span class="hljs-comment"># Update snapshots for a specific test file
npx playwright <span class="hljs-built_in">test tests/visual/homepage.spec.ts --update-snapshots
<span class="hljs-comment"># Update snapshots for tests matching a pattern
npx playwright <span class="hljs-built_in">test --grep <span class="hljs-string">"navigation" --update-snapshotsAlways review updated snapshots before committing. The diff between old and new baseline should match exactly what you intended to change.
Playwright vs. External Visual Testing Tools
Playwright's built-in screenshot comparison handles most use cases:
- No external service or API key needed
- Snapshots stored in version control alongside tests
- Works offline and in air-gapped environments
- Free with no test limits
Where external tools like Percy or Chromatic add value:
- Visual review workflow with human approval before merging
- Comparison across feature branches without local baseline management
- Historical baseline tracking across months of changes
For most teams, Playwright's built-in comparison is the right starting point. Add an external service only when the review workflow becomes a bottleneck.
Visual regression testing with Playwright catches the class of bugs that unit and integration tests completely miss — layout breaks, color changes, font regressions, overlapping elements. It's fast to set up, free to run, and requires no external dependencies. Add it to your test suite and you'll catch visual issues before your users do.