Visual Regression Testing
Catch unintended UI changes with automated visual testing using Percy and Chromatic
Visual Regression Testing
Visual regression testing automatically detects unintended changes to your UI by comparing screenshots. It's essential for maintaining design consistency and catching CSS regressions.
Why Visual Testing?
Problems Unit Tests Don't Catch:
- CSS regressions
- Layout breaks
- Responsive design issues
- Cross-browser rendering differences
- Font/color changes
- Icon/image loading failures
Example:
// ✅ This test passes...
test('button renders', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button')).toBeInTheDocument();
});
// But these issues aren't caught:
// - Button is invisible (color: white on white)
// - Button is off-screen (position: absolute; left: -9999px)
// - Button is tiny (font-size: 1px)
// - Button overlaps other elementsVisual testing catches all of these issues automatically.
Percy (BrowserStack)
Setup
npm install --save-dev @percy/cli @percy/playwright// percy.config.yml
version: 2
static:
cleanUrls: true
snapshot:
widths:
- 375 # Mobile
- 768 # Tablet
- 1280 # Desktop
minHeight: 1024
percyCSS: |
/* Hide dynamic content */
[data-testid="timestamp"] { visibility: hidden; }
.skeleton-loader { display: none; }Usage with Playwright
// tests/visual/homepage.spec.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test.describe('Homepage Visual Tests', () => {
test('homepage renders correctly', async ({ page }) => {
await page.goto('http://localhost:3000');
// Wait for content to load
await page.waitForLoadState('networkidle');
// Take Percy snapshot
await percySnapshot(page, 'Homepage');
});
test('homepage with mobile nav open', async ({ page }) => {
await page.goto('http://localhost:3000');
// Open mobile menu
await page.click('[aria-label="Menu"]');
await percySnapshot(page, 'Homepage - Mobile Nav Open');
});
test('homepage dark mode', async ({ page }) => {
await page.goto('http://localhost:3000');
// Switch to dark mode
await page.click('[aria-label="Toggle theme"]');
await percySnapshot(page, 'Homepage - Dark Mode');
});
});Testing Different States
// tests/visual/button-states.spec.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test.describe('Button States', () => {
test('all button variants', async ({ page }) => {
await page.goto('http://localhost:3000/buttons');
// Test all states
await percySnapshot(page, 'Buttons - Default');
// Hover state
await page.hover('[data-testid="primary-button"]');
await percySnapshot(page, 'Buttons - Hover');
// Focus state
await page.focus('[data-testid="primary-button"]');
await percySnapshot(page, 'Buttons - Focus');
// Disabled state
await page.click('[data-testid="toggle-disabled"]');
await percySnapshot(page, 'Buttons - Disabled');
// Loading state
await page.click('[data-testid="toggle-loading"]');
await percySnapshot(page, 'Buttons - Loading');
});
});CI Integration
# .github/workflows/visual-tests.yml
name: Visual Tests
on: [pull_request]
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Start server
run: npm start &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run visual tests
run: npx percy exec -- npx playwright test tests/visual
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}Chromatic (Storybook)
Setup
npm install --save-dev chromatic// .storybook/main.ts
const config = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx|mdx)'],
addons: ['@storybook/addon-essentials'],
framework: '@storybook/react-vite',
};
export default config;Creating Visual Stories
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
// Chromatic parameters
chromatic: {
viewports: [320, 768, 1200],
delay: 300, // Wait 300ms before snapshot
pauseAnimationAtEnd: true,
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Primary Button',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Secondary Button',
},
};
export const Disabled: Story = {
args: {
variant: 'primary',
children: 'Disabled Button',
disabled: true,
},
};
export const Loading: Story = {
args: {
variant: 'primary',
children: 'Loading...',
isLoading: true,
},
};
// Test multiple sizes
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', flexDirection: 'column' }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};Interaction Tests
// Button.stories.tsx (continued)
import { within, userEvent } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
export const HoverState: Story = {
args: {
variant: 'primary',
children: 'Hover Me',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
// Hover over button
await userEvent.hover(button);
// Take snapshot with hover state
},
};
export const FocusState: Story = {
args: {
variant: 'primary',
children: 'Focus Me',
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
// Focus button
await userEvent.tab();
// Verify focus ring
await expect(button).toHaveFocus();
},
};Running Chromatic
// package.json
{
"scripts": {
"chromatic": "chromatic --project-token=$CHROMATIC_PROJECT_TOKEN"
}
}# Run locally
npm run chromatic
# CI
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN --exit-zero-on-changesCI Integration
# .github/workflows/chromatic.yml
name: Chromatic
on: [push, pull_request]
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history for baseline comparison
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Run Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
autoAcceptChanges: main # Auto-accept on main branchBackstopJS (Self-Hosted)
Setup
npm install --save-dev backstopjs// backstop.config.js
module.exports = {
id: 'my-app',
viewports: [
{
label: 'phone',
width: 375,
height: 667,
},
{
label: 'tablet',
width: 768,
height: 1024,
},
{
label: 'desktop',
width: 1280,
height: 1024,
},
],
scenarios: [
{
label: 'Homepage',
url: 'http://localhost:3000',
delay: 500,
selectors: ['document'],
misMatchThreshold: 0.1,
requireSameDimensions: true,
},
{
label: 'Product Page',
url: 'http://localhost:3000/products/1',
delay: 1000,
selectors: ['document'],
hideSelectors: [
'[data-testid="timestamp"]',
'.skeleton-loader',
],
},
{
label: 'Checkout',
url: 'http://localhost:3000/checkout',
delay: 500,
clickSelector: '[data-testid="accept-terms"]',
postInteractionWait: 500,
selectors: ['document'],
},
],
paths: {
bitmaps_reference: 'tests/visual/backstop_data/bitmaps_reference',
bitmaps_test: 'tests/visual/backstop_data/bitmaps_test',
html_report: 'tests/visual/backstop_data/html_report',
},
report: ['browser', 'CI'],
engine: 'playwright',
engineOptions: {
args: ['--no-sandbox'],
},
};Running BackstopJS
# Create reference screenshots
npx backstop reference
# Run tests (compare against reference)
npx backstop test
# Approve changes
npx backstop approve
# Open report
npx backstop openReportBest Practices
1. Hide Dynamic Content
// Hide elements that change on every run
await percySnapshot(page, 'Dashboard', {
percyCSS: `
[data-testid="timestamp"] { visibility: hidden; }
[data-testid="random-id"] { visibility: hidden; }
.loading-spinner { display: none; }
video { visibility: hidden; }
`,
});2. Wait for Content
// ❌ BAD: Snapshot too early
await page.goto('http://localhost:3000');
await percySnapshot(page, 'Homepage'); // May capture loading state
// ✅ GOOD: Wait for content
await page.goto('http://localhost:3000');
await page.waitForLoadState('networkidle');
await page.waitForSelector('[data-testid="content-loaded"]');
await percySnapshot(page, 'Homepage');3. Test Responsive Layouts
// Test multiple viewport sizes
const viewports = [
{ width: 375, height: 667, name: 'Mobile' },
{ width: 768, height: 1024, name: 'Tablet' },
{ width: 1280, height: 1024, name: 'Desktop' },
];
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await percySnapshot(page, `Homepage - ${viewport.name}`);
}4. Test Interactive States
// Test all interactive states
const states = [
{ name: 'Default', action: null },
{ name: 'Hover', action: () => page.hover('button') },
{ name: 'Focus', action: () => page.focus('button') },
{ name: 'Active', action: () => page.click('button', { noWaitAfter: true }) },
{ name: 'Disabled', action: () => page.click('[data-testid="toggle-disabled"]') },
];
for (const state of states) {
if (state.action) await state.action();
await percySnapshot(page, `Button - ${state.name}`);
}5. Organize Snapshots
// Use consistent naming
await percySnapshot(page, 'Component/Button - Primary');
await percySnapshot(page, 'Component/Button - Secondary');
await percySnapshot(page, 'Page/Homepage - Authenticated');
await percySnapshot(page, 'Page/Homepage - Guest');Handling Flaky Visual Tests
Ignore Specific Regions
// Percy: Ignore regions
await percySnapshot(page, 'Dashboard', {
ignore: '[data-testid="timestamp"], [data-testid="ad-banner"]',
});
// Chromatic: Ignore regions
export const Dashboard: Story = {
parameters: {
chromatic: {
ignore: ['.timestamp', '.advertisement'],
},
},
};Increase Threshold
// Allow small pixel differences
await percySnapshot(page, 'Chart', {
percyCSS: '',
minHeight: 1024,
widths: [1280],
// Allow 0.1% difference
enableJavaScript: false,
});Stabilize Animations
// Pause animations before snapshot
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
`,
});
await percySnapshot(page, 'Homepage');Cost Comparison
| Tool | Pricing | Best For |
|---|---|---|
| Percy | Free: 5K snapshots/month Team: $399/month | Small to medium teams |
| Chromatic | Free: 5K snapshots/month Team: $149/month | Storybook users |
| BackstopJS | Free (self-hosted) | Cost-sensitive projects |
| Playwright Visual | Free (DIY) | Full control needed |
When to Use Visual Testing
✅ Use visual testing for:
- Design systems and component libraries
- Marketing/landing pages (design-critical)
- E-commerce product pages
- Dashboards with complex layouts
- Cross-browser compatibility
- Responsive design testing
❌ Skip visual testing for:
- Simple CRUD apps
- Internal tools with basic UI
- Prototypes/MVPs
- Frequently changing designs
Common Pitfalls
❌ Snapshotting during loading: Captures spinners
✅ Wait for content to load
❌ Testing everything: Too many snapshots = slow + expensive
✅ Focus on critical pages/components
❌ Ignoring false positives: Reduces trust in tests
✅ Stabilize tests, tune thresholds
❌ No baseline management: Baselines become outdated
✅ Regularly review and update baselines
Visual regression testing is your design QA safety net—use it to catch UI bugs before users do.