왜 비주얼 회귀 테스트가 필요한가
단위 테스트와 E2E 테스트는 "기능이 동작하는가"를 검증하지만, 화면이 실제로 어떻게 보이는가는 거의 다루지 못한다. CSS 리팩터링, 디자인 토큰 변경, 폰트 로딩 방식 수정 같은 작업은 로직상 아무 문제가 없어도 레이아웃을 깨뜨린다. 버튼이 20px 밀리거나 다크모드에서 텍스트가 배경과 같은 색이 되는 문제는 대부분 QA나 사용자가 먼저 발견한다.
비주얼 회귀 테스트는 렌더링된 화면을 이미지로 캡처해 기준 이미지(baseline)와 픽셀 단위로 비교한다. 코드 리뷰에서 놓치기 쉬운 시각적 변화를 diff 이미지로 명시해주기 때문에, "의도한 변경인지 사고인지"를 리뷰어가 눈으로 판단할 수 있게 만든다.
도구 선택 기준
크게 두 방향이 있다. 컴포넌트 단위로 캡처하는 Storybook 기반 방식과, 실제 브라우저에서 페이지를 렌더링하는 Playwright 방식이다.
| 구분 | Playwright | Storybook + 스냅샷 |
|---|---|---|
| 캡처 단위 | 페이지/컴포넌트 | 컴포넌트(스토리) |
| 렌더링 환경 | 실제 브라우저 | 격리된 스토리 |
| 도입 비용 | 낮음(기존 E2E 재활용) | 중간(스토리 작성 필요) |
| 플레이키 위험 | 중간 | 낮음 |
기존에 Playwright E2E가 있다면 스크린샷 비교 기능을 그대로 얹는 것이 가장 저렴하다. 아래는 Playwright의 내장 스냅샷 매처 예시다.
// tests/visual/home.spec.ts
import { test, expect } from '@playwright/test';
test('홈 화면 렌더링', async ({ page }) => {
await page.goto('/');
// 애니메이션·커서 깜빡임 등 비결정 요소 제거
await page.addStyleTag({ content: '*{transition:none!important;animation:none!important}' });
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('home.png', {
maxDiffPixelRatio: 0.01, // 1% 이내 차이는 통과
animations: 'disabled',
});
});
플레이키를 줄이는 렌더링 고정
비주얼 테스트가 실패하는 가장 흔한 원인은 실제 UI 버그가 아니라 환경 차이다. 폰트 안티에일리어싱, 날짜/시간 표시, 랜덤 데이터, 스크롤 위치, GPU 가속 여부에 따라 픽셀이 미세하게 달라진다.
- 렌더링은 반드시 동일한 컨테이너(예:
mcr.microsoft.com/playwright이미지)에서 수행한다. 로컬 macOS와 CI 리눅스는 폰트 렌더링이 달라 baseline이 호환되지 않는다. - 시간·랜덤 값은 고정한다(
page.clock또는 API 모킹). - 웹폰트는 로딩 완료를 기다린 뒤 캡처한다(
document.fonts.ready).
CI 파이프라인에 통합
핵심은 baseline 이미지를 리포지토리에 커밋하고, CI에서는 그 baseline과 비교만 하도록 만드는 것이다. baseline 갱신은 명시적인 액션으로만 허용해 "무심코 통과"를 막는다.
# .github/workflows/visual.yml
name: visual-regression
on: [pull_request]
jobs:
visual:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.48.0-jammy
steps:
- uses: actions/checkout@v4
- run: npm ci
# --update-snapshots 는 절대 CI 기본 실행에 넣지 않는다
- run: npx playwright test tests/visual --reporter=html
- name: diff 리포트 업로드
if: failure()
uses: actions/upload-artifact@v4
with:
name: visual-diff
path: playwright-report/
retention-days: 7
테스트가 실패하면 diff 이미지가 포함된 리포트를 아티팩트로 남긴다. 리뷰어는 CI 로그가 아니라 이 리포트를 열어 실제 변화를 확인한다.
baseline 갱신 워크플로
의도한 디자인 변경이라면 baseline을 갱신해야 한다. 로컬에서 갱신하되 반드시 CI와 동일한 컨테이너에서 생성해 환경 차이를 없앤다.
docker run --rm -v "$(pwd)":/work -w /work \
mcr.microsoft.com/playwright:v1.48.0-jammy \
npx playwright test tests/visual --update-snapshots
git add tests/visual/**/*.png
git commit -m "chore: 비주얼 baseline 갱신 (버튼 스타일 변경 반영)"
baseline 변경이 포함된 PR은 diff 이미지가 곧 리뷰 대상이 된다. 커밋 메시지에 "왜 바뀌었는지"를 남기는 습관이 사고 방지에 크게 기여한다.
운영 시 주의점
임계값(maxDiffPixelRatio)을 너무 낮게 잡으면 폰트 렌더링 미세 차이로 계속 실패하고, 너무 높게 잡으면 실제 깨짐을 놓친다. 프로젝트 초기에는 1% 내외에서 시작해 플레이키 발생 빈도를 보며 조정한다. 또한 baseline PNG가 리포지토리 용량을 키우므로, 캡처 대상은 핵심 화면과 반복 사용되는 컴포넌트로 한정하는 편이 유지보수에 유리하다.
마지막으로 비주얼 테스트는 기능 테스트를 대체하지 않는다. 화면이 동일하게 보여도 클릭이 동작하지 않을 수 있다. E2E·단위 테스트와 역할을 분리해 두고, 비주얼 테스트는 "보이는 것"만 책임지도록 범위를 명확히 하는 것이 오래 유지하는 비결이다.