Guide Testing

Automated Visual Regression Testing with Screenshot API

February 8, 2026 ยท 7 min read

Colorful source code on developer screens

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?

Why Use an API Instead of Local Browsers?

Running Puppeteer or Playwright locally for visual tests introduces several problems:

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

  1. Commit baselines to git. This lets you review visual changes in PRs and roll back if needed.
  2. Use consistent viewport sizes. Always specify exact width and height to avoid size-related diffs.
  3. Block cookie banners. GDPR popups are the most common source of false positives.
  4. Test against staging, not production. Production can change independently of your code.
  5. Keep the diff threshold low. Start at 0.1% and only increase for specific pages that need it.
  6. Review diffs carefully. When a test fails, download the diff artifact and inspect the highlighted changes.

Next Steps

Try SnapAPI Free

Get 200 free screenshots per month. Perfect for testing visual regression on small projects.

Get Started Free →

Related Reading

Last updated: February 19, 2026