Visual Regression Testing
Learn how to catch unintended visual changes in your UI using visual regression testing tools like Percy, Chromatic, BackstopJS, and Playwright.
What is Visual Regression Testing?
Visual regression testing compares screenshots of your UI before and after changes to detect unintended visual differences. It catches bugs that traditional functional tests miss, such as CSS changes, layout shifts, and rendering issues.
Why Visual Regression Testing?
- Catch visual bugs: Detect unintended styling changes
- Cross-browser consistency: Ensure UI looks correct everywhere
- Responsive design: Verify layouts across screen sizes
- Component isolation: Test UI components in isolation
- Prevent regressions: Ensure new changes don’t break existing UI
- Design system compliance: Maintain visual consistency
Visual regression tests complement functional tests. Use them together for comprehensive UI testing.
Visual Bugs Caught
- CSS specificity issues
- Layout shifts
- Font rendering differences
- Image loading failures
- Responsive breakpoint issues
- Color and contrast changes
- Positioning and alignment
- Animation and transition bugs
Percy
Percy is a visual testing platform that integrates with your CI/CD pipeline to catch visual regressions automatically.
Setup
npm install --save-dev @percy/cli @percy/playwrightBasic Configuration
// percy.config.js
module.exports = {
version: 2,
static: {
// Static site snapshots
baseUrl: 'http://localhost:3000',
snapshots: [
{ name: 'Homepage', url: '/' },
{ name: 'About', url: '/about' },
{ name: 'Contact', url: '/contact' }
]
},
discovery: {
// Automatically discover pages
allowedHostnames: ['localhost'],
networkIdleTimeout: 750
}
};Playwright Integration
// tests/visual.spec.ts
import { test } from '@playwright/test';
import percySnapshot from '@percy/playwright';
test.describe('Visual regression tests', () => {
test('homepage looks correct', async ({ page }) => {
await page.goto('http://localhost:3000');
await percySnapshot(page, 'Homepage');
});
test('product page responsive', async ({ page }) => {
await page.goto('http://localhost:3000/products/123');
// Mobile
await page.setViewportSize({ width: 375, height: 667 });
await percySnapshot(page, 'Product Page - Mobile');
// Tablet
await page.setViewportSize({ width: 768, height: 1024 });
await percySnapshot(page, 'Product Page - Tablet');
// Desktop
await page.setViewportSize({ width: 1920, height: 1080 });
await percySnapshot(page, 'Product Page - Desktop');
});
test('dark mode', async ({ page }) => {
await page.goto('http://localhost:3000');
// Enable dark mode
await page.click('[data-testid="theme-toggle"]');
await page.waitForTimeout(500); // Wait for transition
await percySnapshot(page, 'Homepage - Dark Mode');
});
test('component states', async ({ page }) => {
await page.goto('http://localhost:3000/components/button');
// Default state
await percySnapshot(page, 'Button - Default');
// Hover state
await page.hover('button');
await percySnapshot(page, 'Button - Hover');
// Disabled state
await page.click('[data-testid="disable-button"]');
await percySnapshot(page, 'Button - Disabled');
});
});Advanced Percy Options
import percySnapshot from '@percy/playwright';
test('custom snapshot options', async ({ page }) => {
await page.goto('http://localhost:3000');
await percySnapshot(page, 'Homepage', {
// Test multiple widths
widths: [375, 768, 1280, 1920],
// Minimum height for screenshot
minHeight: 1024,
// Enable JavaScript
enableJavaScript: true,
// Percy CSS to customize snapshot
percyCSS: `
.ad-banner { display: none; }
.dynamic-timestamp { visibility: hidden; }
`,
// Scope snapshot to specific element
scope: '#main-content'
});
});CI/CD Integration
# .github/workflows/percy.yml
name: Percy Visual Tests
on: [push, pull_request]
jobs:
percy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Start app
run: npm start &
- name: Wait for app
run: npx wait-on http://localhost:3000
- name: Run Percy
run: npx percy exec -- npm run test:visual
env:
PERCY_TOKEN: ${{ secrets.PERCY_TOKEN }}Use Percy’s visual review workflow to approve or reject changes directly in the Percy dashboard before merging PRs.
Chromatic for Storybook
Chromatic is a visual testing service built specifically for Storybook, automating visual regression testing for component libraries.
Setup
npm install --save-dev chromatic storybookStorybook Configuration
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions'
],
framework: '@storybook/react-vite'
};
export default config;Writing Stories for Visual Testing
// 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 configuration
chromatic: {
// Delay screenshot to allow animations to complete
delay: 300,
// Disable animations for consistent screenshots
disableSnapshot: false,
// Take multiple snapshots at different viewport widths
viewports: [320, 768, 1200]
}
}
};
export default meta;
type Story = StoryObj<typeof Button>;
// Default state
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Click me'
}
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Click me'
}
};
export const Disabled: Story = {
args: {
variant: 'primary',
disabled: true,
children: 'Disabled'
}
};
// Pseudo states
export const Hover: Story = {
args: {
variant: 'primary',
children: 'Hover me'
},
parameters: {
pseudo: { hover: true }
}
};
export const Focus: Story = {
args: {
variant: 'primary',
children: 'Focus me'
},
parameters: {
pseudo: { focus: true }
}
};
// Different sizes
export const Small: Story = {
args: {
size: 'small',
children: 'Small'
}
};
export const Large: Story = {
args: {
size: 'large',
children: 'Large'
}
};
// With icons
export const WithIcon: Story = {
args: {
children: 'Download',
icon: 'download'
}
};
// Dark mode
export const DarkMode: Story = {
args: {
variant: 'primary',
children: 'Dark mode'
},
parameters: {
backgrounds: { default: 'dark' }
}
};Running Chromatic
# Build Storybook and run visual tests
npx chromatic --project-token=<your-token>
# CI mode (exit with status code on changes)
npx chromatic --project-token=<your-token> --exit-zero-on-changes
# Only build Storybook, skip visual tests
npx chromatic --build-script-name build-storybook --skipCI/CD Integration
# .github/workflows/chromatic.yml
name: Chromatic
on: push
jobs:
chromatic:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for Chromatic
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Run Chromatic
uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
buildScriptName: build-storybookAdvanced Chromatic Features
// Ignore specific elements
export const IgnoreElements: Story = {
args: {
children: 'Button with timestamp'
},
parameters: {
chromatic: {
// Ignore elements that change frequently
ignore: ['.timestamp', '[data-testid="random-id"]']
}
}
};
// Delay for animations
export const WithAnimation: Story = {
args: {
children: 'Animated button'
},
parameters: {
chromatic: {
delay: 1000 // Wait 1s for animation
}
}
};
// Pause animations
export const PauseAnimations: Story = {
args: {
children: 'Paused animation'
},
parameters: {
chromatic: {
pauseAnimationAtEnd: true
}
}
};
// Force a new snapshot
export const ForceSnapshot: Story = {
args: {
children: 'Always snapshot'
},
parameters: {
chromatic: {
disableSnapshot: false,
// Force snapshot even if story hasn't changed
forcedReRender: true
}
}
};Chromatic provides UI Review directly in GitHub PRs, allowing team members to approve or request changes to UI components.
BackstopJS
BackstopJS is an open-source visual regression testing tool that compares screenshots across different environments.
Installation
npm install --save-dev backstopjsConfiguration
// backstop.json
{
"id": "my_app_visual_tests",
"viewports": [
{
"label": "phone",
"width": 375,
"height": 667
},
{
"label": "tablet",
"width": 768,
"height": 1024
},
{
"label": "desktop",
"width": 1920,
"height": 1080
}
],
"scenarios": [
{
"label": "Homepage",
"url": "http://localhost:3000",
"referenceUrl": "http://localhost:3000",
"delay": 500,
"misMatchThreshold": 0.1
},
{
"label": "Product Page",
"url": "http://localhost:3000/products/123",
"delay": 1000,
"clickSelector": "#view-details",
"postInteractionWait": 500,
"selectors": ["#product-container"],
"misMatchThreshold": 0.2
},
{
"label": "Login Form",
"url": "http://localhost:3000/login",
"selectors": ["form#login"],
"hideSelectors": [".csrf-token"],
"removeSelectors": [".ad-banner"]
},
{
"label": "Dashboard - Dark Mode",
"url": "http://localhost:3000/dashboard",
"onBeforeScript": "puppet/onBefore.js",
"onReadyScript": "puppet/onReady.js"
}
],
"paths": {
"bitmaps_reference": "backstop_data/bitmaps_reference",
"bitmaps_test": "backstop_data/bitmaps_test",
"html_report": "backstop_data/html_report",
"ci_report": "backstop_data/ci_report"
},
"report": ["browser", "CI"],
"engine": "puppeteer",
"engineOptions": {
"args": ["--no-sandbox"]
},
"asyncCaptureLimit": 5,
"asyncCompareLimit": 50,
"debug": false,
"debugWindow": false
}Custom Scripts
// backstop_data/engine_scripts/puppet/onBefore.js
module.exports = async (page, scenario, viewport) => {
console.log('SCENARIO > ' + scenario.label);
await page.setExtraHTTPHeaders({
'Accept-Language': 'en-US'
});
};// backstop_data/engine_scripts/puppet/onReady.js
module.exports = async (page, scenario, viewport) => {
// Wait for fonts to load
await page.evaluateHandle('document.fonts.ready');
// Enable dark mode
if (scenario.label.includes('Dark Mode')) {
await page.click('[data-testid="theme-toggle"]');
await page.waitForTimeout(300);
}
// Scroll to bottom to trigger lazy loading
if (scenario.label.includes('Lazy Load')) {
await page.evaluate(() => {
window.scrollTo(0, document.body.scrollHeight);
});
await page.waitForTimeout(1000);
}
// Hide dynamic content
await page.evaluate(() => {
const dynamicElements = document.querySelectorAll('.timestamp, .random-id');
dynamicElements.forEach(el => el.style.visibility = 'hidden');
});
};Running BackstopJS
# Create reference screenshots
npm run backstop reference
# Run visual regression test
npm run backstop test
# Open report
npm run backstop openReport
# Approve changes
npm run backstop approve// package.json
{
"scripts": {
"backstop": "backstop",
"backstop:reference": "backstop reference",
"backstop:test": "backstop test",
"backstop:approve": "backstop approve",
"backstop:report": "backstop openReport"
}
}Advanced Scenarios
// backstop.json
{
"scenarios": [
{
"label": "Hover State",
"url": "http://localhost:3000/buttons",
"hoverSelector": ".btn-primary",
"clickSelector": null,
"postInteractionWait": 200
},
{
"label": "Scroll Position",
"url": "http://localhost:3000/long-page",
"scrollToSelector": "#section-5",
"delay": 500
},
{
"label": "Cookie Banner",
"url": "http://localhost:3000",
"cookiePath": "backstop_data/cookies.json",
"removeSelectors": [".cookie-banner"]
},
{
"label": "Form with Input",
"url": "http://localhost:3000/contact",
"keyPressSelectors": [
{
"selector": "#name",
"keyPress": "John Doe"
},
{
"selector": "#email",
"keyPress": "john@example.com"
}
]
},
{
"label": "Mobile Menu Open",
"url": "http://localhost:3000",
"clickSelector": ".mobile-menu-toggle",
"viewports": [{ "label": "phone", "width": 375, "height": 667 }]
}
]
}Playwright Visual Comparisons
Playwright includes built-in visual comparison features for pixel-perfect testing.
Basic Setup
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
expect: {
toHaveScreenshot: {
maxDiffPixels: 100,
maxDiffPixelRatio: 0.01
}
},
use: {
screenshot: 'only-on-failure'
}
});Visual Tests
// tests/visual.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Visual regression', () => {
test('homepage snapshot', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot('homepage.png');
});
test('full page screenshot', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot('homepage-full.png', {
fullPage: true
});
});
test('element screenshot', async ({ page }) => {
await page.goto('http://localhost:3000');
const header = page.locator('header');
await expect(header).toHaveScreenshot('header.png');
});
test('custom threshold', async ({ page }) => {
await page.goto('http://localhost:3000/animation');
// Wait for animation to settle
await page.waitForTimeout(1000);
await expect(page).toHaveScreenshot('animation.png', {
maxDiffPixels: 500, // Allow more differences
animations: 'disabled' // Disable CSS animations
});
});
test('multiple viewports', async ({ page }) => {
const viewports = [
{ width: 375, height: 667, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1920, height: 1080, name: 'desktop' }
];
for (const viewport of viewports) {
await page.setViewportSize({
width: viewport.width,
height: viewport.height
});
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`);
}
});
test('dark mode', async ({ page }) => {
await page.goto('http://localhost:3000');
// Enable dark mode
await page.emulateMedia({ colorScheme: 'dark' });
await expect(page).toHaveScreenshot('homepage-dark.png');
});
test('mask dynamic content', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot('homepage-masked.png', {
mask: [
page.locator('.timestamp'),
page.locator('.random-content'),
page.locator('[data-dynamic="true"]')
]
});
});
});Updating Snapshots
# Update all snapshots
npx playwright test --update-snapshots
# Update specific test
npx playwright test visual.spec.ts --update-snapshots
# Update only on specific OS
npx playwright test --update-snapshots --project=chromiumCross-Browser Testing
// playwright.config.ts
export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] }
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 13'] }
}
]
});Playwright stores snapshots in __screenshots__ directories. Commit these to version control and update them when visual changes are intentional.
Best Practices for Visual Testing
1. Stable Test Environment
// ❌ Bad: Unstable content
test('homepage', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot(); // Has timestamps, random data
});
// ✅ Good: Hide dynamic content
test('homepage', async ({ page }) => {
await page.goto('http://localhost:3000');
// Hide or mock dynamic content
await page.addStyleTag({
content: `
.timestamp, .random-content { visibility: hidden; }
[data-dynamic="true"] { display: none; }
`
});
await expect(page).toHaveScreenshot();
});2. Wait for Content to Load
// ✅ Good: Wait for stability
test('product page', async ({ page }) => {
await page.goto('http://localhost:3000/products/123');
// Wait for images to load
await page.waitForLoadState('networkidle');
// Wait for fonts
await page.evaluate(() => document.fonts.ready);
// Wait for specific content
await page.waitForSelector('[data-testid="product-image"]');
await expect(page).toHaveScreenshot();
});3. Disable Animations
// ✅ Good: Consistent snapshots
test('animated component', async ({ page }) => {
await page.goto('http://localhost:3000/animations');
// Disable CSS animations
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`
});
await expect(page).toHaveScreenshot();
});4. Test Responsive Designs
// ✅ Good: Test all breakpoints
const breakpoints = [
{ name: 'mobile', width: 375, height: 667 },
{ name: 'tablet-portrait', width: 768, height: 1024 },
{ name: 'tablet-landscape', width: 1024, height: 768 },
{ name: 'desktop', width: 1440, height: 900 },
{ name: 'desktop-large', width: 1920, height: 1080 }
];
breakpoints.forEach(({ name, width, height }) => {
test(`responsive design - ${name}`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot(`homepage-${name}.png`);
});
});5. Component Isolation
// ✅ Good: Test components in isolation
test('button variants', async ({ page }) => {
await page.goto('http://localhost:6006/iframe.html?id=button--primary');
const button = page.locator('button');
await expect(button).toHaveScreenshot('button-primary.png');
});Tools Comparison
| Tool | Pricing | CI/CD | Storybook | Best For |
|---|---|---|---|---|
| Percy | Free tier, paid plans | ✅ | ✅ | Enterprise teams, full apps |
| Chromatic | Free tier, paid plans | ✅ | ✅ | Storybook component libraries |
| BackstopJS | Free, open-source | ✅ | ❌ | Budget-conscious teams |
| Playwright | Free, open-source | ✅ | ❌ | E2E tests with visual checks |
| Applitools | Paid | ✅ | ✅ | AI-powered visual testing |
Related Topics
- E2E Testing - End-to-end testing
- Best Practices - Testing guidelines
- CI/CD Integration - Automated testing
- Accessibility Testing - A11y testing