Your tests pass. Your build is green. You deploy. Then someone notices the hero section overlaps the nav bar on mobile.
Visual regression testing catches what unit tests and integration tests miss: things that look wrong. A button shifted 20 pixels. A font that didn’t load. A CSS change that broke a layout three pages away from where you edited.
The traditional approach involves running Puppeteer locally, managing browser binaries in CI, and storing baseline images. It’s fragile and slow. A screenshot API simplifies this to HTTP requests.
How It Works
The concept is straightforward:
- Capture baseline screenshots of your key pages at a known-good state
- On every PR or deploy, capture the same pages again
- Compare the new screenshots against the baselines
- Flag differences that exceed a threshold
len.sh handles step 1 and 2. You bring the comparison logic (or use a library like pixelmatch).
Step 1: Define Your Test Pages
Start with the pages that matter most. You don’t need to screenshot every route. Focus on:
- Landing page / homepage
- Pricing page
- Key product pages
- Authentication flows (login, signup)
- Dashboard or app views (if publicly accessible or behind a staging URL)
const pages = [
{ name: "homepage", url: "https://staging.yourapp.com/", width: 1280 },
{ name: "homepage-mobile", url: "https://staging.yourapp.com/", width: 390 },
{ name: "pricing", url: "https://staging.yourapp.com/pricing", width: 1280 },
{ name: "login", url: "https://staging.yourapp.com/login", width: 1280 },
{ name: "docs", url: "https://staging.yourapp.com/docs", width: 1280 },
];
Step 2: Capture Screenshots
const API_KEY = process.env.LENSH_API_KEY;
async function captureScreenshot(
url: string,
width: number
): Promise<Buffer> {
const params = new URLSearchParams({
url,
width: String(width),
format: "png",
wait_until: "networkidle2",
block_cookie_banners: "true",
block_popups: "true",
cache_ttl: "0", // Always fresh for testing
access_key: API_KEY,
});
const response = await fetch(
`https://api.len.sh/v1/screenshot?${params}`
);
if (!response.ok) {
throw new Error(`Screenshot failed: ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
Key parameters for visual testing:
cache_ttl: 0— bypass caching so you always get a fresh renderwait_until: networkidle2— wait for the page to fully loadblock_cookie_banners: true— cookie banners are non-deterministic and will cause false positivesblock_popups: true— same with marketing popups
Step 3: Compare Against Baselines
Using pixelmatch (Node.js):
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
import { readFileSync, writeFileSync, mkdirSync } from "fs";
interface ComparisonResult {
page: string;
diffPixels: number;
totalPixels: number;
diffPercentage: number;
passed: boolean;
}
function compareImages(
baselinePath: string,
currentBuffer: Buffer,
diffOutputPath: string,
threshold = 0.1 // Color difference threshold (0-1)
): ComparisonResult {
const baseline = PNG.sync.read(readFileSync(baselinePath));
const current = PNG.sync.read(currentBuffer);
// Images must be the same dimensions
if (
baseline.width !== current.width ||
baseline.height !== current.height
) {
return {
page: baselinePath,
diffPixels: -1,
totalPixels: 0,
diffPercentage: 100,
passed: false,
};
}
const diff = new PNG({
width: baseline.width,
height: baseline.height,
});
const diffPixels = pixelmatch(
baseline.data,
current.data,
diff.data,
baseline.width,
baseline.height,
{ threshold }
);
writeFileSync(diffOutputPath, PNG.sync.write(diff));
const totalPixels = baseline.width * baseline.height;
const diffPercentage = (diffPixels / totalPixels) * 100;
return {
page: baselinePath,
diffPixels,
totalPixels,
diffPercentage,
passed: diffPercentage < 0.5, // Less than 0.5% different
};
}
Step 4: Wire It Into CI
GitHub Actions Example
name: Visual Regression
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: 22
- name: Install dependencies
run: npm ci
- name: Run visual regression tests
env:
LENSH_API_KEY: ${{ secrets.LENSH_API_KEY }}
STAGING_URL: ${{ vars.STAGING_URL }}
run: node scripts/visual-regression.mjs
- name: Upload diff images
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diffs
path: visual-diffs/
The Test Script
// scripts/visual-regression.mjs
import { mkdirSync, existsSync, writeFileSync, readFileSync } from "fs";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";
const API_KEY = process.env.LENSH_API_KEY;
const BASE_URL = process.env.STAGING_URL || "https://staging.yourapp.com";
const pages = [
{ name: "homepage", path: "/", width: 1280 },
{ name: "homepage-mobile", path: "/", width: 390 },
{ name: "pricing", path: "/pricing", width: 1280 },
{ name: "docs", path: "/docs", width: 1280 },
];
mkdirSync("visual-diffs", { recursive: true });
let failed = false;
for (const page of pages) {
const baselinePath = `visual-baselines/${page.name}.png`;
if (!existsSync(baselinePath)) {
console.log(`No baseline for ${page.name}, skipping`);
continue;
}
const params = new URLSearchParams({
url: `${BASE_URL}${page.path}`,
width: String(page.width),
format: "png",
wait_until: "networkidle2",
block_cookie_banners: "true",
block_popups: "true",
cache_ttl: "0",
access_key: API_KEY,
});
const response = await fetch(
`https://api.len.sh/v1/screenshot?${params}`
);
const currentBuffer = Buffer.from(await response.arrayBuffer());
// Save current screenshot
writeFileSync(`visual-diffs/${page.name}-current.png`, currentBuffer);
// Compare
const baseline = PNG.sync.read(readFileSync(baselinePath));
const current = PNG.sync.read(currentBuffer);
if (
baseline.width !== current.width ||
baseline.height !== current.height
) {
console.error(
`FAIL: ${page.name} - dimensions changed ` +
`(${baseline.width}x${baseline.height} -> ${current.width}x${current.height})`
);
failed = true;
continue;
}
const diff = new PNG({ width: baseline.width, height: baseline.height });
const diffPixels = pixelmatch(
baseline.data, current.data, diff.data,
baseline.width, baseline.height,
{ threshold: 0.1 }
);
const diffPct = ((diffPixels / (baseline.width * baseline.height)) * 100).toFixed(2);
if (parseFloat(diffPct) > 0.5) {
console.error(`FAIL: ${page.name} - ${diffPct}% different (${diffPixels} pixels)`);
writeFileSync(`visual-diffs/${page.name}-diff.png`, PNG.sync.write(diff));
failed = true;
} else {
console.log(`PASS: ${page.name} - ${diffPct}% different`);
}
}
if (failed) {
console.error("\nVisual regression tests failed. Check visual-diffs/ for details.");
process.exit(1);
}
console.log("\nAll visual regression tests passed.");
Generate Baselines
Run this once to create your baseline images:
LENSH_API_KEY=your-key node scripts/generate-baselines.mjs
// scripts/generate-baselines.mjs
import { mkdirSync, writeFileSync } from "fs";
const API_KEY = process.env.LENSH_API_KEY;
const BASE_URL = process.env.BASE_URL || "https://yourapp.com";
const pages = [
{ name: "homepage", path: "/", width: 1280 },
{ name: "homepage-mobile", path: "/", width: 390 },
{ name: "pricing", path: "/pricing", width: 1280 },
{ name: "docs", path: "/docs", width: 1280 },
];
mkdirSync("visual-baselines", { recursive: true });
for (const page of pages) {
const params = new URLSearchParams({
url: `${BASE_URL}${page.path}`,
width: String(page.width),
format: "png",
wait_until: "networkidle2",
block_cookie_banners: "true",
block_popups: "true",
cache_ttl: "0",
access_key: API_KEY,
});
const response = await fetch(`https://api.len.sh/v1/screenshot?${params}`);
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(`visual-baselines/${page.name}.png`, buffer);
console.log(`Saved baseline: ${page.name}`);
}
Commit these baseline images to your repo. Update them when you intentionally change the design.
Tips for Reliable Visual Tests
Eliminate non-determinism
Screenshots must be deterministic to avoid false positives. Watch out for:
- Dates and timestamps — inject CSS to hide or freeze them:
css=.timestamp{visibility:hidden} - Randomized content — A/B tests, random hero images, rotating testimonials
- Animations — add
delayto let animations complete, or inject CSS:css=*{animation:none!important;transition:none!important} - Cookie banners — use
block_cookie_banners=true - Third-party widgets — chat widgets, analytics overlays. Hide with CSS injection.
Use consistent viewport sizes
Always specify width explicitly. Don’t rely on defaults across different environments.
Set a reasonable diff threshold
A threshold of 0.5% works well for most pages. Anti-aliasing differences between renders can cause tiny pixel variations. A threshold that’s too low will cause noise. Too high will miss real regressions.
Test mobile and desktop separately
Responsive bugs often only appear at specific breakpoints. Capture at least one mobile width (390px) and one desktop width (1280px) for key pages.
Full Page vs. Viewport
For visual regression testing, viewport screenshots (the default) are usually better than full-page screenshots. Reasons:
- Faster comparison — smaller images compare faster
- Above-the-fold focus — the most critical visual area is what users see first
- More stable — full page captures are affected by dynamic content length
If you do need to test below-the-fold content, use full_page=true but combine it with css injection to hide variable-height sections.
How Many Screenshots Does This Use?
Each page capture is one screenshot. If you test 5 pages at 2 viewports each, that’s 10 screenshots per CI run. At 20 PRs per week, that’s ~800/month — well within the Pro tier (25,000/month at EUR 19/month).
The Free tier (100/month) is enough for smaller projects running a few tests per week.