Terminal.skills
Use Cases/Build Visual Regression Testing for UI Components

Build Visual Regression Testing for UI Components

Catch unintended CSS changes before they ship — screenshot every component variant, diff against baselines, and block PRs that break visual consistency across 200+ components.

#testing#vite#typescript#unit-test#mocking
Works with:claude-codeopenai-codexgemini-clicursor
$

The Problem

A design system with 200+ React components powers 4 product teams' UIs. CSS changes cause cascading visual bugs: updating a button's padding breaks card layouts, changing a color token makes text unreadable on dark backgrounds, and a z-index change hides dropdown menus. These bugs reach production 2-3 times per month because unit tests pass — the logic is correct, but the UI looks broken. Each visual bug takes 2-3 hours to find, fix, and deploy.

Step 1: Storybook Screenshot Capture

typescript
// src/visual-tests/capture.ts
import { chromium, type Browser, type Page } from 'playwright';
import { readdir } from 'fs/promises';
import { join } from 'path';

interface CaptureConfig {
  storybookUrl: string;
  outputDir: string;
  viewports: Array<{ name: string; width: number; height: number }>;
  themes: string[];
}

const defaultConfig: CaptureConfig = {
  storybookUrl: 'http://localhost:6006',
  outputDir: '.visual-tests/current',
  viewports: [
    { name: 'mobile', width: 375, height: 812 },
    { name: 'tablet', width: 768, height: 1024 },
    { name: 'desktop', width: 1440, height: 900 },
  ],
  themes: ['light', 'dark'],
};

export async function captureAllStories(config = defaultConfig): Promise<string[]> {
  const browser = await chromium.launch();
  const screenshots: string[] = [];

  try {
    // Fetch story list from Storybook API
    const page = await browser.newPage();
    await page.goto(`${config.storybookUrl}/index.json`);
    const storyIndex = await page.evaluate(() => document.body.textContent);
    const stories = JSON.parse(storyIndex!);

    for (const [id, story] of Object.entries(stories.entries as Record<string, any>)) {
      if (story.type !== 'story') continue;

      for (const viewport of config.viewports) {
        for (const theme of config.themes) {
          const filename = `${id}--${viewport.name}--${theme}.png`;
          const filepath = join(config.outputDir, filename);

          await page.setViewportSize({ width: viewport.width, height: viewport.height });

          // Navigate to story in iframe mode with theme
          const url = `${config.storybookUrl}/iframe.html?id=${id}&globals=theme:${theme}`;
          await page.goto(url, { waitUntil: 'networkidle' });

          // Wait for animations to settle
          await page.waitForTimeout(500);

          await page.screenshot({ path: filepath, fullPage: true });
          screenshots.push(filepath);
        }
      }
    }
  } finally {
    await browser.close();
  }

  return screenshots;
}

Step 2: Pixel-Level Diff Engine

typescript
// src/visual-tests/diff.ts
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename } from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';

interface DiffResult {
  filename: string;
  status: 'match' | 'changed' | 'new' | 'removed';
  diffPercent: number;
  diffPixels: number;
  diffImagePath?: string;
}

export async function compareScreenshots(
  baselineDir: string,
  currentDir: string,
  diffDir: string,
  threshold: number = 0.1 // percent difference to flag
): Promise<DiffResult[]> {
  await mkdir(diffDir, { recursive: true });
  const results: DiffResult[] = [];

  const currentFiles = await readdir(currentDir);

  for (const filename of currentFiles) {
    if (!filename.endsWith('.png')) continue;

    const currentPath = join(currentDir, filename);
    const baselinePath = join(baselineDir, filename);

    try {
      const baselineData = await readFile(baselinePath);
      const currentData = await readFile(currentPath);

      const baseline = PNG.sync.read(baselineData);
      const current = PNG.sync.read(currentData);

      // Handle size changes
      if (baseline.width !== current.width || baseline.height !== current.height) {
        results.push({
          filename, status: 'changed', diffPercent: 100, diffPixels: -1,
          diffImagePath: currentPath,
        });
        continue;
      }

      const diff = new PNG({ width: baseline.width, height: baseline.height });
      const diffPixels = pixelmatch(
        baseline.data, current.data, diff.data,
        baseline.width, baseline.height,
        { threshold: 0.1 } // per-pixel sensitivity
      );

      const totalPixels = baseline.width * baseline.height;
      const diffPercent = (diffPixels / totalPixels) * 100;

      if (diffPercent > threshold) {
        const diffPath = join(diffDir, `diff-${filename}`);
        await writeFile(diffPath, PNG.sync.write(diff));

        results.push({
          filename, status: 'changed', diffPercent, diffPixels,
          diffImagePath: diffPath,
        });
      } else {
        results.push({ filename, status: 'match', diffPercent, diffPixels });
      }
    } catch {
      // No baseline = new component
      results.push({ filename, status: 'new', diffPercent: 100, diffPixels: -1 });
    }
  }

  return results;
}

Step 3: GitHub Actions Integration

yaml
# .github/workflows/visual-test.yml
name: Visual Regression Tests
on:
  pull_request:
    paths: ['src/components/**', 'src/styles/**', 'src/tokens/**']

jobs:
  visual-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22 }
      - run: npm ci

      - name: Build Storybook
        run: npx storybook build -o storybook-static

      - name: Start Storybook
        run: npx http-server storybook-static -p 6006 &
        
      - name: Wait for Storybook
        run: npx wait-on http://localhost:6006

      - name: Download baselines
        uses: actions/download-artifact@v4
        with:
          name: visual-baselines
          path: .visual-tests/baseline
        continue-on-error: true

      - name: Capture screenshots
        run: npx tsx src/visual-tests/capture.ts

      - name: Compare screenshots
        id: diff
        run: npx tsx src/visual-tests/diff.ts > diff-report.json

      - name: Post PR comment with diffs
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(fs.readFileSync('diff-report.json'));
            const changed = results.filter(r => r.status === 'changed');
            
            if (changed.length === 0) {
              await github.rest.issues.createComment({
                owner: context.repo.owner, repo: context.repo.repo,
                issue_number: context.issue.number,
                body: '✅ **Visual Tests Passed** — No visual changes detected.',
              });
              return;
            }
            
            let body = `## 🖼️ Visual Changes Detected (${changed.length})\n\n`;
            for (const c of changed.slice(0, 20)) {
              body += `- **${c.filename}**: ${c.diffPercent.toFixed(2)}% changed (${c.diffPixels} pixels)\n`;
            }
            body += '\n> Review the diff images in the artifacts to approve or fix these changes.';
            
            await github.rest.issues.createComment({
              owner: context.repo.owner, repo: context.repo.repo,
              issue_number: context.issue.number, body,
            });

      - name: Fail on unexpected changes
        run: |
          CHANGED=$(cat diff-report.json | node -e "
            const d=[];process.stdin.on('data',c=>d.push(c));
            process.stdin.on('end',()=>{
              const r=JSON.parse(d.join(''));
              console.log(r.filter(x=>x.status==='changed').length)
            })")
          if [ "$CHANGED" -gt "0" ]; then
            echo "❌ ${CHANGED} visual changes need review"
            exit 1
          fi

      - name: Upload baselines
        if: github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v4
        with:
          name: visual-baselines
          path: .visual-tests/current
          retention-days: 90

Results

  • Visual bugs in production: dropped from 2-3/month to zero
  • 200+ components: every variant screenshotted across 3 viewports × 2 themes = 1,200+ screenshots
  • Unintended CSS changes: caught in PR review with pixel-diff images
  • Design token changes: immediately see impact across all components
  • Review confidence: designers approve PRs with visual proof, not guesswork
  • CI time: 4 minutes for full visual regression suite
  • Time saved: 6-9 hours/month previously spent finding and fixing visual bugs