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:
- Deploy your staging/preview build (Vercel preview URLs work perfectly)
- Capture screenshots of key pages using an API
- Compare against baseline screenshots stored in your repo or S3
- Fail the CI check if pixel differences exceed a threshold
- 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: 7Step 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
- Test critical pages only. Start with 5-10 key pages (homepage, pricing, signup, dashboard). You do not need to screenshot every page.
- Use consistent viewport sizes. Always capture at the same width/height to avoid false positives from responsive layout shifts.
- Set a reasonable threshold. Anti-aliasing and font rendering can cause tiny pixel differences between runs. Allow 50-100 pixels of tolerance.
- Handle dynamic content. Hide timestamps, user avatars, and other dynamic elements using CSS injection. ScreenshotAPI supports a
cssparameter for this. - Use an API instead of local Puppeteer. CI environments have limited resources. Running Chrome in GitHub Actions is slow and flaky. An API gives you consistent, fast rendering without managing infrastructure.
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
| Approach | Setup Time | Monthly Cost | Reliability |
|---|---|---|---|
| Local Puppeteer in CI | 4-8 hours | $0 (CI minutes) | Flaky (60-80%) |
| Percy/Chromatic | 1-2 hours | $149-399/mo | High (95%+) |
| ScreenshotAPI + pixelmatch | 30 min | $0-29/mo | High (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.