Skip to main content

Visual Regression Testing with Playwright Snapshots

June 2, 2026

</>

Unit tests verify logic. Integration tests verify data flows. But neither catches the class of bugs that developers and designers fear most: visual regressions. A merge that accidentally shifts a button 4 pixels, changes a font weight, or breaks a dark mode color — these never appear in a test failure, they appear in a Slack message from a user or a designer.

Visual regression testing captures screenshots of your UI at a known-good state and compares every subsequent run against that baseline pixel-by-pixel. Any visual change — intentional or not — surfaces immediately as a failing test with a side-by-side diff image.

Playwright ships a built-in toHaveScreenshot() assertion that makes this accessible without any third-party tooling.


How Playwright Visual Snapshots Work

VISUAL REGRESSION FLOW:

  First Run (Baseline)          Subsequent Runs
  ┌────────────────┐             ┌────────────────┐
    Take                         Take          
    Screenshot                   Screenshot    
    button.png                   button.png    
  └───────┬────────┘             └───────┬────────┘
                                        
           Save as                       Compare pixel-by-pixel
                                        
  ┌────────────────┐             ┌────────────────┐
    Baseline      │◄────────────│  Diff Engine   
    Stored in                    (passes if    
    __snapshots__                 diff < 0.1%) 
  └────────────────┘             └────────────────┘

Setup and Configuration

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  
  // Visual snapshot configuration
  expect: {
    toHaveScreenshot: {
      // Allow up to 0.1% pixel difference (handles antialiasing variance)
      maxDiffPixelRatio: 0.001,
      // Threshold for individual pixel color difference (0-1)
      threshold: 0.2,
      // Animation handling
      animations: 'disabled',
    },
  },

  use: {
    // Consistent viewport for reproducible screenshots
    viewport: { width: 1280, height: 720 },
    // Disable CSS animations globally for screenshots
    reducedMotion: 'reduce',
  },

  projects: [
    {
      name: 'chromium-desktop',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'mobile-safari',
      use: { ...devices['iPhone 14'] },
    },
  ],
});

Writing Visual Snapshot Tests

Component-Level Snapshots

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

test.describe('Button Component Visual Tests', () => {
  test('primary button — default state', async ({ page }) => {
    await page.goto('/components/button');

    const button = page.getByRole('button', { name: 'Submit' });
    await expect(button).toHaveScreenshot('button-primary-default.png');
  });

  test('primary button — hover state', async ({ page }) => {
    await page.goto('/components/button');

    const button = page.getByRole('button', { name: 'Submit' });
    await button.hover();
    // Wait for hover animation to settle
    await page.waitForTimeout(300);
    await expect(button).toHaveScreenshot('button-primary-hover.png');
  });

  test('primary button — disabled state', async ({ page }) => {
    await page.goto('/components/button?state=disabled');

    const button = page.getByRole('button', { name: 'Submit' });
    await expect(button).toHaveScreenshot('button-primary-disabled.png');
  });
});

Full Page Snapshots

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

test.describe('Page Visual Regression Tests', () => {
  test('homepage — light mode', async ({ page }) => {
    await page.goto('/');
    // Wait for all images and fonts to load
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot('homepage-light.png', {
      fullPage: true,
    });
  });

  test('homepage — dark mode', async ({ page }) => {
    await page.emulateMedia({ colorScheme: 'dark' });
    await page.goto('/');
    await page.waitForLoadState('networkidle');
    await expect(page).toHaveScreenshot('homepage-dark.png', {
      fullPage: true,
    });
  });

  test('blog post — typography rendering', async ({ page }) => {
    await page.goto('/blog/my-first-post');
    await page.waitForLoadState('networkidle');
    
    // Mask dynamic content that changes between runs
    await expect(page).toHaveScreenshot('blog-post.png', {
      fullPage: true,
      mask: [
        page.locator('[data-testid="publish-date"]'),   // Date changes
        page.locator('[data-testid="view-count"]'),      // View count changes
      ],
    });
  });
});

The mask option is critical for pages with dynamic content — it replaces those regions with a solid rectangle so date or count changes don't cause false failures.


Managing Baseline Images in CI

Baseline snapshots must be committed to source control so CI can compare against them:

# First time: generate baselines locally
npx playwright test tests/visual/ --update-snapshots

# Commit the generated snapshots
git add tests/visual/__snapshots__/
git commit -m "chore: add visual regression baselines"
git push

In GitHub Actions, the visual tests run against committed baselines:

# .github/workflows/visual-regression.yml
name: Visual Regression Tests

on:
  pull_request:
    branches: [main]

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '22' }
      - run: npm ci
      - run: npx playwright install chromium --with-deps

      - name: Run Visual Regression Tests
        run: npx playwright test tests/visual/

      - name: Upload Diff Report on Failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: playwright-visual-report
          path: playwright-report/
          retention-days: 7

Updating Baselines for Intentional Changes

When a designer intentionally updates a component's appearance, the baseline needs to be updated too:

# Update only the specific test whose baseline changed
npx playwright test tests/visual/buttons.spec.ts --update-snapshots

# Review the changes visually before committing
npx playwright show-report

# Commit the updated baselines
git add tests/visual/__snapshots__/buttons.spec.ts-snapshots/
git commit -m "visual: update button hover state baseline (new design system)"

Make this a deliberate, reviewed process. The PR that updates visual baselines should include a screenshot comparison in the PR description so reviewers can explicitly approve the design change.


Handling Flakiness in Visual Tests

Visual tests can be flaky due to rendering inconsistencies. Apply these techniques to stabilize them:

test('animated hero section', async ({ page }) => {
  await page.goto('/');

  // 1. Disable animations via CSS injection
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0ms !important;
        transition-duration: 0ms !important;
      }
    `,
  });

  // 2. Wait for network and fonts to fully load
  await page.waitForLoadState('networkidle');

  // 3. Scroll to trigger any lazy-loaded images
  await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
  await page.evaluate(() => window.scrollTo(0, 0));

  // 4. Small timeout for any remaining paint operations
  await page.waitForTimeout(500);

  await expect(page).toHaveScreenshot('hero-section.png');
});

Conclusion

Visual regression testing is the missing layer in most test suites. Functional tests tell you if the checkout flow completes. Visual tests tell you if it looks correct while doing so. By adding toHaveScreenshot() assertions to your Playwright suite and committing baselines to source control, you build a pixel-level safety net that catches the category of bugs that designers find first and developers find last. The cost is minimal — a few extra seconds per test run. The benefit is eliminating the "it looked fine on my machine" class of production incident.

Recommended Posts