Visual Regression 테스트(VRT)는 코드 변경으로 인해 UI가 의도치 않게 달라지는지를 이미지 스냅샷으로 비교해 검출하는 기법입니다. React 컴포넌트는 재사용과 스타일 변경이 잦기 때문에 스토리 기반으로 스냅샷을 찍고 CI에서 자동 비교하는 흐름이 특히 효과적입니다.
1. 언제, 무엇을 테스트할까요?
디자인 시스템 컴포넌트, 핵심 화면의 주요 상태(로딩/빈 상태/에러), 테마/반응형 브레이크포인트, 다크모드, 브라우저별 렌더링 차이를 우선 대상으로 합니다. 데이터가 자주 변하거나 애니메이션이 많은 화면은 플래키(flaky)해지므로 제외하거나 마스킹합니다.
2. 도구 선택 가이드
Storybook + 호스티드 서비스: Chromatic, Percy는 스토리 단위 스냅샷과 리뷰 UI, 승인 흐름을 제공합니다. 러닝 커브가 낮고 PR 리뷰와 잘 통합됩니다.
Playwright 스냅샷: toHaveScreenshot으로 자체 호스팅 스냅샷을 돌릴 수 있습니다. 비용이 없고 커스터마이징이 쉬우며 컴포넌트/페이지 모두에 적용 가능합니다.
Cypress + Percy: 이미 Cypress E2E를 쓰고 있다면 자연스럽게 통합됩니다.
jest-image-snapshot/Puppeteer, Loki 등은 세밀한 제어가 가능하지만 설정 부담이 있습니다. 신규라면 Storybook + Chromatic 또는 Playwright를 권장합니다.
3. 최소 구현: Storybook + Playwright
아래는 React 버튼 컴포넌트를 스토리로 노출하고 Playwright로 시각적 스냅샷을 비교하는 예시입니다. baseline 이미지는 저장소에 함께 커밋합니다.
// src/components/Button.jsx
import React from 'react';
export function Button({ variant = 'primary', children, disabled, onClick }) {
const styles = {
base: {
padding: '8px 16px',
borderRadius: 6,
fontWeight: 600,
border: '1px solid transparent',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.6 : 1,
transition: 'background 150ms ease'
},
primary: { background: '#1f6feb', color: '#fff' },
secondary: { background: '#f6f8fa', color: '#24292f', border: '1px solid #d0d7de' }
};
const style = { ...styles.base, ...(variant === 'secondary' ? styles.secondary : styles.primary) };
return (
<button style={style} disabled={disabled} onClick={onClick}>
{children}
</button>
);
}
// src/components/Button.stories.jsx
import React from 'react';
import { Button } from './Button';
export default { title: 'DesignSystem/Button', component: Button, parameters: { layout: 'centered' } };
export const Primary = () => <Button variant="primary">Primary</Button>;
export const Secondary = () => <Button variant="secondary">Secondary</Button>;
export const Disabled = () => <Button disabled>Disabled</Button>;
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
use: {
viewport: { width: 800, height: 600 },
colorScheme: 'light',
deviceScaleFactor: 1,
},
expect: {
toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: 'disabled', threshold: 0.2 }
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
]
});
// tests/vrt.spec.ts
import { test, expect } from '@playwright/test';
const story = (id) => `http://localhost:6006/iframe.html?id=${id}&viewMode=story`;
test.beforeAll(async () => {
// 별도 터미널에서: npx storybook dev -p 6006 실행 또는 CI에서 정적 빌드/서버 사용
});
// 버튼 스토리 스냅샷
for (const id of [
'designsystem-button--primary',
'designsystem-button--secondary',
'designsystem-button--disabled'
]) {
test(`snapshot: ${id}`, async ({ page }) => {
await page.goto(story(id), { waitUntil: 'networkidle' });
await page.evaluate(() => {
// 폰트/애니메이션 변수를 줄여 플래키 방지
const style = document.createElement('style');
style.innerHTML = `*{animation:none!important;transition:none!important}`;
document.head.appendChild(style);
});
const root = page.locator('#root');
await expect(root).toHaveScreenshot({
mask: [page.locator('video, [data-dynamic]')],
timeout: 10000
});
});
}
실행 순서입니다. 1) Storybook을 6006 포트로 띄웁니다. 2) npx playwright test를 실행합니다. 최초 실행 시 __screenshots__에 베이스라인이 생성되며, 변경이 감지되면 CI에서 실패하고 diff를 제공합니다.
4. Percy/Cypress로 간단히 붙이기
이미 Cypress를 사용 중이면 Percy를 얹어 컴포넌트 또는 스토리를 스냅샷할 수 있습니다.
// cypress/component/Button.cy.jsx
import React from 'react';
import { Button } from '../../src/components/Button';
describe('Button VRT', () => {
it('primary', () => {
cy.mount(<Button>Primary</Button>);
cy.percySnapshot('Button - primary');
});
});
Percy 프로젝트 토큰을 설정하고 percy exec -- cypress run으로 실행합니다. 스냅샷은 Percy 웹에서 PR별로 리뷰/승인할 수 있습니다.
5. 플래키 줄이기 체크리스트
폰트 고정: 웹폰트는 정적 호스팅 후 preload합니다. CI에서는 같은 OS 이미지에서 렌더링합니다. 애니메이션/트랜지션 비활성화: 전역 CSS에서 animation/transition을 끄거나 Playwright 옵션에서 animations: 'disabled'를 사용합니다. 네트워크 안정화: waitUntil: 'networkidle', skeleton 제거 후 캡처합니다. 시간/랜덤 데이터 고정: Date.now, Math.random을 mock하고 시계/타임존을 고정합니다. 반응형/테마 분리: 각 뷰포인트/테마를 별도 테스트로 분리합니다. 픽셀 허용치: maxDiffPixelRatio를 0.01~0.05 범위로 시작해 점진적으로 조정합니다.
6. CI 통합과 승인 흐름
GitHub Actions 등에서 Storybook 정적 빌드를 하고 Playwright를 돌립니다. 변경이 있으면 테스트가 실패하고 diff 아티팩트를 업로드하거나, Chromatic/Percy 같은 서비스는 PR 코멘트로 결과를 첨부하고 리뷰어가 승인하면 베이스라인이 업데이트됩니다.
// package.json 스크립트 예시
{
"scripts": {
"sb:dev": "storybook dev -p 6006",
"sb:build": "storybook build -o storybook-static",
"test:vrt": "start-server-and-test \"npm run sb:dev\" http://localhost:6006 \"playwright test\""
}
}
7. 폴더 구조와 베스트 프랙티스
스토리 우선: 각 컴포넌트 상태를 스토리로 표현하고 이를 스냅샷 대상으로 사용합니다. 베이스라인 보관: __screenshots__를 버전에 포함해 리뷰 가능합니다. 리뷰 규칙: 1픽셀 수준 차이라도 이유를 설명하고 적정 허용치를 적용합니다. 테스트 예산: 핵심 경로/고위험 컴포넌트 위주로 유지해 실행 시간을 관리합니다.
8. 문제 해결 사전 목록
폰트 차이: 로컬/CI 폰트가 달라 생기는 문제는 Docker 이미지 통일 또는 시스템 폰트 설치로 해결합니다. 안티앨리어싱: deviceScaleFactor를 고정하고 동일 브라우저로만 비교합니다. 동적 영역: 날짜/아바타/랜덤 요소는 data-dynamic 속성으로 마스킹합니다. Next.js: 환경변수/서버시간 차이로 빌드 결과가 달라질 수 있어 정적 Storybook을 기준으로 테스트합니다.
정리하면, React에서는 스토리 중심 테스트와 안정화 체크리스트가 시각적 회귀를 가장 적은 비용으로 막아줍니다. 신규 팀은 Storybook + Chromatic 또는 Playwright부터 시작하고, 핵심 컴포넌트에 집중해 빠르게 가치를 얻는 전략을 권장합니다.
'React' 카테고리의 다른 글
| React 앱에서 RFID 리더기 데이터 처리하기 (0) | 2026.07.07 |
|---|---|
| React에서 웹 브라우저 멀티 스레드(Web Workers) 활용하기 (0) | 2026.07.07 |
| React에서 커스텀 훅으로 이벤트 디바운스 처리하기 (0) | 2026.07.03 |
| React 앱에서 화면 녹화(Screen Recording) 기능 구현 (0) | 2026.07.03 |
| React에서 비동기 컴포넌트 로딩 순서 제어하기 (0) | 2026.07.02 |