DEV Community

Mack
Mack

Posted on

Build a Visual Regression Testing Pipeline in Under 50 Lines

Visual regression testing catches UI bugs that unit tests miss. A button that shifted 3px left. A font that didn't load. A CSS change that broke your checkout page.

Most visual testing tools are expensive, complex, or both. Here's how to build a lightweight pipeline with just a screenshot API and a bit of Node.js.

The Architecture

1. Capture baseline screenshots of key pages
2. On each PR/deploy, capture new screenshots
3. Compare pixel-by-pixel
4. Alert on differences above threshold
Enter fullscreen mode Exit fullscreen mode

Simple. No Selenium grid. No browser farm. Just HTTP calls.

Step 1: Define Your Pages

// pages.json
[
  { "name": "homepage", "url": "https://yourapp.com" },
  { "name": "pricing", "url": "https://yourapp.com/pricing" },
  { "name": "login", "url": "https://yourapp.com/login" },
  { "name": "dashboard", "url": "https://yourapp.com/dashboard" }
]
Enter fullscreen mode Exit fullscreen mode

Step 2: Capture Screenshots

Using a screenshot API keeps things simple — no local browser dependencies, consistent rendering, and it works in CI.

const fs = require("fs");
const pages = require("./pages.json");

async function captureAll(outputDir) {
  fs.mkdirSync(outputDir, { recursive: true });

  for (const page of pages) {
    const response = await fetch(
      `https://rendly-api.fly.dev/api/v1/screenshots`,
      {
        method: "POST",
        headers: {
          "Authorization": `Bearer ${process.env.RENDLY_API_KEY}`,
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          url: page.url,
          width: 1280,
          height: 800,
          full_page: true,
          format: "png"
        })
      }
    );

    const data = await response.json();
    const img = Buffer.from(data.image, "base64");
    fs.writeFileSync(`${outputDir}/${page.name}.png`, img);
    console.log(`✓ ${page.name}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Compare Images

The pixelmatch library is perfect for this — fast, zero dependencies, battle-tested.

const { PNG } = require("pngjs");
const pixelmatch = require("pixelmatch");

function compareImages(baselinePath, currentPath) {
  const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
  const current = PNG.sync.read(fs.readFileSync(currentPath));

  const { width, height } = baseline;
  const diff = new PNG({ width, height });

  const mismatchedPixels = pixelmatch(
    baseline.data, current.data, diff.data,
    width, height,
    { threshold: 0.1 }
  );

  const totalPixels = width * height;
  const diffPercent = (mismatchedPixels / totalPixels) * 100;

  return { diffPercent, diffImage: PNG.sync.write(diff) };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Put It Together

async function visualRegression() {
  await captureAll("./screenshots/current");

  const pages = require("./pages.json");
  const results = [];

  for (const page of pages) {
    const baselinePath = `./screenshots/baseline/${page.name}.png`;
    const currentPath = `./screenshots/current/${page.name}.png`;

    if (!fs.existsSync(baselinePath)) {
      console.log(`⚠ No baseline for ${page.name}, creating one`);
      fs.copyFileSync(currentPath, baselinePath);
      continue;
    }

    const { diffPercent, diffImage } = compareImages(baselinePath, currentPath);

    results.push({
      page: page.name,
      diffPercent: diffPercent.toFixed(2),
      passed: diffPercent < 0.5
    });

    if (diffPercent >= 0.5) {
      fs.writeFileSync(`./screenshots/diff/${page.name}.png`, diffImage);
    }
  }

  const failed = results.filter(r => !r.passed);
  if (failed.length > 0) {
    console.error("\n❌ Visual regression detected:");
    failed.forEach(f => console.error(`  ${f.page}: ${f.diffPercent}% different`));
    process.exit(1);
  }

  console.log("\n✅ All pages match baseline");
}

visualRegression();
Enter fullscreen mode Exit fullscreen mode

That's ~45 lines of actual logic. The entire pipeline.

Add It to CI

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

jobs:
  visual-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install pixelmatch pngjs
      - run: node visual-regression.js
        env:
          RENDLY_API_KEY: ${{ secrets.RENDLY_API_KEY }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: visual-diffs
          path: screenshots/diff/
Enter fullscreen mode Exit fullscreen mode

Now every PR gets automatically checked for visual changes. Diff images are uploaded as artifacts when something breaks.

Why This Works

  1. No browser management — the screenshot API handles rendering
  2. Consistent results — same rendering environment every time
  3. Fast — parallel API calls, no Selenium startup time
  4. CI-friendly — just Node.js and HTTP calls
  5. Cheap — a few screenshots per PR is minimal usage

When to Update Baselines

When a visual change is intentional:

# Regenerate baselines
node visual-regression.js --update-baseline
Enter fullscreen mode Exit fullscreen mode

Add a flag to your script that copies current screenshots to baseline instead of comparing.

Going Further

  • Responsive testing: capture at multiple widths (375, 768, 1280)
  • Dark mode: toggle color scheme in the screenshot request
  • Slack alerts: post diff images to a channel on failure
  • Scheduled monitoring: run nightly against production

Visual regression doesn't need to be complicated. A screenshot API, a comparison library, and 50 lines of code. Ship it.


Built with Rendly — screenshot and image generation API for developers.

Top comments (0)