目次
概要
コンポーネントライブラリを修正する際、意図しない視覚的変更が発生しないか確認することは非常に重要です。ユニットテストだけではレンダリング結果の視覚的な差異を検証するのは困難です。
この記事では、Jest + Puppeteer + jest-image-snapshotを使用してReactコンポーネントのVRT(Visual Regression Testing)環境を構築する方法を紹介します。
この記事は、共通コンポーネントのテスト方針を決めるプロセスでJest VRTを検証した内容をまとめたものです。全体的なテスト方針と最終的なツール選定については [React] モノレポ環境での共通コンポーネントテスト方針 を参考にしてください。
全体構成
Jest + Puppeteer + jest-image-snapshotを使用したVRTの動作フローは以下の通りです。
- Reactコンポーネントを
ReactDOMServer.renderToStaticMarkup()でHTML文字列に変換 - コンポーネントのSCSSファイルを
sassでコンパイルしてCSSに変換 - HTML + CSSを組み合わせた完全なHTMLページを生成
- Puppeteerでブラウザを開いて該当HTMLをレンダリング
#root要素のスクリーンショットをキャプチャjest-image-snapshotで既存スナップショットと比較
React Component
→ ReactDOMServer.renderToStaticMarkup() → HTML
→ sass.compile() → CSS
→ Puppeteer Page.setContent() → ブラウザレンダリング
→ Page.screenshot() → PNG画像
→ jest-image-snapshot → スナップショット比較
環境設定
この記事で作成するファイルのディレクトリ構成は以下の通りです。
プロジェクトルート/
├── jest.config.js # Jest設定(ユニットテスト + VRT分離)
├── jest-puppeteer.setup.ts # Puppeteerブラウザ起動
├── jest-puppeteer.teardown.ts # Puppeteerブラウザ終了
├── jest-setup.vrt.ts # VRT用カスタムmatcher登録
├── package.json # 実行スクリプト
├── __vrt_snapshots__/ # VRTスナップショット画像保存ディレクトリ
│ └── Button/
│ ├── button-primary.png
│ └── ...
└── src/
├── test-utils/
│ └── vrt-render.tsx # VRTレンダリングユーティリティ
└── components/
└── Button/
└── Button/
├── index.tsx
├── index.module.scss
└── index.vrt.test.tsx # VRTテストファイル
パッケージインストール
この構成を実装するために必要なパッケージをインストールします。
yarn add -D puppeteer jest-image-snapshot @types/jest-image-snapshot pngjs @types/pngjs pixelmatch
インストールしたパッケージの役割は以下の通りです。
| パッケージ | 役割 |
|---|---|
puppeteer | ヘッドレスブラウザでコンポーネントレンダリングとスクリーンショットキャプチャ |
jest-image-snapshot | スクリーンショット画像を既存スナップショットと比較 |
pixelmatch | ピクセル単位の画像比較アルゴリズム |
pngjs | PNG画像処理 |
Jest設定
既存のユニットテストとVRTテストは以下の理由で分離する必要があります。
- 実行環境が異なります — ユニットテストは
jsdom(仮想DOM)環境で実行されますが、VRTはPuppeteerで実際のブラウザを制御する必要があるためnode環境が必要です - 実行速度の差 — VRTはブラウザの起動とスクリーンショットキャプチャが必要なため、ユニットテストより遅くなります。分離すれば
--selectProjectsで必要なテストだけを選択的に実行でき、開発中に素早いフィードバックを得られます - CIパイプラインの分離 — ユニットテストは軽量に常時実行し、VRTは別途管理するなど、CI戦略を柔軟に構成できます
このように既存のユニットテストとVRTテストを分離するために、Jestのprojects機能を活用できます。
// jest.config.js
const baseConfig = {
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.(css|less|scss)$': 'identity-obj-proxy',
},
clearMocks: true,
};
module.exports = {
projects: [
// ユニットテスト
{
...baseConfig,
displayName: 'unit',
preset: 'ts-jest',
testEnvironment: 'jsdom',
setupFilesAfterEnp: ['<rootDir>/jest-setup.ts'],
testMatch: ['**/*.test.tsx', '**/*.test.ts'],
testPathIgnorePatterns: ['\\.vrt\\.test\\.tsx$'],
},
// VRTテスト
{
...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,
};
ここでのポイントは以下の通りです。
- ユニットテストは
jsdom環境、VRTテストはnode環境で実行します - VRTテストファイルは
*.vrt.test.tsxパターンで区別します - ユニットテストからVRTファイルを除外するために
testPathIgnorePatternsを設定します globalSetup/globalTeardownでPuppeteerブラウザインスタンスを管理します
Puppeteer Setup/Teardown
次のように、すべてのVRTテストが1つのブラウザインスタンスを共有するように設定します。
// 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',
],
});
// テストから接続できるようにWebSocketエンドポイントを保存
process.env.PUPPETEER_WS_ENDPOINT = browser.wsEndpoint();
// Teardownでブラウザを閉じられるようにグローバルに保存
(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();
}
}
ブラウザの起動はコストの高い処理であるため、テストケースごとにブラウザを新しく起動すると全体のテスト時間が大幅に増加します。globalSetupでブラウザを一度だけ起動し、WebSocketエンドポイントを環境変数に保存しておけば、各テストでは起動中のブラウザに接続するだけで済むため、テスト実行速度を大幅に改善できます。
VRT Setupファイル
Jestはデフォルトで画像比較機能を提供していません。そのため、以下のようにカスタムmatcherを登録してexpect(screenshot).toMatchImageSnapshot()の形式で使用できるようにします。
// 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,
};
jest-image-snapshotのtoMatchImageSnapshot matcherをexpect.extend()で登録すると、expect(screenshot).toMatchImageSnapshot()のようにJestのアサーションチェーンで画像スナップショット比較を直接使用できます。
また、デフォルトオプションを一箇所で定義しておくことで、各テストで繰り返しオプションを設定する必要がなく、一貫した比較基準を維持できます。
failureThreshold: 0.01— 1%以内のピクセル差は許容します(アンチエイリアシング等による微小な差異への対応)comparisonMethod: 'pixelmatch'— ピクセル単位の比較方式を使用します
VRTレンダリングユーティリティ
以下はVRTの核心であるレンダリングユーティリティです。Reactコンポーネントを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 {
/** 固定ビューポートサイズ(未指定時は自動計算) */
readonly viewport?: { readonly width: number; readonly height: number };
/** レンダリング待機時間 ms(デフォルト: 100) */
readonly waitForTimeout?: number;
/** SCSSファイルパス(未指定時は自動検出) */
readonly scssPath?: string;
/** CSS文字列(直接入力) */
readonly componentStyles?: string;
}
ブラウザ接続
globalSetupで保存したPUPPETEER_WS_ENDPOINTを通じて既存のブラウザに接続します。
// src/test-utils/vrt-render.tsx(続き)
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'],
});
}
};
これにより、テストごとにブラウザを新しく起動する必要がなくなります。
SCSSコンパイルとHTML生成
次のように、実際のプロダクトで使用しているreset CSSとbase CSSをそのまま適用して、プロダクトと同一の環境でレンダリングされるようにします。
// src/test-utils/vrt-render.tsx(続き)
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>
`;
スクリーンショットキャプチャ
次のように、コンポーネントをHTMLにレンダリングした後、Puppeteerでスクリーンショットをキャプチャします。
// src/test-utils/vrt-render.tsx(続き)
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;
};
ここでのポイントは以下の通りです。
- SCSSファイルパスを自動検出します(テストファイルと同じディレクトリの
index.module.scss) - ビューポート未指定時は大きなビューポート(2000x2000)を使用し、
#rootにwidth: fit-contentを適用してコンポーネントサイズに合ったスクリーンショットを生成します networkidle0でネットワークリクエストが完了するまで待機してからスクリーンショットをキャプチャします
スナップショット比較ヘルパー
expectToMatchVRTSnapshotヘルパー関数を作成し、コンポーネントとスナップショットIDを渡すだけでスクリーンショットの撮影から比較まで一括で処理できるようにします。
// src/test-utils/vrt-render.tsx(続き)
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,
});
};
スナップショットは__vrt_snapshots__/{コンポーネント名}/ディレクトリに保存されます。
snapshotIdはスナップショットファイル名として使用されます。例えばbutton-primaryというIDを渡すと、__vrt_snapshots__/Button/button-primary.pngパスにスナップショットが保存・比較されます。
テストコードの作成
上記で作成したユーティリティを使用すると、以下のようにテストコードを簡潔に記述できます。
// 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'
);
});
});
});
このようにexpectToMatchVRTSnapshotにコンポーネントとスナップショットIDを渡すだけで済みます。
実行スクリプト
package.jsonに以下の実行スクリプトを追加して、ユニットテストとVRTテストを独立して実行できるようにします。
// 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: ユニットテストのみ実行test:vrt: VRTテストのみ実行test:vrt:update: VRTスナップショット更新(コンポーネントを意図的に変更した場合)--selectProjectsオプションでユニットテストとVRTを独立して実行できます
まとめ
この記事では、Jest + Puppeteerを使用したVRT環境の構築方法を紹介しました。
| 項目 | 内容 |
|---|---|
| レンダリング | ReactDOMServer.renderToStaticMarkup() + Puppeteer |
| スタイル | sass.compile()でSCSSをCSSに変換して適用 |
| スクリーンショット比較 | jest-image-snapshot + pixelmatch |
| テスト分離 | Jest projectsでユニットテストとVRTテストを分離 |
| ブラウザ管理 | globalSetup/globalTeardownで単一インスタンス共有 |
この構成により、テストコード自体はexpectToMatchVRTSnapshot(component, id)の1行で記述でき、新しいコンポーネントにVRTを追加するコストを最小化できます。
Jestを活用したテスト環境構成についてさらに知りたい方は、以下の記事も参考にしてください。
私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!
アプリ広報
Dekuが開発したアプリを使ってみてください。Dekuが開発したアプリはFlutterで開発されています。興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。