DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to Add Visual Screenshot Tests to Your Jest Test Suite

How to Add Visual Screenshot Tests to Your Jest Test Suite

Your Jest tests pass. Your code is clean. But when you deploy, users see a broken layout that your test suite never caught.

Visual regressions slip through unit tests.

A button moved 10px. A color changed. Text overflowed. None of that breaks an assertion — but it breaks the user experience.

Jest tests what the code does. They don't test what the code looks like.

There's a better way: visual regression testing. Take a screenshot of your deployed app, compare it pixel-by-pixel to a baseline, and fail the test if the visual diff exceeds your threshold.

Here's how to add screenshot-based visual tests to Jest.

The Problem: Jest Tests Don't Catch Visual Regressions

Unit tests are great for logic:

// Jest test: logic passes, but layout breaks
test('checkout button is clickable', () => {
  const { getByRole } = render(<CheckoutButton />);
  const button = getByRole('button');
  expect(button).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

This test passes. The button exists. But the test doesn't know if the button looks broken.

Visual bugs that slip through:

  • CSS property deleted (button doesn't display)
  • Flexbox breakpoint changes (alignment off)
  • Color gradient broken (wrong theme applied)
  • Font size changed (text overflow)
  • Grid layout shifted (spacing wrong)

Unit tests are blind to these. You need visual regression testing.

The Solution: Screenshot Baseline Comparison

Take a screenshot. Compare it to a baseline. Fail if the diff is too large.

// Visual regression test: catch layout breaks
import { test, expect } from '@jest/globals';
import { spawn } from 'child_process';
import * as fs from 'fs';

async function takeScreenshot(url, filename) {
  const response = await fetch('https://api.pagebolt.dev/v1/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer YOUR_API_KEY`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      url: url,
      format: 'png',
      width: 1280,
      height: 720
    })
  });

  if (!response.ok) {
    throw new Error(`Screenshot failed: ${response.status}`);
  }

  const buffer = await response.arrayBuffer();
  fs.writeFileSync(filename, Buffer.from(buffer));
}

async function compareImages(baselineFile, currentFile) {
  return new Promise((resolve, reject) => {
    const compare = spawn('compare', [
      '-metric', 'AE', // AE = Absolute Error (total pixel differences)
      baselineFile,
      currentFile,
      'null:' // Discard diff image, we just want the metric
    ]);

    let output = '';
    compare.stderr.on('data', (data) => {
      output += data.toString();
    });

    compare.on('close', (code) => {
      // compare exits with code 1 if images differ
      // output contains the pixel difference count
      const pixelDiff = parseInt(output.trim());
      resolve(pixelDiff);
    });

    compare.on('error', reject);
  });
}

test('checkout page layout matches baseline', async () => {
  const baselineFile = './__screenshots__/checkout-baseline.png';
  const currentFile = './__screenshots__/checkout-current.png';

  // Take screenshot of current deployment
  await takeScreenshot('https://example.com/checkout', currentFile);

  // Compare against baseline
  const pixelDiff = await compareImages(baselineFile, currentFile);

  // Fail test if more than 1000 pixels differ (threshold is configurable)
  expect(pixelDiff).toBeLessThan(1000);
});
Enter fullscreen mode Exit fullscreen mode

What this does:

  1. Takes a screenshot of your live app (no browser overhead)
  2. Compares it pixel-by-pixel to the baseline using ImageMagick's compare command
  3. Fails the test if the difference exceeds your threshold

Real Use Case: CI/CD Visual Regression Detection

import { test, expect } from '@jest/globals';
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';

async function takeScreenshot(url, filename) {
  const dir = path.dirname(filename);
  if (!fs.existsSync(dir)) {
    fs.mkdirSync(dir, { recursive: true });
  }

  const response = await fetch('https://api.pagebolt.dev/v1/screenshot', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer YOUR_API_KEY`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      url: url,
      format: 'png',
      fullPage: true, // Capture entire scrollable page
      blockBanners: true // Hide cookie popups
    })
  });

  if (!response.ok) {
    throw new Error(`Screenshot failed: ${response.status}`);
  }

  const buffer = await response.arrayBuffer();
  fs.writeFileSync(filename, Buffer.from(buffer));
}

async function compareImages(baseline, current) {
  return new Promise((resolve, reject) => {
    const compare = spawn('compare', [
      '-metric', 'AE',
      baseline,
      current,
      'null:'
    ]);

    let output = '';
    compare.stderr.on('data', (data) => {
      output += data.toString();
    });

    compare.on('close', () => {
      const pixelDiff = parseInt(output.trim()) || 0;
      resolve(pixelDiff);
    });

    compare.on('error', reject);
  });
}

describe('visual regression tests', () => {
  const screenshotDir = './__screenshots__';

  beforeAll(() => {
    if (!fs.existsSync(screenshotDir)) {
      fs.mkdirSync(screenshotDir, { recursive: true });
    }
  });

  test('homepage layout is stable', async () => {
    const baselineFile = path.join(screenshotDir, 'homepage-baseline.png');
    const currentFile = path.join(screenshotDir, 'homepage-current.png');

    // Capture current page
    await takeScreenshot('https://example.com', currentFile);

    // Compare to baseline
    const pixelDiff = await compareImages(baselineFile, currentFile);

    // Allow up to 2% of pixels to change
    const pixelThreshold = 1280 * 720 * 0.02; // ~18k pixels
    expect(pixelDiff).toBeLessThan(pixelThreshold);
  });

  test('checkout flow maintains visual integrity', async () => {
    const baselineFile = path.join(screenshotDir, 'checkout-baseline.png');
    const currentFile = path.join(screenshotDir, 'checkout-current.png');

    // Capture current checkout page
    await takeScreenshot('https://example.com/checkout', currentFile);

    // Compare to baseline
    const pixelDiff = await compareImages(baselineFile, currentFile);

    // Stricter threshold for critical flow
    expect(pixelDiff).toBeLessThan(500);
  });

  test('product page is responsive', async () => {
    const baselineFile = path.join(screenshotDir, 'product-baseline.png');
    const currentFile = path.join(screenshotDir, 'product-current.png');

    // Capture product page
    await takeScreenshot('https://example.com/products/123', currentFile);

    // Compare to baseline
    const pixelDiff = await compareImages(baselineFile, currentFile);

    expect(pixelDiff).toBeLessThan(1000);
  });
});
Enter fullscreen mode Exit fullscreen mode

How this integrates with CI/CD:

  1. On every PR, run these tests against a staging deployment
  2. If pixel diff exceeds threshold, fail the build
  3. Developers see exactly what changed visually
  4. Merge only when layout is approved

Setting Up Baselines

First time you run the test, the baseline doesn't exist. You need to create it:

async function createBaseline(url, filename) {
  await takeScreenshot(url, filename);
  console.log(`Baseline created: ${filename}`);
}

// Run once to create baselines
createBaseline('https://example.com', './__screenshots__/homepage-baseline.png');
createBaseline('https://example.com/checkout', './__screenshots__/checkout-baseline.png');
Enter fullscreen mode Exit fullscreen mode

Then add baselines to version control:

git add __screenshots__/*-baseline.png
git commit -m "Add visual regression baselines"
Enter fullscreen mode Exit fullscreen mode

Update baselines when intentional design changes happen:

# Replace old baseline with new screenshot
cp __screenshots__/homepage-current.png __screenshots__/homepage-baseline.png
git add __screenshots__/homepage-baseline.png
git commit -m "Update homepage baseline after redesign"
Enter fullscreen mode Exit fullscreen mode

ImageMagick Installation

Visual regression tests need ImageMagick's compare command.

macOS:

brew install imagemagick
Enter fullscreen mode Exit fullscreen mode

Linux (Ubuntu/Debian):

sudo apt-get install imagemagick
Enter fullscreen mode Exit fullscreen mode

Docker (for CI):

FROM node:18

# Install ImageMagick
RUN apt-get update && apt-get install -y imagemagick && rm -rf /var/lib/apt/lists/*

# Your Node.js setup
COPY package.json .
RUN npm install
Enter fullscreen mode Exit fullscreen mode

Handling Flaky Tests

Screenshots can be slightly different due to:

  • Font rendering differences between systems
  • Timing (lazy-loaded images)
  • Dynamic content (timestamps, weather, exchange rates)

Solution: Use a threshold instead of exact match.

// Bad: require pixel-perfect match
expect(pixelDiff).toBe(0); // Too strict

// Good: allow some tolerance
expect(pixelDiff).toBeLessThan(500); // Better
Enter fullscreen mode Exit fullscreen mode

For flaky elements, use masks:

// Exclude timestamps from comparison by masking that region
// This requires manual image manipulation or a visual testing library
// For simplicity, just increase the threshold instead
Enter fullscreen mode Exit fullscreen mode

Threshold Guidelines

Scenario Threshold Reason
Minor font rendering 0–100 pixels Small anti-aliasing differences
Color change 100–500 pixels Gradient or theme adjustment
Layout shift 500–2000 pixels Spacing, margin, or flex changes
Content reflow 2000–10000 pixels New section or major layout
Full redesign No test Update baseline instead

Real-World Workflow

1. Add test to your suite:

test('product carousel is visually stable', async () => {
  const baselineFile = './__screenshots__/carousel-baseline.png';
  const currentFile = './__screenshots__/carousel-current.png';

  await takeScreenshot('https://example.com/products', currentFile);
  const pixelDiff = await compareImages(baselineFile, currentFile);

  expect(pixelDiff).toBeLessThan(500);
});
Enter fullscreen mode Exit fullscreen mode

2. Create baseline:

npm test -- --testNamePattern="carousel"
# First run creates screenshot
cp __screenshots__/carousel-current.png __screenshots__/carousel-baseline.png
Enter fullscreen mode Exit fullscreen mode

3. Commit baseline:

git add __screenshots__/carousel-baseline.png
Enter fullscreen mode Exit fullscreen mode

4. On every PR, tests run automatically:

npm test
# Compares current screenshot to baseline
# Fails if diff > 500 pixels
Enter fullscreen mode Exit fullscreen mode

5. If diff is acceptable (intentional redesign):

git checkout __screenshots__/carousel-baseline.png
# Revert baseline, update it after design review
Enter fullscreen mode Exit fullscreen mode

API Parameters for Visual Tests

Parameter Use Case
fullPage: true Capture entire scrollable page (test full flow)
blockBanners: true Hide cookie popups (cleaner baseline)
blockAds: true Remove ads (consistent baseline)
width, height Test specific viewport (desktop, mobile, tablet)
// Full page visual test
await takeScreenshot('https://example.com/checkout', currentFile, {
  fullPage: true,
  blockBanners: true,
  blockAds: true,
  width: 1280,
  height: 720
});
Enter fullscreen mode Exit fullscreen mode

Pricing

Plan Requests/Month Cost Best For
Free 100 $0 Hobby projects, learning
Starter 5,000 $29 Small teams, moderate testing
Growth 25,000 $79 Production apps, frequent tests
Scale 100,000 $199 High-volume CI/CD, daily tests

Visual regression testing is just screenshot capturing. Same pricing as any screenshot use case.

Summary

Jest tests logic. Screenshot comparison tests visuals.

  • ✅ Take a screenshot with one API call
  • ✅ Compare pixel-by-pixel to baseline using ImageMagick
  • ✅ Fail tests when pixel diff exceeds threshold
  • ✅ Catch layout breaks before they reach users
  • ✅ No browser overhead, no CI timeout issues
  • ✅ Integrates into any Jest test suite

Get started free: pagebolt.dev — 100 requests/month, no credit card required.

Top comments (0)