arrow_back Back to blog
Use Cases April 12, 2026 6 min read

Automate Visual Regression Testing with a Screenshot API

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:

  1. Capture baseline screenshots of your key pages at a known-good state
  2. On every PR or deploy, capture the same pages again
  3. Compare the new screenshots against the baselines
  4. 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 render
  • wait_until: networkidle2 — wait for the page to fully load
  • block_cookie_banners: true — cookie banners are non-deterministic and will cause false positives
  • block_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 delay to 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.

Try len.sh for free

Start capturing screenshots with a simple API call. No credit card required.