# 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:

```json
{
  "html": "<html><body><button class='btn'>Click me</button></body></html>",
  "width": 800,
  "height": 400,
  "format": "png"
}
```

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

```bash
npm install --save-dev jest @types/jest pixelmatch pngjs
# For React component rendering:
npm install --save-dev react react-dom @types/react
```

Set your API key in your test environment:

```bash
# .env.test
PAGEBOLT_API_KEY=YOUR_API_KEY
```

Load it in Jest config:

```typescript
// 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;
```

```typescript
// jest.setup.ts
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
```

## The custom `toMatchVisualSnapshot` matcher

```typescript
// 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)`,
  };
}
```

Register the matcher globally:

```typescript
// 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>;
    }
  }
}
```

## React component example

```typescript
// 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');
  });
});
```

## 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:

```typescript
// 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');
});
```

## Updating baselines

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

```bash
UPDATE_SNAPSHOTS=true npx jest tests/visual/Button.visual.test.ts
```

Or update a single snapshot by name:

```bash
UPDATE_SNAPSHOTS=true npx jest --testNamePattern="primary variant"
```

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

```yaml
# .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
```

## 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](https://pagebolt.dev/docs)
