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"
}
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
Set your API key in your test environment:
# .env.test
PAGEBOLT_API_KEY=YOUR_API_KEY
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;
// jest.setup.ts
import dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });
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)`,
};
}
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>;
}
}
}
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');
});
});
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');
});
Updating baselines
When you intentionally change a component's appearance, update the baselines:
UPDATE_SNAPSHOTS=true npx jest tests/visual/Button.visual.test.ts
Or update a single snapshot by name:
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
# .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
Top comments (0)