DEV Community

DevToolsmith
DevToolsmith

Posted on

Visual Regression Testing with Screenshot APIs: Catch UI Bugs Before Users Do

Unit tests check logic. Integration tests check API contracts. But neither catches the CSS regression that moves your checkout button off-screen in Safari, or the z-index bug that hides your navigation on mobile. Visual regression testing fills this gap — and with a screenshot API, it's surprisingly easy to set up.

The Core Concept

Visual regression testing works by:

  1. Capturing a "baseline" screenshot of each page/component
  2. After each deploy, capturing a new screenshot
  3. Diffing the two images pixel-by-pixel
  4. Flagging any changes above a threshold as potential regressions

The diff highlights exactly where things changed — so you see "the nav bar is 2px taller" or "this button moved 40px to the right" immediately.

Setting Up a Baseline

First, capture reference screenshots of all critical pages:

const fs   = require('fs');
const path = require('path');

const PAGES_TO_MONITOR = [
  { name: 'homepage',  url: 'https://yourapp.com', viewport: '1920x1080' },
  { name: 'login',     url: 'https://yourapp.com/login', viewport: '1280x800' },
  { name: 'dashboard', url: 'https://yourapp.com/dashboard', viewport: '1440x900' },
  { name: 'mobile-home', url: 'https://yourapp.com', viewport: '390x844' },
  { name: 'pricing',   url: 'https://yourapp.com/pricing', viewport: '1280x800' },
];

async function captureBaseline() {
  const baselineDir = './visual-tests/baseline';
  fs.mkdirSync(baselineDir, { recursive: true });

  for (const page of PAGES_TO_MONITOR) {
    const [width, height] = page.viewport.split('x').map(Number);
    const url = `https://captureapi.dev/v1/screenshot?${new URLSearchParams({
      url:      page.url,
      width:    width.toString(),
      height:   height.toString(),
      format:   'png',
      full_page: 'false',
    })}`;

    const response = await fetch(url, {
      headers: { 'Authorization': `Bearer ${process.env.CAPTURE_API_KEY}` },
    });

    const buffer = Buffer.from(await response.arrayBuffer());
    fs.writeFileSync(path.join(baselineDir, `${page.name}.png`), buffer);
    console.log(`✅ Baseline captured: ${page.name}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Comparing Against Baseline

After each deploy, capture the same pages and diff them:

const { createCanvas, loadImage } = require('canvas');

async function diffImages(baselinePath, currentPath) {
  const [baseline, current] = await Promise.all([
    loadImage(baselinePath),
    loadImage(currentPath),
  ]);

  if (baseline.width !== current.width || baseline.height !== current.height) {
    return { changed: true, reason: 'Dimensions changed', pixelDiff: Infinity };
  }

  const canvas = createCanvas(baseline.width, baseline.height);
  const ctx    = canvas.getContext('2d');

  ctx.drawImage(baseline, 0, 0);
  const baselineData = ctx.getImageData(0, 0, canvas.width, canvas.height);

  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawImage(current, 0, 0);
  const currentData = ctx.getImageData(0, 0, canvas.width, canvas.height);

  let diffCount = 0;
  const diffCanvas = createCanvas(baseline.width, baseline.height);
  const diffCtx    = diffCanvas.getContext('2d');
  const diffImage  = diffCtx.createImageData(canvas.width, canvas.height);

  for (let i = 0; i < baselineData.data.length; i += 4) {
    const rDiff = Math.abs(baselineData.data[i]   - currentData.data[i]);
    const gDiff = Math.abs(baselineData.data[i+1] - currentData.data[i+1]);
    const bDiff = Math.abs(baselineData.data[i+2] - currentData.data[i+2]);
    const diff  = (rDiff + gDiff + bDiff) / 3;

    if (diff > 10) { // threshold for noise
      diffCount++;
      diffImage.data[i]   = 255; // red highlight
      diffImage.data[i+1] = 0;
      diffImage.data[i+2] = 0;
      diffImage.data[i+3] = 255;
    } else {
      // Dim unchanged pixels for contrast
      diffImage.data[i]   = baselineData.data[i]   * 0.3;
      diffImage.data[i+1] = baselineData.data[i+1] * 0.3;
      diffImage.data[i+2] = baselineData.data[i+2] * 0.3;
      diffImage.data[i+3] = 255;
    }
  }

  const totalPixels  = canvas.width * canvas.height;
  const diffPercent  = (diffCount / totalPixels) * 100;

  diffCtx.putImageData(diffImage, 0, 0);
  const diffPng = diffCanvas.toBuffer('image/png');

  return {
    changed:      diffPercent > 0.1, // 0.1% threshold
    pixelDiff:    diffCount,
    diffPercent:  diffPercent.toFixed(3),
    diffImage:    diffPng,
  };
}
Enter fullscreen mode Exit fullscreen mode

GitHub Actions Integration

# .github/workflows/visual-regression.yml
name: Visual Regression
on: [deployment_status]

jobs:
  visual-test:
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { name: visual-baseline, path: visual-tests/baseline }
      - name: Run visual regression tests
        env:
          CAPTURE_API_KEY: ${{ secrets.CAPTURE_API_KEY }}
          TARGET_URL: ${{ github.event.deployment_status.target_url }}
        run: node scripts/visual-regression.js
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diffs
          path: visual-tests/diffs/
Enter fullscreen mode Exit fullscreen mode

Practical Tips

Anti-flake strategies:

  • Wait for network idle before screenshotting (the API handles this automatically)
  • Exclude dynamic content (timestamps, ads) with CSS: .timestamp { visibility: hidden; }
  • Use a small pixel threshold (0.1%) to avoid false positives from antialiasing

What to monitor:

  • All pages in your primary user flow (login → dashboard → key action → checkout)
  • Mobile viewports — these regress most often
  • Email clients if you send HTML emails

Updating baselines: When you intentionally change the UI, update the baseline images as part of your PR. Store them in git or a dedicated artifact storage.

CaptureAPI works well for this use case with its CSS selector targeting (capture just the component you changed) and batch endpoint (check 20 pages in one request).

Top comments (0)