Visual Regression Testing
Catch unintended visual changes before they reach production. Covers screenshot comparison, pixel-level diffing, component visual testing, responsive viewport testing, and integrating visual regression into CI/CD pipelines.
A CSS change that fixes a button on one page breaks the layout on another. A font update shifts text alignment across the entire application. A dependency upgrade changes the default padding. Visual regression testing catches these changes automatically by comparing screenshots of the current state against a known-good baseline.
How Visual Regression Testing Works
1. Capture baseline screenshots (approved "golden" state)
2. Run test suite → capture new screenshots
3. Pixel-by-pixel comparison
4. Highlight differences
5. Human reviews: approve (update baseline) or reject (it's a bug)
Tools
| Tool | Type | Best For |
|---|---|---|
| Percy (BrowserStack) | Cloud-based | Full-page, cross-browser |
| Chromatic | Cloud-based | Storybook component testing |
| Playwright | Open source | E2E + visual testing |
| BackstopJS | Open source | Full-page regression |
| Loki | Open source | Storybook-based, self-hosted |
Playwright Visual Testing
const { test, expect } = require('@playwright/test');
test('order page visual regression', async ({ page }) => {
await page.goto('/orders');
await page.waitForSelector('.order-list');
// Full page screenshot comparison
await expect(page).toHaveScreenshot('order-page.png', {
fullPage: true,
threshold: 0.2, // 0.2% pixel difference tolerance
maxDiffPixels: 100, // Allow up to 100 different pixels
});
});
test('button states visual regression', async ({ page }) => {
await page.goto('/components/button');
const button = page.locator('.primary-button');
// Default state
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 regression', async ({ page }) => {
// Mobile
await page.setViewportSize({ width: 375, height: 812 });
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard-mobile.png');
// Tablet
await page.setViewportSize({ width: 768, height: 1024 });
await expect(page).toHaveScreenshot('dashboard-tablet.png');
// Desktop
await page.setViewportSize({ width: 1440, height: 900 });
await expect(page).toHaveScreenshot('dashboard-desktop.png');
});
Component Testing with Storybook + Chromatic
// Button.stories.js
export default {
title: 'Components/Button',
component: Button,
};
export const Primary = { args: { variant: 'primary', children: 'Click me' } };
export const Secondary = { args: { variant: 'secondary', children: 'Cancel' } };
export const Loading = { args: { variant: 'primary', loading: true, children: 'Saving...' } };
export const Disabled = { args: { variant: 'primary', disabled: true, children: 'Submit' } };
// Each story automatically gets visual regression snapshots in Chromatic
# CI integration
- name: Run Chromatic
run: npx chromatic --project-token $CHROMATIC_TOKEN
# Captures snapshots of every story
# Compares against baseline
# Blocks PR if unapproved changes exist
Handling Dynamic Content
// Mask dynamic elements (timestamps, avatars, ads)
await expect(page).toHaveScreenshot('page.png', {
mask: [
page.locator('.timestamp'),
page.locator('.user-avatar'),
page.locator('.advertisement'),
],
});
// Freeze animations
await page.addStyleTag({
content: `*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}`
});
// Wait for fonts to load
await page.evaluate(() => document.fonts.ready);
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| Testing entire pages only | Cannot isolate which component changed | Component-level + page-level tests |
| Zero tolerance threshold | Flaky tests from anti-aliasing differences | Set appropriate pixel threshold |
| No masking of dynamic content | False positives from timestamps, avatars | Mask or freeze dynamic elements |
| Manual baseline management | Baselines drift, tests abandoned | Automated baseline management (Chromatic, Percy) |
| Running only on one viewport | Mobile regressions missed | Test at minimum 3 viewport sizes |
Visual regression testing does not replace other testing — it catches the category of bugs that code-level tests cannot: the visual ones.