[React] Building VRT (Visual Regression Testing) with Jest + Puppeteer

2026-03-07 hit count image

Sharing how to build a VRT (Visual Regression Testing) environment for React components using Jest and Puppeteer. The approach involves server-side rendering components, capturing screenshots with Puppeteer, and comparing them with jest-image-snapshot.

react

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:

  1. Convert React component to HTML string using ReactDOMServer.renderToStaticMarkup()
  2. Compile the component’s SCSS file to CSS using sass
  3. Generate a complete HTML page combining HTML + CSS
  4. Open a browser with Puppeteer and render the HTML
  5. Capture a screenshot of the #root element
  6. 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:

PackageRole
puppeteerHeadless browser for component rendering and screenshot capture
jest-image-snapshotCompares screenshot images against existing snapshots
pixelmatchPixel-level image comparison algorithm
pngjsPNG 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 a node environment 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 jsdom environment, VRT tests run in the node environment
  • VRT test files are distinguished by the *.vrt.test.tsx pattern
  • testPathIgnorePatterns is set to exclude VRT files from unit tests
  • globalSetup/globalTeardown manages 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.scss in the same directory as the test file)
  • When viewport is not specified, a large viewport (2000x2000) is used with width: fit-content on #root to generate screenshots matching the component size
  • Waits for network requests to complete with networkidle0 before 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 only
  • test:vrt: Run VRT tests only
  • test:vrt:update: Update VRT snapshots (when components are intentionally changed)
  • The --selectProjects option allows running unit tests and VRT independently

Summary

This article introduced how to build a VRT environment using Jest + Puppeteer.

ItemDetails
RenderingReactDOMServer.renderToStaticMarkup() + Puppeteer
StylesConvert SCSS to CSS with sass.compile()
Screenshot comparisonjest-image-snapshot + pixelmatch
Test separationSeparate unit and VRT tests with Jest projects
Browser managementShare 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:

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS