Table of Contents
Overview
When modifying a component library, it is crucial to verify that no unintended visual changes occur. Unit tests alone cannot verify visual differences in rendering output.
This article introduces how to build a VRT (Visual Regression Testing) environment for React components using Jest + Puppeteer + jest-image-snapshot.
This article documents the process of validating Jest VRT as part of establishing a testing strategy for shared components. For the overall testing strategy and final tool selection, see [React] Testing Strategy for Shared Components in a Monorepo.
Overall Architecture
The workflow of VRT using Jest + Puppeteer + jest-image-snapshot is as follows:
- Convert React component to HTML string using
ReactDOMServer.renderToStaticMarkup() - Compile the component’s SCSS file to CSS using
sass - Generate a complete HTML page combining HTML + CSS
- Open a browser with Puppeteer and render the HTML
- Capture a screenshot of the
#rootelement - Compare with the existing snapshot using
jest-image-snapshot
React Component
→ ReactDOMServer.renderToStaticMarkup() → HTML
→ sass.compile() → CSS
→ Puppeteer Page.setContent() → Browser rendering
→ Page.screenshot() → PNG image
→ jest-image-snapshot → Snapshot comparison
Environment Setup
The directory structure of files created in this article is as follows:
Project Root/
├── jest.config.js # Jest config (unit test + VRT separation)
├── jest-puppeteer.setup.ts # Puppeteer browser launch
├── jest-puppeteer.teardown.ts # Puppeteer browser shutdown
├── jest-setup.vrt.ts # VRT custom matcher registration
├── package.json # Run scripts
├── __vrt_snapshots__/ # VRT snapshot image directory
│ └── Button/
│ ├── button-primary.png
│ └── ...
└── src/
├── test-utils/
│ └── vrt-render.tsx # VRT rendering utility
└── components/
└── Button/
└── Button/
├── index.tsx
├── index.module.scss
└── index.vrt.test.tsx # VRT test file
Package Installation
Install the required packages to implement this structure.
yarn add -D puppeteer jest-image-snapshot @types/jest-image-snapshot pngjs @types/pngjs pixelmatch
The role of each package is as follows:
| Package | Role |
|---|---|
puppeteer | Headless browser for component rendering and screenshot capture |
jest-image-snapshot | Compares screenshot images against existing snapshots |
pixelmatch | Pixel-level image comparison algorithm |
pngjs | PNG image processing |
Jest Configuration
Existing unit tests and VRT tests need to be separated for the following reasons:
- Different execution environments — Unit tests run in a
jsdom(virtual DOM) environment, while VRT requires anodeenvironment to control a real browser with Puppeteer - Execution speed difference — VRT is slower than unit tests because it requires browser execution and screenshot capture. Separating them allows you to selectively run only the needed tests with
--selectProjects, enabling faster feedback during development - CI pipeline separation — Unit tests can run lightweight and always, while VRT can be managed separately, allowing flexible CI strategy configuration
To separate existing unit tests from VRT tests, you can use Jest’s projects feature.
// jest.config.js
const baseConfig = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
clearMocks: true,
};
module.exports = {
projects: [
// Unit tests
{
...baseConfig,
displayName: 'unit',
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnp: ['<rootDir>/jest-setup.ts'],
testMatch: ['**/*.test.tsx', '**/*.test.ts'],
testPathIgnorePatterns: ['\\.vrt\\.test\\.tsx$'],
},
// VRT tests
{
...baseConfig,
displayName: 'vrt',
preset: 'ts-jest',
testEnvironment: 'node',
setupFilesAfterSetup: ['<rootDir>/jest-setup.vrt.ts'],
testMatch: ['**/*.vrt.test.tsx'],
globalSetup: '<rootDir>/jest-puppeteer.setup.ts',
globalTeardown: '<rootDir>/jest-puppeteer.teardown.ts',
},
],
testTimeout: 30000,
};
The key points here are:
- Unit tests run in the
jsdomenvironment, VRT tests run in thenodeenvironment - VRT test files are distinguished by the
*.vrt.test.tsxpattern testPathIgnorePatternsis set to exclude VRT files from unit testsglobalSetup/globalTeardownmanages the Puppeteer browser instance
Puppeteer Setup/Teardown
Configure all VRT tests to share a single browser instance as follows.
// jest-puppeteer.setup.ts
import puppeteer from 'puppeteer';
export default async function globalSetup() {
const browser = await puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
],
});
// Save the WebSocket endpoint so tests can connect
process.env.PUPPETEER_WS_ENDPOINT = browser.wsEndpoint();
// Save to global so teardown can close the browser
(global as unknown as { __BROWSER__: typeof browser }).__BROWSER__ = browser;
}
// jest-puppeteer.teardown.ts
import type { Browser } from 'puppeteer';
export default async function globalTeardown() {
const browser = (global as unknown as { __BROWSER__?: Browser }).__BROWSER__;
if (browser) {
await browser.close();
}
}
Launching a browser is an expensive operation, and launching a new browser for each test case would significantly increase total test time. By launching the browser once in globalSetup and saving the WebSocket endpoint to an environment variable, each test only needs to connect to the already-running browser, greatly improving test execution speed.
VRT Setup File
Jest does not provide image comparison functionality by default. Therefore, you register a custom matcher as follows to use the expect(screenshot).toMatchImageSnapshot() format.
// jest-setup.vrt.ts
import path from 'path';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });
export const defaultImageSnapshotOptions = {
customSnapshotsDir: path.join(__dirname, '__vrt_snapshots__'),
failureThreshold: 0.01,
failureThresholdType: 'percent' as const,
comparisonMethod: 'pixelmatch' as const,
};
By registering jest-image-snapshot’s toMatchImageSnapshot matcher with expect.extend(), you can use image snapshot comparison directly in Jest’s assertion chain like expect(screenshot).toMatchImageSnapshot().
Also, defining default options in one place means you don’t need to repeatedly set options in each test, maintaining consistent comparison criteria.
failureThreshold: 0.01— Allows pixel differences within 1% (to handle minor differences from anti-aliasing, etc.)comparisonMethod: 'pixelmatch'— Uses pixel-level comparison method
VRT Rendering Utility
The following is the core rendering utility for VRT. It takes a React component and generates a screenshot using Puppeteer.
// src/test-utils/vrt-render.tsx
import path from 'path';
import type React from 'react';
import type { Browser, Page } from 'puppeteer';
import puppeteer from 'puppeteer';
import ReactDOMServer from 'react-dom/server';
import * as sass from 'sass';
import { defaultImageSnapshotOptions } from '../../jest-setup.vrt';
const srcDir = path.join(__dirname, '..');
const resetScssPath = path.join(srcDir, 'utils/styles/reset.scss');
const baseScssPath = path.join(srcDir, 'utils/styles/base.scss');
let browser: Browser | null = null;
interface VRTRenderOptions {
/** Fixed viewport size (auto-calculated if not specified) */
readonly viewport?: { readonly width: number; readonly height: number };
/** Rendering wait time in ms (default: 100) */
readonly waitForTimeout?: number;
/** SCSS file path (auto-detected if not specified) */
readonly scssPath?: string;
/** CSS string (direct input) */
readonly componentStyles?: string;
}
Browser Connection
Connect to the existing browser through the PUPPETEER_WS_ENDPOINT saved in globalSetup.
// src/test-utils/vrt-render.tsx (continued)
const setupBrowser = async (): Promise<void> => {
if (process.env.PUPPETEER_WS_ENDPOINT) {
browser = await puppeteer.connect({
browserWSEndpoint: process.env.PUPPETEER_WS_ENDPOINT,
});
} else {
browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
}
};
This eliminates the need to launch a new browser for each test.
SCSS Compilation and HTML Generation
The following applies the actual reset CSS and base CSS used in production to ensure rendering matches the production environment.
// src/test-utils/vrt-render.tsx (continued)
const compileScss = (scssPath: string): string => {
return sass.compile(scssPath, { loadPaths: [srcDir] }).css;
};
const getBaseStyles = (): string => {
const resetCss = compileScss(resetScssPath);
const baseCss = compileScss(baseScssPath);
return `
${resetCss}
${baseCss}
body { padding: 16px; background: #fff; }
`;
};
const buildHtml = (
content: string,
styles: string,
rootStyle: string
): string => `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
${getBaseStyles()}
${styles}
#root { ${rootStyle} }
</style>
</head>
<body>
<div id="root">${content}</div>
</body>
</html>
`;
Screenshot Capture
Render the component as HTML and capture a screenshot with Puppeteer.
// src/test-utils/vrt-render.tsx (continued)
const renderToImage = async (
component: React.ReactElement,
options: VRTRenderOptions = {}
): Promise<Buffer> => {
const { viewport, waitForTimeout = 100 } = options;
const scssPath =
options.scssPath ?? path.join(getTestDir(), 'index.module.scss');
if (!browser) {
await setupBrowser();
}
const page: Page = await browser!.newPage();
await page.setViewport(viewport ?? { width: 2000, height: 2000 });
const html = ReactDOMServer.renderToStaticMarkup(component);
const styles = getComponentStyles({ ...options, scssPath });
const rootStyle = viewport
? `width: ${viewport.width}px; height: ${viewport.height}px;`
: 'width: fit-content;';
const fullHtml = buildHtml(html, styles, rootStyle);
await page.setContent(fullHtml, { waitUntil: 'networkidle0' });
await new Promise((resolve) => setTimeout(resolve, waitForTimeout));
const rootElement = await page.$('#root');
const screenshot = await rootElement!.screenshot({ type: 'png' });
await page.close();
return screenshot as Buffer;
};
Key points:
- SCSS file path is auto-detected (uses
index.module.scssin the same directory as the test file) - When viewport is not specified, a large viewport (2000x2000) is used with
width: fit-contenton#rootto generate screenshots matching the component size - Waits for network requests to complete with
networkidle0before capturing the screenshot
Snapshot Comparison Helper
Create an expectToMatchVRTSnapshot helper function so you only need to pass the component and snapshot ID to handle screenshot capture and comparison in one step.
// src/test-utils/vrt-render.tsx (continued)
const expectToMatchVRTSnapshot = async (
component: React.ReactElement,
snapshotId: string,
options: VRTRenderOptions = {}
): Promise<void> => {
const testDir = getTestDir();
const componentName = path.basename(testDir);
const snapshotsDir = path.join(
srcDir,
'..',
'__vrt_snapshots__',
componentName
);
const screenshot = await renderToImage(component, options);
expect(screenshot).toMatchImageSnapshot({
...defaultImageSnapshotOptions,
customSnapshotsDir: snapshotsDir,
customSnapshotIdentifier: snapshotId,
});
};
Snapshots are saved in the __vrt_snapshots__/{ComponentName}/ directory.
The snapshotId is used as the snapshot file name. For example, passing an ID of button-primary will save and compare the snapshot at the path __vrt_snapshots__/Button/button-primary.png.
Writing Test Code
Using the utility created above, you can write test code concisely as follows:
// src/components/Button/Button/index.vrt.test.tsx
import { expectToMatchVRTSnapshot } from '../../../test-utils/vrt-render';
import { Button } from '.';
describe('Button VRT', () => {
describe('Variants', () => {
it('should match snapshot for primary variant', async () => {
await expectToMatchVRTSnapshot(
<Button variant="primary">Primary Button</Button>,
'button-primary'
);
});
it('should match snapshot for secondary variant', async () => {
await expectToMatchVRTSnapshot(
<Button variant="secondary">Secondary Button</Button>,
'button-secondary'
);
});
it('should match snapshot for destructive variant', async () => {
await expectToMatchVRTSnapshot(
<Button variant="destructive">Destructive Button</Button>,
'button-destructive'
);
});
});
describe('States', () => {
it('should match snapshot for disabled state', async () => {
await expectToMatchVRTSnapshot(
<Button variant="primary" disabled>
Disabled Button
</Button>,
'button-disabled'
);
});
});
describe('Sizes', () => {
it('should match snapshot for all height types', async () => {
await expectToMatchVRTSnapshot(
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<Button heightType="small">Small</Button>
<Button heightType="medium">Medium</Button>
<Button heightType="large">Large</Button>
</div>,
'button-height-types'
);
});
});
});
Simply pass the component and snapshot ID to expectToMatchVRTSnapshot.
Run Scripts
Add the following run scripts to package.json to run unit tests and VRT tests independently.
// package.json
{
"scripts": {
"test": "jest --watchAll",
"test:ci": "TZ=Asia/Tokyo jest --selectProjects unit --ci --bail",
"test:unit": "jest --selectProjects unit --watchAll",
"test:vrt": "jest --selectProjects vrt --watchAll",
"test:vrt:ci": "TZ=Asia/Tokyo jest --selectProjects vrt --ci --bail",
"test:vrt:update": "jest --selectProjects vrt --updateSnapshot"
}
}
test:unit: Run unit tests onlytest:vrt: Run VRT tests onlytest:vrt:update: Update VRT snapshots (when components are intentionally changed)- The
--selectProjectsoption allows running unit tests and VRT independently
Summary
This article introduced how to build a VRT environment using Jest + Puppeteer.
| Item | Details |
|---|---|
| Rendering | ReactDOMServer.renderToStaticMarkup() + Puppeteer |
| Styles | Convert SCSS to CSS with sass.compile() |
| Screenshot comparison | jest-image-snapshot + pixelmatch |
| Test separation | Separate unit and VRT tests with Jest projects |
| Browser management | Share single instance via globalSetup/globalTeardown |
With this structure, test code itself can be written in a single line as expectToMatchVRTSnapshot(component, id), minimizing the cost of adding VRT to new components.
For those interested in learning more about test environment configuration with Jest, see the following articles:
- [React] Testing with react-testing-library in create-react-app
- [Vite] Setting Up Test Environment in a TypeScript-based React Project
Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!
App promotion
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.