← Back to blog
14 min readMarch 15, 2026

Add Screenshot Testing to Your CI/CD Pipeline

CSS bugs are invisible to unit tests. A one-line change to your styles can break layouts across your entire app, and you will not know until a user reports it. Screenshot testing catches these visual regressions automatically, on every pull request.

Why Visual Testing Matters

Traditional testing covers logic: does the function return the right value? Does the API return a 200? But it completely misses visual issues: is the button still visible? Did the new CSS cause an overlap? Is the mobile layout still correct?

Visual regression testing captures screenshots of your pages and compares them against baselines. If anything changes, the CI pipeline flags it for review. Simple, effective, and catches entire categories of bugs that other tests miss.

Architecture Overview

The approach is straightforward:

  1. Deploy your staging/preview build (Vercel preview URLs work perfectly)
  2. Capture screenshots of key pages using an API
  3. Compare against baseline screenshots stored in your repo or S3
  4. Fail the CI check if pixel differences exceed a threshold
  5. Update baselines when visual changes are intentional

Step 1: Set Up Screenshot Capture

Instead of managing your own Puppeteer installation in CI (which is painful -- Chrome dependencies, memory limits, flaky rendering), use a Screenshot API. One HTTP call, guaranteed consistent rendering.

// scripts/capture-screenshots.js
const fs = require('fs');
const path = require('path');

const API_KEY = process.env.SCREENSHOT_API_KEY;
const BASE_URL = process.env.PREVIEW_URL || 'http://localhost:3000';

const pages = [
  { name: 'homepage', path: '/' },
  { name: 'pricing', path: '/pricing' },
  { name: 'docs', path: '/docs' },
  { name: 'blog', path: '/blog' },
  { name: 'login', path: '/login' },
];

async function captureAll() {
  const outDir = path.join(__dirname, '../screenshots/current');
  fs.mkdirSync(outDir, { recursive: true });

  for (const page of pages) {
    const url = `https://screenshotapi-api-production.up.railway.app/v1/screenshot`
      + `?url=${encodeURIComponent(BASE_URL + page.path)}`
      + '&width=1280&height=800&format=png&fullpage=true';

    const response = await fetch(url, {
      headers: { Authorization: `Bearer ${API_KEY}` },
    });

    if (!response.ok) {
      console.error(`Failed: ${page.name} (${response.status})`);
      continue;
    }

    const buffer = Buffer.from(await response.arrayBuffer());
    fs.writeFileSync(path.join(outDir, `${page.name}.png`), buffer);
    console.log(`Captured: ${page.name}`);
  }
}

captureAll();

Step 2: Compare Screenshots

Use pixelmatch or looks-same to compare the current screenshots against baselines:

// scripts/compare-screenshots.js
const fs = require('fs');
const path = require('path');
const { PNG } = require('pngjs');
const pixelmatch = require('pixelmatch');

const THRESHOLD = 0.1; // 0 = exact, 1 = very different
const MAX_DIFF_PIXELS = 100; // Allow small rendering differences

const baselineDir = path.join(__dirname, '../screenshots/baseline');
const currentDir = path.join(__dirname, '../screenshots/current');
const diffDir = path.join(__dirname, '../screenshots/diff');

fs.mkdirSync(diffDir, { recursive: true });

const files = fs.readdirSync(currentDir).filter(f => f.endsWith('.png'));
let failures = 0;

for (const file of files) {
  const baselinePath = path.join(baselineDir, file);
  const currentPath = path.join(currentDir, file);

  if (!fs.existsSync(baselinePath)) {
    console.log(`NEW: ${file} (no baseline -- will be added)`);
    fs.copyFileSync(currentPath, baselinePath);
    continue;
  }

  const baseline = PNG.sync.read(fs.readFileSync(baselinePath));
  const current = PNG.sync.read(fs.readFileSync(currentPath));

  if (baseline.width !== current.width || baseline.height !== current.height) {
    console.error(`FAIL: ${file} -- dimensions changed`);
    failures++;
    continue;
  }

  const diff = new PNG({ width: baseline.width, height: baseline.height });
  const numDiffPixels = pixelmatch(
    baseline.data, current.data, diff.data,
    baseline.width, baseline.height,
    { threshold: THRESHOLD }
  );

  if (numDiffPixels > MAX_DIFF_PIXELS) {
    console.error(`FAIL: ${file} -- ${numDiffPixels} pixels differ`);
    fs.writeFileSync(path.join(diffDir, file), PNG.sync.write(diff));
    failures++;
  } else {
    console.log(`PASS: ${file} (${numDiffPixels} pixels)`);
  }
}

if (failures > 0) {
  console.error(`\n${failures} visual regression(s) detected!`);
  process.exit(1);
}
console.log('\nAll visual tests passed.');

Step 3: GitHub Actions Workflow

Put it all together in a GitHub Actions workflow that 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

      - name: Wait for Vercel preview
        uses: patrickedqvist/wait-for-vercel-preview@v1.3.2
        id: vercel
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          max_timeout: 300

      - name: Capture screenshots
        run: node scripts/capture-screenshots.js
        env:
          SCREENSHOT_API_KEY: ${{ secrets.SCREENSHOT_API_KEY }}
          PREVIEW_URL: ${{ steps.vercel.outputs.url }}

      - name: Compare with baselines
        run: node scripts/compare-screenshots.js

      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: screenshots/diff/
          retention-days: 7

Step 4: Update Baselines

When you intentionally change the UI, update the baselines:

# Capture new baselines
PREVIEW_URL=https://your-preview.vercel.app node scripts/capture-screenshots.js

# Copy current to baseline
cp -r screenshots/current/* screenshots/baseline/

# Commit the updated baselines
git add screenshots/baseline/
git commit -m "update visual test baselines"
git push

Best Practices

Hiding Dynamic Content

Pass custom CSS to hide elements that change between runs:

// Hide timestamps, avatars, and cookie banners
const css = encodeURIComponent(`
  [data-testid="timestamp"],
  .avatar,
  .cookie-banner,
  .live-chat-widget {
    visibility: hidden !important;
  }
`);

const url = `https://screenshotapi-api-production.up.railway.app/v1/screenshot`
  + `?url=${encodeURIComponent(pageUrl)}`
  + `&css=${css}`
  + '&width=1280&height=800&format=png';

Cost Comparison

ApproachSetup TimeMonthly CostReliability
Local Puppeteer in CI4-8 hours$0 (CI minutes)Flaky (60-80%)
Percy/Chromatic1-2 hours$149-399/moHigh (95%+)
ScreenshotAPI + pixelmatch30 min$0-29/moHigh (95%+)

Using ScreenshotAPI with the free tier (100 screenshots/month), a project testing 10 pages can run visual tests on 10 PRs per month at zero cost. The Pro plan at $29/month covers 1,000 PR runs.

Conclusion

Visual regression testing is one of those practices that seems like overkill until it catches a bug that would have reached production. With a screenshot API and 50 lines of JavaScript, you can add it to any project in 30 minutes. No Puppeteer setup, no browser management, no flaky tests.

Try ScreenshotAPI Free

100 free screenshots/month. Perfect for visual testing in CI/CD. No credit card required.

Related Guides