Automated Visual Regression Testing with Screenshot API
February 8, 2026 ยท 7 min read
Photo via Unsplash
CSS changes are notoriously hard to test. A one-line change to a shared utility class can break layouts on dozens of pages. Unit tests will not catch it. Integration tests will not catch it. The only reliable way to detect unintended visual changes is to compare screenshots before and after the change. This is called visual regression testing.
In this guide, we will build a complete visual regression testing pipeline using SnapAPI for consistent screenshot capture and pixelmatch for pixel-level comparison, all wired into GitHub Actions.
Why Visual Regression Testing?
- Catch CSS regressions: Detect unintended layout shifts, broken responsive designs, and style conflicts
- Cross-browser consistency: Verify your site looks correct in the same rendering engine every time
- Confidence in refactoring: Safely rename CSS classes, restructure components, or upgrade design systems
- Documentation: Baseline screenshots serve as living documentation of your UI state
Why Use an API Instead of Local Browsers?
Running Puppeteer or Playwright locally for visual tests introduces several problems:
- Environment inconsistency: A developer's Mac renders fonts differently than a Linux CI runner. This causes false positives on every run.
- Browser version drift: Chrome updates can subtly change rendering, creating noisy diffs
- Infrastructure overhead: You need to install and manage headless Chrome in every CI environment
- Flakiness: Local browser instances crash, hang, or produce inconsistent results under load
SnapAPI solves all of these by rendering every screenshot on the same infrastructure with the same browser version, producing pixel-identical results regardless of where you run your tests.
Project Setup
Create a new project or add to your existing one:
mkdir visual-tests && cd visual-tests
npm init -y
npm install pixelmatch pngjs
Create the directory structure:
visual-tests/
baselines/ # Reference screenshots (committed to git)
current/ # Screenshots from current build (gitignored)
diffs/ # Diff images when tests fail (gitignored)
visual-test.js # Test runner
pages.json # Pages to test
Step 1: Define Pages to Test
Create a pages.json file listing every page and viewport you want to test:
{
"baseUrl": "https://staging.yoursite.com",
"pages": [
{
"name": "homepage-desktop",
"path": "/",
"width": 1440,
"height": 900
},
{
"name": "homepage-mobile",
"path": "/",
"width": 375,
"height": 812
},
{
"name": "pricing-desktop",
"path": "/pricing",
"width": 1440,
"height": 900
},
{
"name": "blog-desktop",
"path": "/blog",
"width": 1440,
"height": 900
},
{
"name": "docs-desktop",
"path": "/docs",
"width": 1440,
"height": 900,
"fullPage": true
}
]
}
Step 2: Capture Screenshots
Write a function to capture screenshots for all configured pages:
// capture.js
const fs = require("fs");
const path = require("path");
const SNAPAPI_KEY = process.env.SNAPAPI_KEY;
const API_URL = "https://api.snapapi.pics/v1/screenshot";
async function captureScreenshot(url, options = {}) {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"X-Api-Key": SNAPAPI_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url,
format: "png",
width: options.width || 1440,
height: options.height || 900,
fullPage: options.fullPage || false,
blockCookieBanners: true,
responseType: "image",
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Screenshot failed for ${url}: ${response.status} ${text}`);
}
return Buffer.from(await response.arrayBuffer());
}
async function captureAll(outputDir) {
const config = JSON.parse(fs.readFileSync("pages.json", "utf-8"));
fs.mkdirSync(outputDir, { recursive: true });
const results = [];
for (const page of config.pages) {
const url = `${config.baseUrl}${page.path}`;
console.log(` Capturing ${page.name} (${url})...`);
try {
const buffer = await captureScreenshot(url, {
width: page.width,
height: page.height,
fullPage: page.fullPage,
});
const filepath = path.join(outputDir, `${page.name}.png`);
fs.writeFileSync(filepath, buffer);
results.push({ name: page.name, status: "ok", filepath });
} catch (err) {
results.push({ name: page.name, status: "error", error: err.message });
}
}
return results;
}
module.exports = { captureAll, captureScreenshot };
Step 3: Compare Screenshots
Use pixelmatch to compare current screenshots against baselines:
// compare.js
const fs = require("fs");
const path = require("path");
const { PNG } = require("pngjs");
const pixelmatch = require("pixelmatch");
function compareImages(baselinePath, currentPath, diffPath) {
const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
const current = PNG.sync.read(fs.readFileSync(currentPath));
// Handle size differences
const width = Math.max(baseline.width, current.width);
const height = Math.max(baseline.height, current.height);
// Create canvases of equal size
const baselineResized = resizeImage(baseline, width, height);
const currentResized = resizeImage(current, width, height);
const diff = new PNG({ width, height });
const mismatchedPixels = pixelmatch(
baselineResized.data,
currentResized.data,
diff.data,
width,
height,
{
threshold: 0.1, // Color distance threshold (0 = exact, 1 = lenient)
includeAA: false, // Ignore anti-aliasing differences
alpha: 0.1, // Opacity of unchanged pixels in diff
diffColor: [255, 0, 0] // Red for changed pixels
}
);
const totalPixels = width * height;
const diffPercent = ((mismatchedPixels / totalPixels) * 100).toFixed(3);
// Save diff image
fs.mkdirSync(path.dirname(diffPath), { recursive: true });
fs.writeFileSync(diffPath, PNG.sync.write(diff));
return {
mismatchedPixels,
totalPixels,
diffPercent: parseFloat(diffPercent),
diffPath,
};
}
function resizeImage(img, width, height) {
const resized = new PNG({ width, height, fill: true });
PNG.bitblt(img, resized, 0, 0, img.width, img.height, 0, 0);
return resized;
}
module.exports = { compareImages };
Step 4: Build the Test Runner
Tie capture and comparison together into a single test script:
// visual-test.js
const fs = require("fs");
const path = require("path");
const { captureAll } = require("./capture");
const { compareImages } = require("./compare");
const BASELINE_DIR = "./baselines";
const CURRENT_DIR = "./current";
const DIFF_DIR = "./diffs";
const THRESHOLD = 0.1; // Fail if more than 0.1% of pixels differ
async function run() {
const mode = process.argv[2]; // "update" or "test"
if (mode === "update") {
console.log("Updating baselines...\n");
const results = await captureAll(BASELINE_DIR);
const succeeded = results.filter((r) => r.status === "ok").length;
console.log(`\nBaselines updated: ${succeeded}/${results.length}`);
return;
}
// Default: test mode
console.log("Capturing current screenshots...\n");
const captures = await captureAll(CURRENT_DIR);
console.log("\nComparing against baselines...\n");
let failures = 0;
for (const capture of captures) {
if (capture.status !== "ok") {
console.log(` SKIP ${capture.name} (capture failed)`);
failures++;
continue;
}
const baselinePath = path.join(BASELINE_DIR, `${capture.name}.png`);
if (!fs.existsSync(baselinePath)) {
console.log(` NEW ${capture.name} (no baseline found)`);
failures++;
continue;
}
const diffPath = path.join(DIFF_DIR, `${capture.name}-diff.png`);
const result = compareImages(baselinePath, capture.filepath, diffPath);
if (result.diffPercent > THRESHOLD) {
console.log(` FAIL ${capture.name} - ${result.diffPercent}% different`);
console.log(` Diff saved: ${result.diffPath}`);
failures++;
} else {
console.log(` PASS ${capture.name} - ${result.diffPercent}% different`);
}
}
console.log(`\n${failures === 0 ? "All tests passed!" : `${failures} test(s) failed.`}`);
process.exit(failures === 0 ? 0 : 1);
}
run().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
Usage
# First run: capture baseline screenshots
SNAPAPI_KEY=your_key node visual-test.js update
# Subsequent runs: test against baselines
SNAPAPI_KEY=your_key node visual-test.js test
# After intentional changes: update baselines again
SNAPAPI_KEY=your_key node visual-test.js update
Step 5: GitHub Actions Integration
Add visual regression tests to your CI pipeline. This workflow runs on every pull request:
# .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
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci
working-directory: visual-tests
- name: Capture current screenshots
run: node visual-test.js test
working-directory: visual-tests
env:
SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
- name: Upload diff artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: visual-tests/diffs/
retention-days: 7
- name: Upload current screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-current
path: visual-tests/current/
retention-days: 7
Important: Add your SnapAPI key as a GitHub repository secret. Go to Settings > Secrets and variables > Actions > New repository secret, and add SNAPAPI_KEY with your API key value.
Handling Dynamic Content
Real websites often have dynamic elements that change between captures: timestamps, animations, ads, user-specific content. Here are strategies to handle them:
Hide Dynamic Elements with CSS
Use the css parameter to inject CSS that hides volatile elements:
const response = await fetch(API_URL, {
method: "POST",
headers: {
"X-Api-Key": SNAPAPI_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://yoursite.com",
format: "png",
width: 1440,
height: 900,
css: `
/* Hide dynamic content */
.timestamp, .relative-time { visibility: hidden; }
.animated-banner { display: none; }
.ad-container { display: none; }
[data-testid="live-counter"] { visibility: hidden; }
`,
responseType: "image",
}),
});
Increase the Diff Threshold
For pages with minor dynamic variations, increase the tolerance:
// Allow up to 0.5% pixel difference for pages with dynamic content
const THRESHOLD_DYNAMIC = 0.5;
// Or use per-page thresholds in pages.json:
{
"name": "dashboard",
"path": "/dashboard",
"threshold": 0.5
}
Testing Multiple Viewports
A thorough visual test suite covers multiple breakpoints. Here is a helper to test common viewport sizes:
const VIEWPORTS = {
mobile: { width: 375, height: 812, name: "mobile" },
tablet: { width: 768, height: 1024, name: "tablet" },
desktop: { width: 1440, height: 900, name: "desktop" },
wide: { width: 1920, height: 1080, name: "wide" },
};
async function captureAllViewports(url, pageName) {
const results = [];
for (const [key, viewport] of Object.entries(VIEWPORTS)) {
const buffer = await captureScreenshot(url, viewport);
const filename = `${pageName}-${viewport.name}.png`;
fs.writeFileSync(path.join(CURRENT_DIR, filename), buffer);
results.push({ name: filename, viewport: key });
}
return results;
}
Best Practices
- Commit baselines to git. This lets you review visual changes in PRs and roll back if needed.
- Use consistent viewport sizes. Always specify exact
widthandheightto avoid size-related diffs. - Block cookie banners. GDPR popups are the most common source of false positives.
- Test against staging, not production. Production can change independently of your code.
- Keep the diff threshold low. Start at 0.1% and only increase for specific pages that need it.
- Review diffs carefully. When a test fails, download the diff artifact and inspect the highlighted changes.
Next Steps
- Full API Documentation -- all screenshot parameters and options
- Screenshot API with Node.js -- more Node.js integration examples
- Web Archiving -- store historical screenshots for compliance
Try SnapAPI Free
Get 200 free screenshots per month. Perfect for testing visual regression on small projects.
Get Started Free →Related Reading
- Automated Testing Use Case โ full guide to visual regression testing
- Screenshot API with Python โ Python integration examples
- Free Screenshot API โ perfect for testing visual regression on small projects