Playwright로 구축하는 비주얼 회귀 테스트 파이프라인: 스크린샷 기준 이미지 관리부터 CI 디자인 리뷰 자동화까지

유닛 테스트가 "통과"해도 디자인은 무너진다
우리 프론트엔드 개발자들은 테스트 커버리지가 80%를 넘어도 프로덕션 배포 다음 날 디자이너에게 "헤더 폰트가 바뀌었어요"라는 메시지를 받는 경험을 한 번쯤 합니다. Button 컴포넌트의 로직은 완벽하게 동작하는데, CSS 변수 하나가 바뀌면서 브랜드 컬러가 슬그머니 회색으로 바뀌어 있는 상황입니다.
비주얼 회귀 테스트(Visual Regression Testing)는 바로 이 간극을 메웁니다. 코드가 아닌 픽셀을 기준으로 UI가 의도치 않게 변경됐는지 자동으로 검증하는 방식입니다. 통합 테스트 전반을 먼저 다룬 Vitest + MSW로 React 통합 테스트 설계하기나 TDD로 프론트엔드 개발하기도 함께 참고하면 좋습니다.
1. 비주얼 회귀 테스트가 필요한 이유
유닛 테스트와 통합 테스트는 로직과 데이터 흐름을 검증합니다. expect(price).toBe(9900) 같은 assertion은 숫자가 맞는지 확인하지만, 그 숫자가 화면에서 어떤 크기로 렌더링되는지는 전혀 알 수 없습니다.
비주얼 버그는 다양한 경로로 발생합니다.
- CSS 충돌: 전역 스타일시트에 새 규칙이 추가되면서 특정 컴포넌트의
margin이 덮어씌워집니다. - 의존성 업데이트: 디자인 시스템 패키지를 패치 버전으로 올렸는데 내부 토큰 값이 바뀌어 있습니다.
- 폰트 로딩 순서 변경: 폴백 폰트가 레이아웃을 밀어냅니다.
- 브라우저 기본 스타일 차이: Chromium 업데이트 이후
<button>기본 아웃라인 두께가 달라집니다.
| 테스트 유형 | 로직 버그 | 스타일 버그 | 레이아웃 버그 |
|---|---|---|---|
| 유닛 테스트 | O | X | X |
| 통합 테스트 | O | △ | X |
| E2E 기능 테스트 | O | X | X |
| 비주얼 회귀 테스트 | X | O | O |
비주얼 회귀 테스트는 앞의 테스트들을 대체하는 것이 아니라 보완합니다.
2. Playwright 설치와 스크린샷 API 기초
pnpm add -D @playwright/test
pnpm exec playwright install --with-deps chromium
Playwright 공식 스크린샷 문서에 따르면 page.screenshot() API는 전체 페이지, 특정 엘리먼트, 뷰포트 영역 세 가지 모드를 지원합니다.
import { test, expect } from '@playwright/test';
test.describe('Button 컴포넌트 비주얼 회귀', () => {
test.beforeEach(async ({ page }) => {
await page.goto('http://localhost:6006/iframe.html?id=ui-button--primary');
await page.waitForLoadState('networkidle');
});
test('Primary 버튼이 기준 이미지와 일치한다', async ({ page }) => {
const button = page.locator('[data-testid="btn-primary"]');
await expect(button).toHaveScreenshot('button-primary.png', {
maxDiffPixels: 50,
threshold: 0.1,
});
});
test('Hover 상태 버튼', async ({ page }) => {
const button = page.locator('[data-testid="btn-primary"]');
await button.hover();
await page.waitForTimeout(300);
await expect(button).toHaveScreenshot('button-primary-hover.png');
});
});
toHaveScreenshot()은 처음 실행 시 기준 이미지를 자동 생성하고, 이후 실행부터는 픽셀 단위로 비교합니다.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
snapshotDir: './tests/visual/__snapshots__',
updateSnapshots: process.env.UPDATE_SNAPSHOTS === 'true' ? 'all' : 'none',
use: {
baseURL: 'http://localhost:6006',
viewport: { width: 1280, height: 720 },
reducedMotion: 'reduce',
},
expect: {
toHaveScreenshot: {
threshold: 0.1,
maxDiffPixels: 100,
},
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
3. 기준 이미지(Baseline) 생성과 버전 관리 전략
Git에 직접 커밋하는 방식이 가장 단순합니다. Git LFS를 사용하면 저장소 크기 증가를 완화할 수 있습니다.
git lfs install
git lfs track "tests/visual/__snapshots__/**/*.png"
git add .gitattributes
기준 이미지 업데이트는 반드시 의도적인 행위여야 합니다. UPDATE_SNAPSHOTS=true 환경 변수를 명시적으로 설정해야만 갱신되도록 구성합니다.
기준 이미지 변경 PR은 디자이너와 공동 리뷰를 필수로 지정합니다.
# .github/CODEOWNERS
tests/visual/__snapshots__/ @frontend-team @design-team
4. 픽셀 비교 임계값과 안티앨리어싱 처리
비주얼 회귀 테스트에서 가장 골치 아픈 문제는 거짓 실패(False Positive) 입니다. 안티앨리어싱, 서브픽셀 렌더링, OS 폰트 힌팅 차이로 인해 픽셀 값이 미세하게 다르게 나타납니다.
| 컴포넌트 유형 | threshold | maxDiffPixels |
|---|---|---|
| 아이콘 · SVG | 0.05 | 10 |
| 텍스트 버튼 | 0.15 | 80 |
| 차트 · 그래프 | 0.2 | 200 |
| 전체 페이지 | 0.1 | 500 |
test('로딩 스켈레톤 비주얼 테스트', async ({ page }) => {
await page.goto('/stories/skeleton');
// CSS 애니메이션을 강제로 정지
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`,
});
const skeleton = page.locator('[data-testid="skeleton-card"]');
await expect(skeleton).toHaveScreenshot('skeleton-card.png');
});
5. Docker 환경에서 폰트·OS 렌더링 일관성 확보
비주얼 회귀 테스트의 가장 큰 함정은 로컬 맥북에서는 통과하고 CI(Linux)에서는 실패하는 현상입니다. 해결책은 동일한 Docker 이미지로 통일하는 것입니다.
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
WORKDIR /app
RUN apt-get update && apt-get install -y \
fonts-noto-cjk \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
RUN fc-cache -fv
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install --frozen-lockfile
COPY . .
ENV CI=true
ENV UPDATE_SNAPSHOTS=false
CMD ["pnpm", "exec", "playwright", "test", "--reporter=html"]
# 로컬에서 Docker로 기준 이미지 생성
docker run --rm \
-v $(pwd)/tests/visual/__snapshots__:/app/tests/visual/__snapshots__ \
-e UPDATE_SNAPSHOTS=true \
playwright-vrt \
pnpm exec playwright test --update-snapshots
폰트 버전을 고정하고, Docker 이미지 태그도 고정하는 것이 중요합니다.
6. GitHub Actions 워크플로우 통합과 아티팩트 업로드
name: Visual Regression Test
on:
pull_request:
branches: [main]
jobs:
visual-test:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.49.0-jammy
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- run: pnpm install --frozen-lockfile
- name: Install CJK fonts
run: |
apt-get update -qq
apt-get install -y --no-install-recommends fonts-noto-cjk
fc-cache -fv
- name: Build Storybook
run: pnpm build-storybook
- name: Run visual tests
run: |
pnpm dlx serve storybook-static --port 6006 &
sleep 5
pnpm exec playwright test --reporter=html,json
env:
CI: true
UPDATE_SNAPSHOTS: false
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-visual-report-${{ github.run_id }}
path: playwright-report/
retention-days: 14
- uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-diff-${{ github.run_id }}
path: tests/visual/__snapshots__/**/*-diff.png
retention-days: 7
GitHub Actions 아티팩트 문서에 따르면 actions/upload-artifact@v4는 워크플로우 실행당 최대 10GB를 저장할 수 있으며, 기본 보존 기간은 90일입니다.
7. PR 리뷰에 diff 이미지 자동 첨부하기
name: Visual Regression Comment
on:
workflow_run:
workflows: [Visual Regression Test]
types: [completed]
jobs:
comment-on-pr:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'failure'
permissions:
pull-requests: write
actions: read
steps:
- name: Comment on PR
uses: actions/github-script@v7
with:
script: |
const prNumber = context.payload.workflow_run.pull_requests[0]?.number;
if (!prNumber) return;
const runId = ${{ github.event.workflow_run.id }};
const artifactUrl =
`https://github.com/${{ github.repository }}/actions/runs/${runId}`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `## 비주얼 회귀 테스트 실패\n\nUI 픽셀 차이가 감지되었습니다.\n\n[리포트 보기](${artifactUrl})`,
});
이 워크플로우는 workflow_run 이벤트를 사용해 비주얼 테스트 워크플로우가 완료된 직후 트리거됩니다.
8. 컴포넌트 단위 vs 페이지 단위 테스트 전략
컴포넌트 단위: Storybook Story 활용. 안정성·속도 모두 우수.
페이지 단위: 실제 라우트, 네트워크 모킹 필요, 동적 콘텐츠 고정 필요.
test('상품 목록 페이지 비주얼 회귀', async ({ page }) => {
// 날짜·시간 고정
await page.clock.setFixedTime(new Date('2026-01-15T09:00:00'));
await page.route('**/api/products**', async (route) => {
await route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
data: [
{ id: 'p1', name: '무선 키보드', price: 89000 },
{ id: 'p2', name: '기계식 마우스', price: 65000 },
],
}),
});
});
await page.goto('/products');
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('product-list-page.png', {
fullPage: false,
maxDiffPixels: 300,
});
});
경험적으로 컴포넌트 단위 80%, 페이지 단위 20% 비율이 유지 비용 대비 효과가 좋습니다.
9. Percy·Chromatic 등 SaaS와의 비용·기능 비교
Chromatic 공식 문서에 따르면 Chromatic은 Storybook과 네이티브로 통합되어 Story 단위로 스크린샷을 찍고 클라우드에서 비교합니다.
| 항목 | 셀프 호스팅 Playwright | Percy | Chromatic |
|---|---|---|---|
| 기준 이미지 저장 | Git LFS / S3 | 클라우드 | 클라우드 |
| 디자이너 리뷰 UI | PR 코멘트 | 내장 | 내장 |
| 월 무료 한도 | 무제한 | 5,000장 | 5,000장 |
| 커스터마이징 | 높음 | 중간 | 낮음 |
소규모 팀이나 Storybook을 적극 사용하는 팀이라면 Chromatic이 초기 설정 비용을 낮춰줍니다. 디자인 시스템 규모가 크거나 보안상 코드 외부 업로드가 제한되는 환경이라면 셀프 호스팅이 현실적입니다.
10. 실전 운영 체크리스트
안티패턴: 절대로 피해야 할 것들
--update-snapshots플래그를 CI 파이프라인에 상시 활성화하기- 전체 페이지를
fullPage: true로 스크린샷하기 - 로딩 중 상태를 스크린샷하기 (
waitForLoadState('networkidle')없이) - 기준 이미지를 macOS에서 생성하고 Linux CI에서 비교하기
threshold를0.5이상으로 설정하기
체크리스트
- Docker 이미지 통일: 기준 이미지 생성 환경과 CI 환경이 동일한지 확인.
- Git LFS 설정: PNG 파일이 LFS로 트래킹되는지 점검.
- CODEOWNERS 등록: 디자인 팀이 필수 리뷰어로 지정.
- 동적 콘텐츠 고정:
page.clock또는 API 모킹. - 애니메이션 비활성화:
reducedMotion: 'reduce'+ 인라인 스타일 주입.
결론
비주얼 회귀 테스트 파이프라인은 "한 번 설정하면 끝"이 아닙니다. 디자인 시스템이 진화하고 컴포넌트가 추가될수록 기준 이미지도 함께 관리해야 합니다. 핵심은 업데이트를 어렵게 만드는 것이 아니라 업데이트를 명시적이고 추적 가능하게 만드는 것입니다.
Playwright의 toHaveScreenshot() API, Docker 기반 렌더링 환경 통일, GitHub Actions 아티팩트 업로드, PR 자동 코멘트까지 이 글에서 다룬 구성 요소들이 맞물리면, 디자이너와 개발자가 같은 diff 이미지를 보며 대화하는 협업 구조가 자연스럽게 만들어집니다.