Visual Regression Testing
Catch unintended visual changes with Chromatic, Percy, and Playwright screenshot comparisons integrated into CI pipelines
What is Visual Regression Testing?
Visual regression testing captures screenshots of your UI and compares them against approved baselines. It catches CSS regressions, layout shifts, and unintended visual changes that functional tests miss. A button might still work correctly but look completely wrong -- visual tests catch that.
📸 Why Visual Testing:
Functional tests verify behavior; visual tests verify appearance. Both are needed for a complete quality story, especially for design systems and component libraries.
Playwright Visual Comparisons
// Built-in Playwright screenshot assertions
import { test, expect } from '@playwright/test';
test('homepage visual check', async ({ page }) => {
await page.goto('/');
// Wait for animations and fonts
await page.waitForLoadState('networkidle');
// Full page comparison
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixelRatio: 0.01, // 1% tolerance
});
});
test('button states', async ({ page }) => {
await page.goto('/components/button');
// Element-level screenshot
const button = page.getByRole('button', { name: 'Submit' });
await expect(button).toHaveScreenshot('button-default.png');
// Hover state
await button.hover();
await expect(button).toHaveScreenshot('button-hover.png');
// Focus state
await button.focus();
await expect(button).toHaveScreenshot('button-focus.png');
});
test('responsive layout', async ({ page }) => {
await page.goto('/dashboard');
// Test multiple viewports
await page.setViewportSize({ width: 1920, height: 1080 });
await expect(page).toHaveScreenshot('dashboard-desktop.png');
await page.setViewportSize({ width: 768, height: 1024 });
await expect(page).toHaveScreenshot('dashboard-tablet.png');
await page.setViewportSize({ width: 375, height: 667 });
await expect(page).toHaveScreenshot('dashboard-mobile.png');
});
// Handle dynamic content (dates, avatars, ads)
test('page with dynamic content', async ({ page }) => {
await page.goto('/profile');
// Mask dynamic elements
await expect(page).toHaveScreenshot('profile.png', {
mask: [
page.locator('.timestamp'),
page.locator('.avatar'),
page.locator('.ad-banner'),
],
});
});
// Update snapshots: npx playwright test --update-snapshots
Chromatic with Storybook
# Install Chromatic
npm install --save-dev chromatic
# Run Chromatic (publishes Storybook and captures screenshots)
npx chromatic --project-token=YOUR_TOKEN
// Button.stories.tsx - Storybook stories become visual tests
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'danger'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
// Each story = one visual test in Chromatic
export const Primary: Story = {
args: { children: 'Click me', variant: 'primary' },
};
export const Secondary: Story = {
args: { children: 'Click me', variant: 'secondary' },
};
export const Disabled: Story = {
args: { children: 'Disabled', disabled: true },
};
export const Loading: Story = {
args: { children: 'Loading...', loading: true },
};
// Chromatic-specific configuration
Primary.parameters = {
chromatic: {
viewports: [320, 768, 1200], // Test at multiple widths
delay: 300, // Wait for animations
},
};
Percy for Cross-Browser Snapshots
# Install Percy
npm install --save-dev @percy/cli @percy/playwright
# Run with Percy
npx percy exec -- npx playwright test
// tests/visual.spec.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test('Percy visual test', async ({ page }) => {
await page.goto('/pricing');
await page.waitForLoadState('networkidle');
// Capture snapshot across browsers configured in Percy
await percySnapshot(page, 'Pricing Page');
});
test('interactive component snapshots', async ({ page }) => {
await page.goto('/components/accordion');
await percySnapshot(page, 'Accordion - Collapsed');
await page.click('[data-testid="accordion-item-1"]');
await percySnapshot(page, 'Accordion - First Item Open');
});
CI Pipeline Integration
# .github/workflows/visual-tests.yml
name: Visual Regression Tests
on: [pull_request]
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed for baseline comparison
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps
# Playwright built-in visual tests
- run: npx playwright test --grep @visual
continue-on-error: true
# Or Chromatic
- run: npx chromatic --auto-accept-changes=main
env:
CHROMATIC_PROJECT_TOKEN: \${{ secrets.CHROMATIC_TOKEN }}
# Upload results
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diffs
path: test-results/
Managing Baselines:
- Playwright: Commit snapshot files to git. Update with --update-snapshots.
- Chromatic: Baselines stored in cloud. Accept or reject changes in the web UI.
- Percy: Uses the main branch build as the baseline automatically.
Key Takeaways
- Visual regression tests complement functional tests -- they catch CSS and layout bugs
- Use Playwright for free built-in visual testing or Chromatic/Percy for cloud-hosted reviews
- Mask dynamic content (dates, avatars) to avoid false positives
- Test at multiple viewports and browser widths
- Integrate into PR workflows so visual changes require explicit approval