[React] Jest + Puppeteer로 VRT(Visual Regression Testing) 구축하기

2026-03-07 hit count image

Jest와 Puppeteer를 활용하여 React 컴포넌트의 VRT(Visual Regression Testing) 환경을 구축하는 방법을 공유합니다. 컴포넌트를 서버 사이드 렌더링 후 Puppeteer로 스크린샷을 찍어 jest-image-snapshot으로 비교하는 구조입니다.

react

개요

컴포넌트 라이브러리를 수정할 때, 의도치 않은 시각적 변경이 발생하지 않는지 확인하는 것은 매우 중요합니다. 단위 테스트만으로는 렌더링 결과의 시각적 차이를 검증하기 어렵습니다.

이 글에서는 Jest + Puppeteer + jest-image-snapshot을 사용하여 React 컴포넌트의 VRT(Visual Regression Testing) 환경을 구축하는 방법을 소개합니다.

이 글은 공통 컴포넌트의 테스트 방침을 결정하는 과정에서 Jest VRT를 검증한 내용을 정리한 것입니다. 전체 테스트 방침과 최종 도구 선정에 대해서는 [React] 모노레포 환경에서 공통 컴포넌트 테스트 방침을 참고해 주세요.

전체 구조

Jest + Puppeteer + jest-image-snapshot을 사용한 VRT의 동작 흐름은 다음과 같습니다.

  1. React 컴포넌트를 ReactDOMServer.renderToStaticMarkup()으로 HTML 문자열로 변환
  2. 컴포넌트의 SCSS 파일을 sass로 컴파일하여 CSS로 변환
  3. HTML + CSS를 조합한 완전한 HTML 페이지를 생성
  4. Puppeteer로 브라우저를 열어 해당 HTML을 렌더링
  5. #root 요소의 스크린샷을 캡처
  6. 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픽셀 단위 이미지 비교 알고리즘
pngjsPNG 이미지 처리

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-snapshottoMatchImageSnapshot 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)를 사용하고, #rootwidth: 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를 활용한 테스트 환경 구성에 대해 더 알고 싶으신 분은 아래 글도 참고해 주세요.

제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!

앱 홍보

책 홍보

블로그를 운영하면서 좋은 기회가 생겨 책을 출판하게 되었습니다.

아래 링크를 통해 제가 쓴 책을 구매하실 수 있습니다.
많은 분들에게 도움이 되면 좋겠네요.



SHARE
Twitter Facebook RSS