How to Build a Visual Regression Testing Pipeline

Visual regressions are the bugs that slip past unit tests, past code review, and straight into production. A CSS specificity change shifts a button two pixels. A dependency upgrade rewrites a font stack. A third-party script injects a banner. All invisible to linters, all visible to users.
This guide walks through a GitHub Actions pipeline that captures screenshots of production and PR preview URLs, diffs them with pixelmatch, and blocks merges when pixel differences exceed a threshold. It uses the Rendex screenshot tool so you get a real browser rendering the page rather than a simulated DOM.
How Visual Regression Testing Works
The pipeline has three steps:
- Baseline capture: Screenshot the current production URL to establish what the page should look like.
- PR preview capture: Screenshot the same page on the PR preview deploy.
- Pixel diff: Compare the two images. If the mismatch ratio exceeds a threshold, fail the check and attach a diff image to the PR.
For visual regression testing, consistency matters more than speed. The same browser engine, the same viewport, the same device pixel ratio must produce the screenshots. Using a hosted API keeps this consistent across CI runners.
Prerequisites
- A Rendex API key (free tier: 100 calls/month)
- A GitHub repo with PR preview deploys (Vercel, Netlify, Railway)
- Node.js 20+ in your CI environment (for the pixelmatch diffing step)
Add your key to GitHub Secrets as RENDEX_API_KEY. Get a free key at rendex.dev/login.
Step 1: Capture Screenshots
The capture script hits the Rendex screenshot API and saves both images locally. Use waitUntil: "networkidle2" so the page is fully painted before capture, and deviceScaleFactor: 1 to keep image sizes manageable for diffing.
// scripts/capture.mjs
// Usage: node scripts/capture.mjs <url> <output-path>
// Requires: RENDEX_API_KEY env var
import { writeFileSync } from "fs";
const [, , url, outputPath] = process.argv;
if (!url || !outputPath) {
console.error("Usage: node capture.mjs <url> <output>");
process.exit(1);
}
const API_KEY = process.env.RENDEX_API_KEY;
if (!API_KEY) {
console.error("RENDEX_API_KEY is not set — get one at https://rendex.dev/login");
process.exit(1);
}
const res = await fetch("https://api.rendex.dev/v1/screenshot", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
format: "png",
width: 1280,
height: 800,
fullPage: true,
waitUntil: "networkidle2",
deviceScaleFactor: 1, // keep 1x for diff accuracy
blockAds: true,
}),
});
if (!res.ok) {
const text = await res.text();
console.error(`Capture failed (${res.status}): ${text}`);
process.exit(1);
}
const buffer = Buffer.from(await res.arrayBuffer());
writeFileSync(outputPath, buffer);
console.log(`Saved ${buffer.length} bytes → ${outputPath}`);Step 2: Diff the Images
pixelmatch compares two PNG buffers and returns the number of differing pixels. The diff image highlights changes in red, making it easy to spot exactly what moved or changed.
npm install pixelmatch pngjs// scripts/diff.mjs
// Usage: node scripts/diff.mjs <before.png> <after.png> <diff.png>
// Exits with code 1 if pixel mismatch exceeds threshold
import { readFileSync, writeFileSync } from "fs";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
const [, , beforePath, afterPath, diffPath] = process.argv;
const THRESHOLD = 0.1; // 0.0–1.0 per-pixel color threshold
const MAX_DIFF_RATIO = 0.02; // fail if >2% of pixels differ
function loadPng(path) {
return PNG.sync.read(readFileSync(path));
}
const before = loadPng(beforePath);
const after = loadPng(afterPath);
// Resize if dimensions differ (preview deploy may have different height)
const width = Math.min(before.width, after.width);
const height = Math.min(before.height, after.height);
const diff = new PNG({ width, height });
const mismatch = pixelmatch(
before.data,
after.data,
diff.data,
width,
height,
{ threshold: THRESHOLD, includeAA: false },
);
const totalPixels = width * height;
const ratio = mismatch / totalPixels;
writeFileSync(diffPath, PNG.sync.write(diff));
console.log(
`Mismatch: ${mismatch} / ${totalPixels} pixels (${(ratio * 100).toFixed(2)}%)`,
);
if (ratio > MAX_DIFF_RATIO) {
console.error(
`FAIL: ${(ratio * 100).toFixed(2)}% exceeds threshold ${MAX_DIFF_RATIO * 100}%`,
);
process.exit(1);
}
console.log("PASS: diff within threshold");Step 3: GitHub Actions Workflow
The workflow runs on every pull request. It extracts the preview URL from a Vercel deployment, captures both screenshots, runs the diff, and uploads all three images as artifacts so you can inspect them.
name: Visual Regression
on:
pull_request:
branches: [main]
jobs:
visual-diff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install diff dependencies
run: npm install pixelmatch pngjs
- name: Wait for preview deploy
id: preview
# Replace this with your preview URL resolver.
# Vercel sets VERCEL_PREVIEW_URL in PR environments.
# For Netlify, parse the deploy-preview URL from PR comments.
run: |
# Example: hard-code preview URL pattern for Vercel
PREVIEW_URL="https://${{ github.event.repository.name }}-git-${{ github.head_ref }}-${{ github.repository_owner }}.vercel.app"
echo "url=${PREVIEW_URL}" >> "$GITHUB_OUTPUT"
- name: Capture production screenshot
env:
RENDEX_API_KEY: ${{ secrets.RENDEX_API_KEY }}
run: |
node scripts/capture.mjs https://yoursite.com before.png
- name: Capture preview screenshot
env:
RENDEX_API_KEY: ${{ secrets.RENDEX_API_KEY }}
run: |
node scripts/capture.mjs ${{ steps.preview.outputs.url }} after.png
- name: Diff images
run: node scripts/diff.mjs before.png after.png diff.png
- name: Upload screenshots as artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: visual-regression-${{ github.sha }}
path: |
before.png
after.png
diff.pngMulti-Page Batching
For sites with multiple critical pages, run captures in parallel using the batch endpoint. This uses one API call instead of one per page and returns results via webhook or polling.
// scripts/batch-capture.mjs
// Captures multiple pages in one batch request and polls until complete.
import { writeFileSync } from "fs";
const PAGES = [
{ url: process.env.BASE_URL + "/", name: "home" },
{ url: process.env.BASE_URL + "/pricing", name: "pricing" },
{ url: process.env.BASE_URL + "/docs", name: "docs" },
];
const API_KEY = process.env.RENDEX_API_KEY;
const captureDefaults = {
format: "png",
width: 1280,
height: 800,
fullPage: true,
waitUntil: "networkidle2",
deviceScaleFactor: 1,
};
// Submit batch
const batchRes = await fetch("https://api.rendex.dev/v1/screenshot/batch", {
method: "POST",
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
urls: PAGES.map((p) => ({ url: p.url })),
defaults: captureDefaults,
}),
});
const { batchId } = await batchRes.json();
console.log("Batch submitted:", batchId);
// Poll until complete
let batch;
do {
await new Promise((r) => setTimeout(r, 3000));
const statusRes = await fetch(
`https://api.rendex.dev/v1/batches/${batchId}`,
{ headers: { Authorization: `Bearer ${API_KEY}` } },
);
batch = await statusRes.json();
console.log(`Status: ${batch.status} (${batch.completedJobs}/${batch.totalJobs})`);
} while (batch.status !== "completed" && batch.status !== "failed");
// Download images
for (let i = 0; i < batch.jobs.length; i++) {
const job = batch.jobs[i];
if (job.status !== "completed") continue;
const imgRes = await fetch(job.imageUrl);
const buf = Buffer.from(await imgRes.arrayBuffer());
writeFileSync(`${PAGES[i].name}.png`, buf);
console.log(`Saved ${PAGES[i].name}.png`);
}Production Considerations
A few things worth handling before relying on this in CI:
- Dynamic content: Timestamps, ads, and carousels will always diff. Use the
selectorparameter to capture only a stable region, or inject CSS via thecssparameter to hide dynamic elements before capture. - Auth-gated pages: For pages behind a login, use the
jsparameter to inject session tokens or cookies into the page before capture. - Threshold tuning: Start at 2% and tighten after false positives stabilize. Font rendering can vary slightly between browser builds. A
threshold: 0.1per-pixel tolerance in pixelmatch catches layout shifts while tolerating minor antialiasing differences. - Storage: Upload diff images to a persistent store (S3, R2, GitHub artifacts) so you can review them after the job expires. GitHub artifact retention defaults to 90 days.
- Free tier limits: At 100 calls/month on the free tier, a pipeline checking 3 pages on every PR will handle about 16 PRs per month before needing an upgrade. See pricing for higher limits.
Next Steps
The same screenshot capture works for broader visual testing use cases: monitoring component libraries for regressions, generating snapshot baselines for Storybook, or diffing email templates across clients.
Ready to add visual regression testing to your pipeline? Get a free API key (100 calls/month, no credit card required) or test a capture with the free screenshot tool before wiring it into CI.