TestingVisual Regression

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/playwright

Basic 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 storybook

Storybook 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 --skip

CI/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-storybook

Advanced 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 backstopjs

Configuration

// 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=chromium

Cross-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

ToolPricingCI/CDStorybookBest For
PercyFree tier, paid plansEnterprise teams, full apps
ChromaticFree tier, paid plansStorybook component libraries
BackstopJSFree, open-sourceBudget-conscious teams
PlaywrightFree, open-sourceE2E tests with visual checks
ApplitoolsPaidAI-powered visual testing


Additional Resources