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:
- Capturing a "baseline" screenshot of each page/component
- After each deploy, capturing a new screenshot
- Diffing the two images pixel-by-pixel
- 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}`);
}
}
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,
};
}
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/
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).