「프론트엔드 테스트 제대로 하기」 시리즈의 16편입니다. 전체 목차 보기 · 용어집

모든 테스트가 초록불인데 화면이 깨진 적, 있으시죠? 로직은 멀쩡한데 CSS 하나가 레이아웃을 무너뜨린 경우요. 시각적 회귀 테스트는 기준 스크린샷(baseline)을 저장해두고 픽셀을 비교해서, 이런 “눈으로 봐야 아는 회귀"를 자동으로 잡는 기법입니다. Playwright에 내장돼 있어 한 줄이면 시작해요 — 대신 흔들리지 않게 찍는 요령과 환경 차이라는 복병까지, 이 글에서 다 짚습니다.

검색으로 처음 오셨어도 괜찮습니다. 예제는 자급자족하고, Playwright 기초는 입문 편에 있어요.

실습 코드: 이 편의 상태가 step-16 태그에 담겨 있습니다(e2e/visual.spec.ts와 커밋된 baseline 포함). StackBlitz에서 바로 열기도 됩니다.

이 편을 읽고 나면

  • 시각 회귀 테스트가 뭘 검증하는지, 유닛·E2E와 뭐가 다른지
  • Playwright 스냅샷 비교 (toHaveScreenshot) — 전체·요소 단위, 허용치 조절
  • 안정적인 스냅샷을 위한 팁과 baseline 관리
  • 시각 회귀의 득과 실 (환경 차이 문제 포함)
기준 스크린샷과 변경된 스크린샷의 픽셀 차이를 강조 표시한 diff 이미지 예시
기준 스크린샷과 변경된 스크린샷의 픽셀 차이를 강조 표시한 diff 이미지 예시

시각 회귀 테스트가 뭔가요

지금까지 이 시리즈에서 만든 테스트는 이 맞는지(유닛), 흐름과 구조가 되는지(E2E)를 봤습니다. 버튼을 누르면 그 행이 뜨는지, 함수가 5를 돌려주는지 같은 거요. 그런데 이 둘이 다 통과해도 못 잡는 게 있어요 — 화면이 실제로 어떻게 보이는지입니다.

예를 들어 CSS flexblock으로 잘못 바꿔서 카드가 세로로 주저앉았다고 해봅시다. 버튼은 여전히 “버튼"이고 이름도 그대로라 E2E는 초록불, 값도 멀쩡하니 유닛도 초록불이에요. 하지만 사용자 눈에는 레이아웃이 무너진 화면이 뜹니다. 값도 구조도 맞는데 ‘보이는 모양’만 깨진 이 틈이 바로 시각 회귀 테스트의 자리예요.

테스트마다 보는 것이 다르다는 것을 세 칸으로 비교한 다이어그램 - 유닛 테스트는 값이 맞나(sum(2,3)===5)를 보고 통과, E2E 테스트는 흐름과 구조가 되나(검색하면 그 행이 뜨나)를 보고 통과하지만, 시각 회귀 테스트는 모양이 그대로인가(레이아웃·색·간격)를 화면 픽셀로 대조해 여기서만 잡히는 게 있다. 유닛·E2E가 다 초록불이어도 CSS 한 줄이 레이아웃을 무너뜨릴 수 있고, 그 틈을 시각 회귀가 메운다
테스트마다 보는 것이 다르다는 것을 세 칸으로 비교한 다이어그램 - 유닛 테스트는 값이 맞나(sum(2,3)===5)를 보고 통과, E2E 테스트는 흐름과 구조가 되나(검색하면 그 행이 뜨나)를 보고 통과하지만, 시각 회귀 테스트는 모양이 그대로인가(레이아웃·색·간격)를 화면 픽셀로 대조해 여기서만 잡히는 게 있다. 유닛·E2E가 다 초록불이어도 CSS 한 줄이 레이아웃을 무너뜨릴 수 있고, 그 틈을 시각 회귀가 메운다

작동 방식은 이름 그대로입니다. 한 번 찍은 화면을 ‘기준’으로 저장해두고(이 기준 이미지를 baseline이라고 불러요), 이후 테스트를 돌릴 때마다 새로 찍은 화면을 그 baseline과 픽셀 단위로 대조합니다. 픽셀이 달라진 만큼이 곧 “화면이 변했다"는 신호고요. baseline은 코드처럼 저장소에 커밋해서 팀과 공유하는 게 핵심인데, 이 얘기는 실제로 찍어보면서 이어가죠.

Playwright엔 이 기능이 toHaveScreenshot이라는 이름으로 내장돼 있어, 별도 라이브러리 설치 없이 한 줄로 시작합니다. 바로 찍어봅시다.


스냅샷 비교

ts
test('대시보드 레이아웃이 유지된다', async ({ page }) => {
  await page.goto('/')
  await page.getByText('Alice Kim').waitFor()

  // 첫 실행: 이 이름으로 기준(baseline) 저장 / 이후: 기준과 픽셀 비교
  await expect(page).toHaveScreenshot('dashboard.png', {
    maxDiffPixelRatio: 0.01,   // 1% 이내 차이는 허용 (폰트 안티앨리어싱 등)
  })
})

첫 실행에서 미리 알아둘 게 하나 있어요 — 일부러 한 번 실패합니다.

bash
1 [chromium] › e2e/visual.spec.ts:3:1 › 대시보드 레이아웃이 유지된다 (653ms)
    Error: A snapshot doesn't exist at .../visual.spec.ts-snapshots/
    dashboard-chromium-darwin.png, writing actual.

버그가 아닙니다. “비교할 기준이 없어서, 지금 화면을 기준으로 저장해뒀다"는 뜻이에요(파일명의 -chromium-darwin 접미사는 브라우저·OS 표시 — 뒤에서 중요해집니다). 다시 실행하면 그 기준과 비교해서 통과하고, 이후부터가 진짜 감시입니다. 다르면 실패 + diff 이미지(달라진 픽셀이 강조된 그림)가 남아요.

진짜로 부숴봅시다. main에 어두운 배경색을 깔고 다시 돌리면:

bash
1 [chromium] › e2e/visual.spec.ts:3:1 › 대시보드 레이아웃이 유지된다 (794ms)
    Error: expect(page).toHaveScreenshot(expected) failed
      190833 pixels (ratio 0.21 of all image pixels) are different.

test-results/visual-대시보드-레이아웃이-유지된다-chromium/
├── dashboard-expected.png   ← 기준
├── dashboard-actual.png     ← 지금 화면
└── dashboard-diff.png       ← 달라진 픽셀만 강조

19만 픽셀(21%)이 다르다는 판정과 함께, 기준·현재·diff 세 장이 증거로 남습니다. 셋을 나란히 열면 뭐가 변했는지 한눈에 보여요.

그런데 실험 중에 재미있는 걸 발견했습니다. 목록의 이메일 색을 #595959에서 #b0b0b0으로 옅히는 은근한 변화는 통과하더라고요. 픽셀 비교에는 개수 허용치(maxDiffPixelRatio)와 별개로 픽셀당 색 거리 허용치(threshold, 기본 0.2)가 있어서, 파스텔급 미묘한 색 변화는 “다른 픽셀"로 안 칩니다. 그 변화, 어디서 본 것 같지 않나요 — 접근성 편에서 axe가 대비율 2.16이라는 숫자로 정확히 잡아낸 바로 그 변화예요. 도구마다 잡는 결이 다릅니다. 그래서 겹쳐 쓰는 거고요.

실패했을 때가 중요합니다. diff를 보고 둘 중 하나를 판정하세요.

  • 의도 안 한 변화다 → 축하합니다, 회귀를 잡았습니다. 코드를 고치세요
  • 의도한 UI 변경이다 → 기준이 낡은 것뿐. baseline을 갱신하세요
bash
npx playwright test --update-snapshots
baseline 스크린샷의 수명 주기 다이어그램 - 첫 실행은 기준을 저장하고, 이후 실행마다 픽셀을 비교해 같으면 통과합니다. 다르면 diff 이미지를 보고 판정하는 갈림길이 나오는데, 의도하지 않은 변화면 코드를 고치고, 의도한 UI 변경이면 update-snapshots로 기준을 갱신해 새 기준으로 다음 비교를 이어갑니다
baseline 스크린샷의 수명 주기 다이어그램 - 첫 실행은 기준을 저장하고, 이후 실행마다 픽셀을 비교해 같으면 통과합니다. 다르면 diff 이미지를 보고 판정하는 갈림길이 나오는데, 의도하지 않은 변화면 코드를 고치고, 의도한 UI 변경이면 update-snapshots로 기준을 갱신해 새 기준으로 다음 비교를 이어갑니다

이 판정을 사람이 해야 한다는 것 — 그게 시각 회귀의 비용입니다. UI를 자주 바꾸는 화면에 걸어두면 갱신만 하다 하루가 가요. 그래서 자주 안 바뀌어야 하는 핵심 화면에 거는 게 요령입니다.

전체 말고 ‘요소만’ 찍기

page 대신 특정 요소(로케이터)에 붙이면, 그 요소만 잘라서 비교합니다. 페이지 전체를 찍으면 무관한 영역 하나만 바뀌어도 실패하는데, 검증하고 싶은 카드 하나만 찍으면 흔들림이 확 줄어요.

ts
// 페이지 전체가 아니라 이 카드 하나만 baseline으로
await expect(page.getByTestId('user-card')).toHaveScreenshot('user-card.png')

반대로 뷰포트 밖 스크롤 영역까지 통째로 찍고 싶으면 fullPage: true를 줍니다. 실무에선 “핵심 요소 몇 개를 콕 집어” 찍는 쪽이 유지 비용이 훨씬 쌉니다.

baseline은 저장소에 커밋한다

처음 실행하면 visual.spec.ts-snapshots/ 폴더에 기준 PNG가 생깁니다. 이 파일들은 테스트의 일부예요 — 코드처럼 git에 커밋해서 팀·CI와 공유해야 합니다. 커밋을 빠뜨리면 다른 사람 컴퓨터나 CI엔 비교할 기준이 없어서 매번 “기준 없음"으로 처음처럼 통과해버리고, 정작 회귀는 못 잡습니다.

bash
git add e2e/visual.spec.ts-snapshots/
git commit -m "test: 대시보드 baseline 추가"

그래서 baseline을 갱신할 때(--update-snapshots)는 바뀐 PNG도 함께 커밋하는 게 한 세트입니다. 코드 리뷰에서 이 PNG diff를 같이 보면, “UI가 이렇게 바뀝니다"가 그림으로 보여서 리뷰어도 편해져요.


안정적으로 찍기

스냅샷을 흔드는 3대장은 애니메이션·시간·랜덤 데이터입니다.

ts
await expect(page).toHaveScreenshot('dashboard.png', {
  animations: 'disabled',   // CSS 애니메이션 정지
  mask: [page.getByTestId('clock')],  // 시계처럼 변하는 영역 가리기
})

데이터는 MSW 목업 덕에 이미 고정돼 있어요 — 네트워크 모킹 편의 또 다른 보너스입니다. 진짜 API였다면 사용자가 한 명 늘 때마다 스크린샷이 흔들렸겠죠.


환경 차이라는 복병

baseline은 환경(OS·브라우저 버전)마다 폰트 렌더링이 달라요. macOS에서 찍은 기준으로 Linux CI에서 비교하면 거의 항상 실패합니다. 그래서 실무에선 (1) CI와 같은 Docker 이미지에서 baseline 생성, (2) 시각 회귀 전용 서비스 사용, (3) 핵심 화면 몇 개로 범위 제한 — 중 하나를 택해요.

(1)번이 궁금하다면, Playwright 공식 이미지로 이렇게 만듭니다. (Docker가 처음이라면 지금은 건너뛰고 (3)번 방법으로 시작해도 충분해요.)

bash
# CI(리눅스)와 같은 환경에서 baseline 생성
docker run --rm -v $(pwd):/work -w /work \
  mcr.microsoft.com/playwright:v1.61.0-noble \
  npx playwright test --update-snapshots

버전 태그는 로컬 @playwright/test 버전과 맞추세요(데모 repo는 1.61). 이렇게 만든 스냅샷은 파일명에 -linux 접미사가 붙어, CI에서 그대로 비교됩니다. 데모 repo의 CI에는 이 이유로 시각 테스트를 기본 포함하지 않았습니다(로컬 실습용).


한 장 요약

  • 시각 회귀 테스트 = 화면의 ‘보이는 모양’을 검증 — 값(유닛)·구조(E2E)가 다 맞아도 CSS가 무너뜨린 레이아웃은 여기서만 잡힌다
  • 원리는 baseline 스크린샷과 픽셀 비교toHaveScreenshot('이름.png') 한 줄, 첫 실행은 기준을 저장하며 일부러 한 번 실패
  • page에 붙이면 페이지 전체, 로케이터에 붙이면 그 요소만 — 실무는 핵심 요소만 콕 집어 찍는 게 유지가 쉽다
  • baseline PNG는 테스트의 일부snapshots/ 폴더를 git에 커밋해야 CI·팀이 공유한다. 갱신(--update-snapshots) 시 바뀐 PNG도 함께 커밋
  • 실패하면 기준·현재·diff 세 장이 남고, 의도된 변경이면 --update-snapshots로 기준 갱신
  • 허용치는 두 겹: 픽셀 개수(maxDiffPixelRatio)와 픽셀당 색 거리(threshold, 기본 0.2) — 미묘한 색 변화는 통과할 수 있으니 대비 문제는 axe에 맡기기
  • 흔드는 3대장(애니메이션·시간·랜덤 데이터)은 animations: 'disabled'·mask·목 데이터로 고정
  • 환경 차이가 최대 복병 — macOS 기준으로 Linux CI와 비교하면 폰트 렌더링 차이로 거의 항상 실패. baseline은 CI와 같은 환경(Docker)에서
  • 전부 찍으려 하지 말 것 — 핵심 화면 몇 개로 시작하는 게 유지 가능한 범위

눈이 하던 일을 기계에게

테스트 도구 상자가 꽤 두둑해졌어요. 다음 챕터는 이 모든 걸 자동으로 돌리는 CI입니다.

폰트 하나 때문에 CI가 빨간불이 되면 억울하지만, 그게 바로 시각 회귀가 일하고 있다는 증거예요. (baseline은 같은 환경에서 찍읍시다.)

레벨업: 눈으로만 보이던 UI 회귀를 스크린샷 비교로 잡을 수 있습니다.

다음 편: GitHub Actions로 테스트·접근성 게이트 만들기

낯선 용어가 있었다면 — 용어집에 전부 한 줄씩 정리돼 있어요.