이 분야의 문서 목록

Playwright로 유의미한 시각 회귀 테스트 만들기

사용자 계약, 결정적 렌더링과 기준선 검토 절차를 결합해 신뢰할 수 있는 Playwright 시각 회귀 테스트를 구성합니다.

업데이트 근거 자료

핵심 원칙

스크린샷이 같다는 사실만으로 기능이 올바르다고 볼 수 없음. 사용자에게 보이는 동작을 assertion으로 확인하고, 그 동작이 만들어 낸 안정된 화면을 스크린샷으로 비교할 때 시각 회귀 테스트가 제품 계약이 됨

Playwright의 toHaveScreenshot()은 첫 실행에서 기준 이미지를 만들고 이후 실행에서 실제 렌더링과 비교한다. 이 비교는 레이아웃, 색상, 타이포그래피와 반응형 배치의 변화를 한 번에 찾을 수 있지만, 데이터와 실행 환경이 흔들리거나 변경된 기준선을 검토 없이 승인하면 신뢰할 수 없는 테스트가 된다.

이 문서는 apps/web/app/(tech)/visual.e2e.test.ts를 기준으로 무엇을 검증하고, 실패를 어떻게 판정하며, 언제 기준 이미지를 갱신할지 설명한다.

스크린샷보다 사용자 계약을 먼저 검증함

시각 테스트에는 서로 다른 세 가지 계약이 있다. 한 장의 스크린샷이 이 계약을 모두 대신하게 하지 않는다.

계약질문우선 검증
동작사용자가 목적을 달성할 수 있는가role 기반 locator와 web-first assertion
구조필요한 콘텐츠와 접근 가능한 이름이 있는가toBeVisible(), toHaveText(), toHaveAttribute()
외형배치, 간격, 색상과 반응형 결과가 의도와 같은가toHaveScreenshot()

유의미함은 assertion 수가 아니라 실패가 알려 주는 제품 위험으로 판단한다.

  • 테스트 이름에 시작 조건, 사용자 행동과 기대 결과 중 필요한 정보를 드러냄
  • 한 케이스는 하나의 사용자 위험을 보호하고, 여러 assertion은 같은 결과를 설명할 때만 묶음
  • CSS class나 DOM 깊이가 아니라 role, accessible name과 화면에 표시되는 결과를 검증함
  • 단독 실행과 순서 변경에도 같은 결과를 내도록 다른 테스트의 cookie, storage와 데이터에 의존하지 않음
  • 테스트를 삭제할 때 어떤 회귀를 놓치게 되는지 설명할 수 있어야 함

Playwright 권장사항은 구현 세부사항보다 사용자가 관찰하는 동작을 검증하고, 자동 대기와 재시도가 있는 locator와 web-first assertion을 사용하도록 안내한다. 또한 테스트 격리 문서는 각 테스트가 독립된 browser context에서 실행되어 실패 전파와 순서 의존성을 막는다고 설명한다. 따라서 각 테스트가 자신의 전제조건을 준비하고, 스크린샷 직전에 페이지의 핵심 의미가 준비되었는지 확인한다.

test("visual: overview-wide-light", async ({ page }) => {
  await page.setViewportSize({ width: 1440, height: 1000 });
  await page.addInitScript(() => {
    localStorage.setItem("tech-theme", "light");
  });
  await page.goto("/en");

  await expect(
    page.getByRole("heading", { level: 1, name: "Engineering" }),
  ).toBeVisible();
  await expect(
    page.getByRole("navigation", { name: "Editorial navigation" }),
  ).toBeVisible();

  await expect(page).toHaveScreenshot("overview-wide-light.png", {
    fullPage: true,
  });
});
  • heading과 navigation assertion 실패는 콘텐츠·접근성·라우팅 문제로 해석함
  • screenshot assertion 실패는 외형 또는 렌더링 환경 문제로 해석함
  • 두 실패를 분리하면 픽셀 diff만 보고 기능 정상 여부를 추측하지 않아도 됨

케이스는 화면 수가 아니라 위험으로 선택함

모든 페이지와 viewport의 곱을 캡처하면 저장 비용과 검토량만 증가한다. 각 케이스가 어떤 회귀를 잡는지 한 문장으로 설명할 수 있어야 한다.

대표 케이스검증할 위험
넓은 목록 화면grid column, 최대 너비, 전역 navigation과 footer
태블릿 문서 화면본문과 outline 전환 경계
모바일 다크 화면단일 column, overflow, theme token과 긴 제목 wrapping
시리즈 landing카드 순서, metadata와 collection layout
장문 문서code block, 표, 이미지와 전체 문서 흐름

케이스를 추가할 때는 다음 조건을 만족한다.

  • 기존 케이스와 다른 사용자 위험을 대표함
  • viewport width가 실제 breakpoint의 한쪽 결과를 명확히 대표함
  • light·dark와 locale 조합이 모든 순열이 아니라 다른 layout·token 위험을 대표함
  • 테스트 이름만 읽어도 경로, 화면 크기와 theme의 의도를 찾을 수 있음
  • 삭제할 때 잃는 회귀 검출 능력을 설명할 수 있음

전체 페이지는 navigation부터 footer까지 하나의 제품 계약일 때 사용한다. 독립 component나 특정 영역의 외형만 중요하다면 locator screenshot으로 diff 범위를 줄인다.

const article = page.getByRole("article");

await expect(article).toBeVisible();
await expect(article).toHaveScreenshot("document-article.png");

캡처 전에 입력과 렌더링을 결정적으로 만듦

공식 시각 비교 문서는 OS, 브라우저 버전, 설정과 하드웨어에 따라 렌더링이 달라질 수 있으므로 기준선을 만든 환경과 같은 환경에서 검증하도록 경고한다. 페이지 상태도 같은 원칙으로 고정한다.

  1. theme, locale cookie, reduced motion과 인증 상태처럼 초기 렌더링에 영향을 주는 값은 page.goto() 전에 설정함

    await page.addInitScript(() => {
      localStorage.setItem("tech-theme", "dark");
    });
    await page.goto("/en");
  2. 시간과 데이터를 통제함

    날짜나 상대 시간이 화면에 있으면 page.clock.setFixedTime()으로 시간을 고정함. API 응답이 화면을 바꾸면 page.route()로 작은 고정 fixture를 반환하거나 빌드 시점의 결정적 데이터를 사용함

    await page.clock.setFixedTime(new Date("2026-08-28T00:00:00Z"));
    await page.route("**/api/articles", async (route) => {
      await route.fulfill({
        json: [{ id: "visual-contract", title: "Visual contract" }],
      });
    });

    실제 backend 통합을 검증하는 테스트와 시각 fixture 테스트를 분리함. 외부 API의 현재 상태를 시각 기준선의 일부로 만들지 않음

  3. 관찰 가능한 준비 상태를 기다림

    고정된 timeout 대신 사용자가 볼 요소에 web-first assertion을 적용함. 폰트와 이미지 크기가 layout에 영향을 준다면 해당 resource의 완료도 기다림

    await expect(page.getByRole("main")).toBeVisible();
    await page.evaluate(() => document.fonts.ready);
    
    await page.evaluate(async () => {
      await Promise.all(
        [...document.images].map((image) =>
          image.decode().catch(() => undefined),
        ),
      );
    });

    waitForTimeout()과 임의의 긴 대기는 준비 상태를 증명하지 못하고 실행 시간만 늘리므로 사용하지 않음

  4. 변동 요소만 제한적으로 제거함

    animation과 caret은 screenshot 설정에서 비활성화함. 광고, 임의 avatar처럼 테스트 대상이 아닌 변동 영역은 mask 또는 stylePath로 통제함

    제품의 실제 timestamp, error state 또는 loading indicator가 검증 대상이라면 가리지 않고 입력을 고정함. 넓은 mask는 실제 회귀까지 숨길 수 있음

허용치는 노이즈가 아니라 계약으로 관리함

maxDiffPixelRatio, maxDiffPixelsthreshold는 서로 다른 문제를 조절한다.

옵션의미적용 기준
maxDiffPixelRatio전체 이미지에서 달라도 되는 픽셀 비율viewport가 다른 페이지 집합에 공통 비율이 필요할 때
maxDiffPixels달라도 되는 픽셀의 절대 개수크기가 일정한 작은 component에서 미세 노이즈를 제한할 때
threshold같은 위치 픽셀의 색상 차이 민감도anti-aliasing 차이를 조정할 명확한 근거가 있을 때
  • 먼저 데이터, 폰트, animation과 실행 환경을 안정화함
  • 안정화 후에도 반복되는 비제품 차이만 측정해 최소 허용치를 정함
  • 실패를 통과시키기 위해 전역 허용치를 즉시 높이지 않음
  • 페이지별 예외가 필요하면 이유와 제거 조건을 해당 assertion 가까이에 기록함
  • 이미지 높이가 다르거나 주요 component가 이동한 실패는 허용치 문제가 아니라 layout 변경으로 판정함

현재 Web 설정은 animation을 끄고 caret을 숨기며 CSS pixel scale과 0.001의 최대 diff 비율을 사용한다. 이 값은 결과를 자동 승인하는 기준이 아니라, 사람이 확인해야 할 diff를 발생시키는 경계다.

실패는 expected, actual과 diff를 함께 판정함

시각 테스트 실패는 버그와 테스트 환경 이상을 모두 의미할 수 있다. 다음 순서로 원인을 좁힌다.

  1. 기능 assertion과 URL이 먼저 통과했는지 확인함
  2. expected와 actual의 너비·높이가 같은지 확인함
  3. diff가 특정 component에 모이는지 페이지 전체에 번지는지 확인함
  4. 폰트, 이미지, 날짜, locale과 API 데이터가 동일한지 확인함
  5. trace에서 navigation, console과 network 실패를 확인함
  6. 제품 변경 설명과 diff가 일치할 때만 기준선 후보로 인정함

Playwright는 screenshot assertion에서 연속된 두 캡처가 같아질 때까지 재시도한 후 기준선과 비교한다. 따라서 captured a stable screenshot은 브라우저 안에서 두 이미지가 같았다는 뜻이지, 데이터와 환경이 제품 계약에 맞다는 뜻은 아니다.

현재 설정의 trace: "on-first-retry", 실패 screenshot과 video는 CI 실패 원인을 재현하는 증거다. 공식 권장사항에 따라 CI에서는 trace의 DOM snapshot, action log와 network를 함께 확인한다.

기준선은 생성물이 아니라 검토 대상임

기준 이미지 갱신은 테스트를 고치는 명령이 아니라 새 UI를 승인하는 변경이다. 전체 기준선을 한 번에 덮어쓰지 않고 실패한 케이스를 좁혀 처리한다.

  1. 갱신 없이 대상 테스트를 실행함

    저장소 root에서 현재 기준선이 무엇을 검출하는지 먼저 확인함

    pnpm --filter @jongminchung/web exec playwright test \
      visual.e2e.test.ts \
      --project=tech-chromium \
      --grep "visual: overview-wide-light"
  2. 실패 증거와 제품 변경을 대조함

    apps/web/test-results*-expected.png, *-actual.png, *-diff.png와 trace를 확인함. 변경 요청에 없는 영역까지 달라졌다면 기준선을 갱신하지 않고 원인을 수정함

  3. CI와 같은 환경에서 한 케이스만 갱신함

    Linux CI 기준선은 같은 Playwright·Chromium 버전과 Linux 환경에서 생성함

    pnpm --filter @jongminchung/web exec playwright test \
      visual.e2e.test.ts \
      --project=tech-chromium \
      --grep "visual: overview-wide-light" \
      --update-snapshots=changed

    macOS에서 생성한 *-darwin.png로 Linux CI 기준선을 대체하지 않음

  4. 갱신 옵션 없이 다시 검증함

    같은 명령에서 --update-snapshots를 제거하고 재실행함. 이후 전체 기술 시각 테스트를 실행해 한 기준선 변경이 다른 화면의 회귀를 숨기지 않았는지 확인함

    pnpm --filter @jongminchung/web exec playwright test \
      visual.e2e.test.ts \
      --project=tech-chromium
  5. Git diff에서 기준선 수명주기를 검토함

    git status --short -- \
      'apps/web/app/(tech)/visual.e2e.test.ts' \
      'apps/web/app/(tech)/visual.e2e.test.ts-snapshots'
    git diff --check

    추가된 케이스에는 기준 이미지가 있고 삭제·이름 변경된 케이스에는 고아 이미지가 없는지 확인함. PNG가 바뀌었다는 사실만 승인하지 않고 diff 화면의 의미를 review에 기록함

CI는 재현성과 실패 증거를 보장함

Playwright CI 가이드는 브라우저와 시스템 의존성을 설치한 뒤 테스트를 실행하고, 안정성이 우선인 CI에서는 worker 1을 권장한다. 시각 회귀 CI는 다음 계약을 유지한다.

  • lockfile로 Playwright와 Chromium 버전을 고정함
  • 기준선을 생성한 Linux 환경과 검증 환경을 일치시킴
  • 병렬 실행에서 간헐적 diff가 발생하면 resource contention을 측정하고 CI worker를 줄임
  • 실패 시 playwright-reporttest-results를 artifact로 보존함
  • retry로 최종 통과한 시각 테스트도 flaky 신호로 추적함
  • PR에서 PNG 변경과 제품 코드 변경을 함께 검토함

완료 조건

  • 각 screenshot 케이스가 보호하는 사용자 위험을 설명할 수 있음
  • screenshot 전에 사용자 동작과 핵심 구조가 web-first assertion으로 확인됨
  • 날짜, API 데이터, theme, locale, 폰트와 이미지가 결정적으로 준비됨
  • 전체 페이지와 locator screenshot의 범위가 검증 의도에 맞음
  • 허용치와 mask가 실제 제품 회귀를 숨기지 않음
  • 기준선이 CI와 같은 OS·브라우저 환경에서 생성됨
  • expected, actual, diff와 trace를 검토한 뒤에만 기준선이 갱신됨
  • 갱신 후 --update-snapshots 없이 대상 및 전체 테스트가 통과함
  • 누락된 기준 이미지와 삭제된 케이스의 고아 이미지가 없음

공식 문서