Visual regression testing sounds simple in theory: take a screenshot before a deploy, take one after, compare them. Done. But anyone who's actually tried to build this in-house knows the reality: you need Playwright or Selenium running in a container somewhere, a CI job that spins up a headless browser, storage for your baseline images, and someone who actually understands why the browser renders fonts 1px differently on Ubuntu vs macOS.
For most QA teams — especially at companies without a dedicated DevOps engineer — that overhead kills the project before it starts.
There's a simpler path: use an external screenshot API that handles the browser infrastructure for you. Here's how to build a complete visual regression workflow in under 100 lines of code.
The Setup
We'll use SnapAPI, a hosted screenshot service with a simple REST API. No browser to install, no containers to manage.
All you need:
npm install pixelmatch pngjs node-fetch
Step 1: Capture a Baseline Screenshot
Before your deploy, capture what the page looks like:
// capture-baseline.js
import fs from 'fs';
import fetch from 'node-fetch';
const SNAPAPI_ENDPOINT = 'https://api.opspawn.com/api/screenshot';
const TARGET_URL = process.env.TARGET_URL || 'https://yourapp.com';
async function captureScreenshot(url, outputPath) {
const response = await fetch(SNAPAPI_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
width: 1280,
height: 900,
fullPage: false,
}),
});
if (!response.ok) {
throw new Error(`Screenshot failed: ${response.status} ${response.statusText}`);
}
const buffer = await response.buffer();
fs.writeFileSync(outputPath, buffer);
console.log(`Saved screenshot to ${outputPath}`);
}
await captureScreenshot(TARGET_URL, './baseline.png');
Run this before your deploy:
node capture-baseline.js
Step 2: Capture a Post-Deploy Screenshot
After your deploy completes, capture the same page:
// capture-current.js
import fs from 'fs';
import fetch from 'node-fetch';
const SNAPAPI_ENDPOINT = 'https://api.opspawn.com/api/screenshot';
const TARGET_URL = process.env.TARGET_URL || 'https://yourapp.com';
async function captureScreenshot(url, outputPath) {
const response = await fetch(SNAPAPI_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
width: 1280,
height: 900,
fullPage: false,
}),
});
if (!response.ok) {
throw new Error(`Screenshot failed: ${response.status} ${response.statusText}`);
}
const buffer = await response.buffer();
fs.writeFileSync(outputPath, buffer);
console.log(`Saved screenshot to ${outputPath}`);
}
await captureScreenshot(TARGET_URL, './current.png');
Step 3: Compare and Alert
Here's the comparison script. It uses pixelmatch to do a pixel-level diff and alerts you if the visual difference exceeds a threshold:
// compare-screenshots.js
import fs from 'fs';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
const DIFF_THRESHOLD = 0.05; // 5% pixel difference triggers alert
const ALERT_WEBHOOK = process.env.SLACK_WEBHOOK_URL; // optional
function loadPng(path) {
const data = fs.readFileSync(path);
return PNG.sync.read(data);
}
async function compareScreenshots(baselinePath, currentPath, diffOutputPath) {
const baseline = loadPng(baselinePath);
const current = loadPng(currentPath);
if (baseline.width !== current.width || baseline.height !== current.height) {
console.error('Image dimensions changed!');
console.error(`Baseline: ${baseline.width}x${baseline.height}`);
console.error(`Current: ${current.width}x${current.height}`);
process.exit(1);
}
const { width, height } = baseline;
const diffImage = new PNG({ width, height });
const mismatchedPixels = pixelmatch(
baseline.data,
current.data,
diffImage.data,
width,
height,
{ threshold: 0.1 }
);
const totalPixels = width * height;
const diffPercent = mismatchedPixels / totalPixels;
// Save diff image for review
fs.writeFileSync(diffOutputPath, PNG.sync.write(diffImage));
console.log(`Diff: ${(diffPercent * 100).toFixed(2)}% (${mismatchedPixels} pixels)`);
console.log(`Diff image saved to ${diffOutputPath}`);
if (diffPercent > DIFF_THRESHOLD) {
const message = `🚨 Visual regression detected! ${(diffPercent * 100).toFixed(1)}% of pixels changed (threshold: ${DIFF_THRESHOLD * 100}%).`;
console.error(message);
// Optional: send Slack alert
if (ALERT_WEBHOOK) {
await fetch(ALERT_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: message }),
});
}
process.exit(1); // Fail CI
} else {
console.log('✅ Visual regression check passed.');
}
}
await compareScreenshots('./baseline.png', './current.png', './diff.png');
Run it after your deploy:
node compare-screenshots.js
If the diff exceeds 5%, the script exits with code 1 (fails CI) and optionally posts to Slack.
Putting It All Together in CI
Here's a GitHub Actions snippet that wires everything up:
- name: Capture baseline (pre-deploy)
run: node capture-baseline.js
env:
TARGET_URL: https://yourapp.com
- name: Deploy
run: your-deploy-command-here
- name: Wait for deploy to propagate
run: sleep 30
- name: Capture post-deploy screenshot
run: node capture-current.js
env:
TARGET_URL: https://yourapp.com
- name: Visual regression check
run: node compare-screenshots.js
env:
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Why External vs Self-Hosted?
The obvious question: why not just run Playwright yourself?
For many QA setups, you can. But external screenshot APIs shine when:
- You don't control CI: If your builds run in a locked-down environment (like some enterprise CI systems), installing Chromium is painful or blocked.
- You need consistency: Hosted APIs render from a fixed environment, eliminating "works on my machine" browser diff issues.
- You're testing multiple pages: An API call scales horizontally — you can fire off 20 screenshot requests in parallel without spinning up 20 browser instances.
- Your team isn't DevOps: A REST call is something every developer understands. A Playwright Docker setup is not.
Adjusting the Threshold
The 5% threshold is a starting point. Dial it based on your app:
- Mostly static content (docs, marketing): Use 1-2%.
- Dynamic elements (timestamps, ads, animations): Use 10-15%, or mask those regions.
- Data dashboards: Consider cropping to specific sections before comparing.
Visual regression testing doesn't have to be a DevOps project. With an external screenshot API, you can add it to your CI pipeline in an afternoon.
Try SnapAPI free at opspawn.com/snapapi — no credit card required to get started.
Top comments (0)