목차
개요
컴포넌트 라이브러리를 수정할 때, 의도치 않은 시각적 변경이 발생하지 않는지 확인하는 것은 매우 중요합니다. 단위 테스트만으로는 렌더링 결과의 시각적 차이를 검증하기 어렵습니다.
이 글에서는 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 테스트가 하나의 브라우저 인스턴스를 공유하도록 설정합니다.
// 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의 assertion 체인에서 바로 이미지 스냅샷 비교를 사용할 수 있습니다.
또한 기본 옵션을 한 곳에 정의해 두면, 각 테스트에서 반복적으로 옵션을 설정할 필요 없이 일관된 비교 기준을 유지할 수 있습니다.
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) 한 줄로 작성할 수 있으며, 새로운 컴포넌트에 VRT를 추가하는 비용을 최소화할 수 있습니다.
Jest를 활용한 테스트 환경 구성에 대해 더 알고 싶으신 분은 아래 글도 참고해 주세요.
- [React] create-react-app에서 react-testing-library로 테스트하기
- [Vite] TypeScript 기반 React 프로젝트에 테스트 환경 구성하기
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.