DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to add visual regression testing to Jest

How to add visual regression testing to Jest

Jest has excellent snapshot testing for component output — toMatchSnapshot() saves the serialized HTML and diffs it on subsequent runs. But HTML string diffs miss the entire visual layer: CSS, layout, spacing, fonts. jest-image-snapshot solves this but requires Puppeteer, which means a 300MB browser install in every CI environment and flaky rendering across OS versions. There's a cleaner approach.

PageBolt's screenshot API accepts raw HTML strings directly via the html parameter. That means you can render a component to a string with renderToString, POST it to PageBolt, get a PNG back, and diff it against a stored baseline — with no browser install required and consistent rendering every time.

How the html parameter works

Most screenshot APIs only accept URLs. PageBolt also accepts raw HTML:

{
  "html": "<html><body><button class='btn'>Click me</button></body></html>",
  "width": 800,
  "height": 400,
  "format": "png"
}
Enter fullscreen mode Exit fullscreen mode

PageBolt renders this HTML in a hosted Chromium instance and returns a PNG. You get the same consistent rendering regardless of which machine runs the test.

Setup

npm install --save-dev jest @types/jest pixelmatch pngjs
# For React component rendering:
npm install --save-dev react react-dom @types/react
Enter fullscreen mode Exit fullscreen mode

Set your API key in your test environment:

# .env.test
PAGEBOLT_API_KEY=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

Load it in Jest config:

// jest.config.ts
import type { Config } from 'jest';

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  setupFiles: ['<rootDir>/jest.setup.ts'],
  snapshotSerializers: [],
};

export default config;
Enter fullscreen mode Exit fullscreen mode
// jest.setup.ts
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
Enter fullscreen mode Exit fullscreen mode

The custom toMatchVisualSnapshot matcher

// tests/matchers/visual-snapshot.ts
import fs from 'fs';
import path from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';
import type { MatcherContext } from 'expect';

const SNAPSHOT_DIR = path.join(process.cwd(), '__visual_snapshots__');
const UPDATE = process.env.UPDATE_SNAPSHOTS === 'true';

async function screenshotHtml(html: string): Promise<Buffer> {
  const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      html,
      width: 800,
      height: 600,
      format: 'png',
      // Transparent background so component renders on white, not grey
      omitBackground: false,
    }),
  });

  if (!res.ok) {
    const err = await res.json() as { error: string };
    throw new Error(`PageBolt screenshot failed: ${err.error}`);
  }

  return Buffer.from(await res.arrayBuffer());
}

function diffPngs(
  baseline: Buffer,
  current: Buffer
): { diffPercent: number; diffBuffer: Buffer } {
  const img1 = PNG.sync.read(baseline);
  const img2 = PNG.sync.read(current);
  const { width, height } = img1;
  const diff = new PNG({ width, height });

  const numDiffPixels = pixelmatch(
    img1.data,
    img2.data,
    diff.data,
    width,
    height,
    { threshold: 0.1 }
  );

  return {
    diffPercent: (numDiffPixels / (width * height)) * 100,
    diffBuffer: PNG.sync.write(diff),
  };
}

export async function toMatchVisualSnapshot(
  this: MatcherContext,
  html: string,
  snapshotName: string
): Promise<jest.CustomMatcherResult> {
  const snapshotPath = path.join(SNAPSHOT_DIR, `${snapshotName}.png`);
  const diffPath = path.join(SNAPSHOT_DIR, `${snapshotName}-diff.png`);

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

  const current = await screenshotHtml(html);

  if (!fs.existsSync(snapshotPath) || UPDATE) {
    fs.writeFileSync(snapshotPath, current);
    return {
      pass: true,
      message: () => `Visual snapshot saved: ${snapshotPath}`,
    };
  }

  const baseline = fs.readFileSync(snapshotPath);
  const { diffPercent, diffBuffer } = diffPngs(baseline, current);
  const THRESHOLD = 0.1; // 0.1% pixel diff allowed

  if (diffPercent > THRESHOLD) {
    fs.writeFileSync(diffPath, diffBuffer);
    return {
      pass: false,
      message: () =>
        `Visual snapshot mismatch: ${diffPercent.toFixed(3)}% pixels differ (threshold: ${THRESHOLD}%).\n` +
        `Baseline: ${snapshotPath}\n` +
        `Diff saved: ${diffPath}\n` +
        `To update baseline, run: UPDATE_SNAPSHOTS=true jest`,
    };
  }

  return {
    pass: true,
    message: () => `Visual snapshot matches (${diffPercent.toFixed(3)}% diff)`,
  };
}
Enter fullscreen mode Exit fullscreen mode

Register the matcher globally:

// jest.setup.ts (add to existing file)
import { toMatchVisualSnapshot } from './tests/matchers/visual-snapshot';

expect.extend({ toMatchVisualSnapshot });

// Extend TypeScript types
declare global {
  namespace jest {
    interface Matchers<R> {
      toMatchVisualSnapshot(snapshotName: string): Promise<R>;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

React component example

// tests/visual/Button.visual.test.ts
import React from 'react';
import { renderToString } from 'react-dom/server';
import { Button } from '../../src/components/Button';

// Wrap with full HTML document so styles load correctly
function renderComponent(component: React.ReactElement): string {
  const body = renderToString(component);
  return `<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://your-cdn.com/your-app.css">
  <style>
    body { margin: 40px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: white; }
    /* Or inline your component styles here */
  </style>
</head>
<body>${body}</body>
</html>`;
}

describe('Button — visual regression', () => {
  test('primary variant', async () => {
    const html = renderComponent(
      <Button variant="primary">Click me</Button>
    );
    await expect(html).toMatchVisualSnapshot('button-primary');
  });

  test('secondary variant', async () => {
    const html = renderComponent(
      <Button variant="secondary">Cancel</Button>
    );
    await expect(html).toMatchVisualSnapshot('button-secondary');
  });

  test('disabled state', async () => {
    const html = renderComponent(
      <Button variant="primary" disabled>Click me</Button>
    );
    await expect(html).toMatchVisualSnapshot('button-disabled');
  });

  test('loading state', async () => {
    const html = renderComponent(
      <Button variant="primary" loading>Saving...</Button>
    );
    await expect(html).toMatchVisualSnapshot('button-loading');
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing with raw HTML (no framework needed)

The html parameter works with any HTML string — you don't need React or a framework at all:

// tests/visual/email-template.visual.test.ts
import fs from 'fs';
import path from 'path';

test('invoice email template renders correctly', async () => {
  const html = fs.readFileSync(
    path.join(__dirname, '../../templates/invoice-email.html'),
    'utf8'
  );

  // Replace template variables with test data
  const rendered = html
    .replace('{{customer_name}}', 'Acme Corp')
    .replace('{{invoice_number}}', 'INV-2026-001')
    .replace('{{amount}}', '$1,250.00')
    .replace('{{due_date}}', 'March 15, 2026');

  await expect(rendered).toMatchVisualSnapshot('invoice-email');
});
Enter fullscreen mode Exit fullscreen mode

Updating baselines

When you intentionally change a component's appearance, update the baselines:

UPDATE_SNAPSHOTS=true npx jest tests/visual/Button.visual.test.ts
Enter fullscreen mode Exit fullscreen mode

Or update a single snapshot by name:

UPDATE_SNAPSHOTS=true npx jest --testNamePattern="primary variant"
Enter fullscreen mode Exit fullscreen mode

Commit the updated PNG files in __visual_snapshots__/. The diff between the old and new baseline will be visible in your PR as a changed binary file — reviewers can download both and compare.

CI: fail on visual regression

# .github/workflows/visual.yml
name: Visual Regression

on:
  pull_request:
    branches: [main]

jobs:
  visual:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Run visual regression tests
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
        run: npx jest tests/visual/ --forceExit

      - name: Upload diff images on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: __visual_snapshots__/*-diff.png
Enter fullscreen mode Exit fullscreen mode

PageBolt html vs jest-image-snapshot + Puppeteer

jest-image-snapshot + Puppeteer PageBolt html parameter
Browser install ~300MB, in every CI runner None — API call
Rendering consistency Depends on runner OS, fonts, GPU Same hosted Chromium every time
Setup complexity Puppeteer config, launch options fetch() call
Works with raw HTML Yes Yes
Supports selector crop Via Puppeteer Yes, via selector param
Offline/private networks Yes Requires HTTPS or html param

The main case for Puppeteer is if you need to test pages on a private network (staging behind a VPN) where PageBolt can't reach the URL. For everything else — raw HTML rendering, public URLs, CI environments — the API approach is simpler, faster to set up, and produces more consistent results.

For component visual tests specifically, passing html directly means you never need a running dev server. Render the component to a string, POST it, get a PNG. That works in any environment, including offline CI runners, as long as you have outbound HTTPS access.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)