Front-end Engineering Lab
PatternsTesting Strategies

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 elements

Visual 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-changes

CI 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 branch

BackstopJS (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 openReport

Best 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

ToolPricingBest For
PercyFree: 5K snapshots/month
Team: $399/month
Small to medium teams
ChromaticFree: 5K snapshots/month
Team: $149/month
Storybook users
BackstopJSFree (self-hosted)Cost-sensitive projects
Playwright VisualFree (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.

On this page