# 시각적 회귀 테스트 — 스크린샷 비교

> '기능은 되는데 레이아웃이 깨졌어요.' Playwright의 toHaveScreenshot으로 baseline과 픽셀을 비교하는 시각적 회귀 테스트를 구현하고, 애니메이션·시간을 고정하는 법과 macOS·Linux 폰트 렌더링 차이라는 환경 복병까지 다룹니다.

**Published:** 2026-07-29 | **Updated:** 2026-07-29

---


> 「프론트엔드 테스트 제대로 하기」 시리즈의 **16편**입니다. [전체 목차 보기](/series/프론트엔드-테스트-제대로-하기/) · [용어집](/posts/frontend-testing-glossary/)

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

검색으로 처음 오셨어도 괜찮습니다. 예제는 자급자족하고, Playwright 기초는 [입문 편](/posts/frontend-testing-playwright-intro/)에 있어요.

> 실습 코드: 이 편의 상태가 [`step-16` 태그](https://github.com/IsaacEryn/frontend-testing-lab/tree/step-16)에 담겨 있습니다(`e2e/visual.spec.ts`와 커밋된 baseline 포함). [StackBlitz에서 바로 열기](https://stackblitz.com/github/IsaacEryn/frontend-testing-lab/tree/step-16)도 됩니다.

## 이 편을 읽고 나면

- 시각 회귀 테스트가 뭘 검증하는지, 유닛·E2E와 뭐가 다른지
- Playwright 스냅샷 비교 (`toHaveScreenshot`) — 전체·요소 단위, 허용치 조절
- 안정적인 스냅샷을 위한 팁과 baseline 관리
- 시각 회귀의 득과 실 (환경 차이 문제 포함)

{{< img src="images/contents/visual-diff.png" alt="기준 스크린샷과 변경된 스크린샷의 픽셀 차이를 강조 표시한 diff 이미지 예시" >}}

---

## 시각 회귀 테스트가 뭔가요

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

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

{{< img src="images/contents/what-each-test-sees.png" alt="테스트마다 보는 것이 다르다는 것을 세 칸으로 비교한 다이어그램 - 유닛 테스트는 값이 맞나(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)가 있어서, 파스텔급 미묘한 색 변화는 "다른 픽셀"로 안 칩니다. 그 변화, 어디서 본 것 같지 않나요 — [접근성 편](/posts/frontend-testing-axe-e2e/)에서 axe가 **대비율 2.16**이라는 숫자로 정확히 잡아낸 바로 그 변화예요. 도구마다 잡는 결이 다릅니다. 그래서 겹쳐 쓰는 거고요.

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

- **의도 안 한 변화다** → 축하합니다, 회귀를 잡았습니다. 코드를 고치세요
- **의도한 UI 변경이다** → 기준이 낡은 것뿐. baseline을 갱신하세요

```bash
npx playwright test --update-snapshots
```

{{< img src="images/contents/baseline-cycle.png" alt="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 목업 덕에 이미 고정돼 있어요 — [네트워크 모킹 편](/posts/frontend-testing-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로 테스트·접근성 게이트 만들기

> 낯선 용어가 있었다면 — [용어집](/posts/frontend-testing-glossary/)에 전부 한 줄씩 정리돼 있어요.

