A visual regression is when a code change unexpectedly alters the appearance of your UI - a button moves 2px, a font size changes, a layout breaks on mobile. Unit and integration tests won't catch these. Visual regression testing will.
This guide shows you how to build a lightweight visual regression pipeline using a screenshot API, without the overhead of tools like Percy or Chromatic.
How Visual Regression Testing Works
The concept is simple:
- Baseline: capture screenshots of your app in its "correct" state and store them.
- Test: on every PR or deploy, capture screenshots of the same pages.
- Diff: compare new screenshots against the baseline pixel-by-pixel.
- Alert: if the diff exceeds a threshold, fail the build and show the diff image.
Step 1: Define Your Test Pages
Start with a list of URLs that represent critical UI surfaces:
// test-pages.js
module.exports = [
{ name: 'home', url: 'https://staging.yourapp.com/' },
{ name: 'pricing', url: 'https://staging.yourapp.com/pricing' },
{ name: 'dashboard', url: 'https://staging.yourapp.com/dashboard', auth: true },
{ name: 'mobile-home', url: 'https://staging.yourapp.com/', width: 390, height: 844 },
];
Step 2: Capture Baseline Screenshots
const axios = require('axios');
const fs = require('fs');
const path = require('path');
const pages = require('./test-pages');
const API_KEY = process.env.SCREENSHOT_API_KEY;
const BASE_DIR = './screenshots/baseline';
fs.mkdirSync(BASE_DIR, { recursive: true });
for (const page of pages) {
const response = await axios.get('https://api.screenshotcore.com/v1/screenshot', {
headers: { Authorization: `Bearer ${API_KEY}` },
params: {
url: page.url,
width: page.width || 1280,
height: page.height || 800,
full_page: false,
wait_until: 'networkidle',
},
responseType: 'arraybuffer',
});
fs.writeFileSync(path.join(BASE_DIR, `${page.name}.png`), response.data);
console.log(`Baseline captured: ${page.name}`);
}
Run this once and commit the baseline images to your repository (or store them in S3).
Step 3: Capture and Compare in CI
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');
const fs = require('fs');
const path = require('path');
async function runVisualTests() {
const pages = require('./test-pages');
const failures = [];
for (const page of pages) {
// 1. Capture new screenshot
const response = await axios.get('https://api.screenshotcore.com/v1/screenshot', {
headers: { Authorization: `Bearer ${process.env.SCREENSHOT_API_KEY}` },
params: { url: page.url, width: page.width || 1280, height: page.height || 800, wait_until: 'networkidle' },
responseType: 'arraybuffer',
});
const newBuffer = Buffer.from(response.data);
const newPng = PNG.sync.read(newBuffer);
// 2. Load baseline
const baselineBuffer = fs.readFileSync(path.join('./screenshots/baseline', `${page.name}.png`));
const baselinePng = PNG.sync.read(baselineBuffer);
// 3. Diff
const { width, height } = baselinePng;
const diffPng = new PNG({ width, height });
const numDiffPixels = pixelmatch(
baselinePng.data, newPng.data, diffPng.data,
width, height,
{ threshold: 0.1 }
);
const diffPercent = (numDiffPixels / (width * height)) * 100;
if (diffPercent > 0.5) { // fail if more than 0.5% of pixels changed
fs.writeFileSync(`./screenshots/diff-${page.name}.png`, PNG.sync.write(diffPng));
failures.push({ page: page.name, diffPercent: diffPercent.toFixed(2) });
}
}
if (failures.length > 0) {
console.error('Visual regression failures:');
failures.forEach(f => console.error(` ${f.page}: ${f.diffPercent}% pixels changed`));
process.exit(1);
}
console.log('All visual tests passed.');
}
runVisualTests();
Step 4: Integrate with GitHub Actions
# .github/workflows/visual-tests.yml
name: Visual Regression Tests
on:
pull_request:
branches: [main]
jobs:
visual-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- name: Run visual regression tests
env:
SCREENSHOT_API_KEY: ${{ secrets.SCREENSHOT_API_KEY }}
run: node scripts/visual-test.js
- name: Upload diff screenshots on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: screenshots/diff-*.png
When a test fails, GitHub Actions uploads the diff images as artifacts so you can see exactly what changed.
Handling Dynamic Content
Pages with timestamps, random data, or animated elements will produce false positives. Handle this with:
- CSS to hide dynamic elements: use
custom_cssparam to inject.timestamp { visibility: hidden }. - Wait for stability: use
wait_until: networkidleand a smalldelay. - Higher threshold: increase the pixel diff threshold for pages with dynamic content.
- Mock data: test against a staging environment with seeded, deterministic data.
When to Update Baselines
When you intentionally change the UI, update the baselines by running the capture script again and committing the new images. A good workflow is:
- Developer makes a deliberate UI change.
- Visual test fails (expected).
- Developer reviews the diff, confirms it's intentional, runs
npm run update-baselines. - New baselines committed alongside the UI change.
Summary
Visual regression testing is one of the highest-ROI QA investments for frontend-heavy applications. A screenshot API makes it easy to integrate into any CI/CD pipeline without managing browser infrastructure. The entire setup - from baseline capture to GitHub Actions integration - takes less than a day to implement.
