픽셀 비교의 한계를 넘어 Visual AI 기반 시각적 회귀 테스트를 다룹니다. Applitools Eyes, Percy, Chromatic 비교 분석과 동적 콘텐츠 처리, 반응형 레이아웃 테스트, 스토리북 통합을 안내합니다.
기능 테스트가 "동작이 올바른지"를 검증한다면, Visual Regression Test(시각적 회귀 테스트)는 "보이는 것이 올바른지"를 검증합니다. CSS 변경, 폰트 누락, 레이아웃 깨짐, 요소 겹침 등 기능 테스트로는 잡을 수 없는 시각적 결함을 감지합니다.
실제 프로젝트에서 시각적 결함은 배포 후 사용자가 먼저 발견하는 경우가 많습니다. 시각적 회귀 테스트는 이 간극을 메웁니다.
전통적인 시각적 테스트는 Pixel-to-pixel Comparison(픽셀 대 픽셀 비교) 방식입니다. 기준 스크린샷과 현재 스크린샷을 픽셀 단위로 비교하여 차이를 감지합니다.
| 문제 | 설명 |
|---|---|
| 과도한 노이즈 | 안티앨리어싱, 서브픽셀 렌더링 차이로 위양성 발생 |
| 동적 콘텐츠 | 날짜, 광고, 추천 콘텐츠 등이 매번 달라져 실패 |
| 플랫폼 차이 | 동일 코드가 OS/브라우저별로 다르게 렌더링 |
| 유지보수 부담 | 의도적 변경 시마다 기준 이미지를 수동 업데이트 |
| 의미 해석 불가 | 중요한 변화와 사소한 변화를 구분하지 못함 |
[기준 이미지] vs [현재 이미지]
-> 1px 안티앨리어싱 차이 -> 실패 (위양성)
-> 날짜 텍스트 변경 -> 실패 (위양성)
-> 버튼 누락 -> 실패 (진양성)
모두 "실패"로 보고되어 진짜 문제를 놓치기 쉽습니다.픽셀 비교 도구에서 threshold(임계값)를 높이면 위양성은 줄지만, 미묘한 실제 결함도 함께 무시됩니다. 이 딜레마가 Visual AI의 등장 배경입니다.
Visual AI는 픽셀 수준의 비교가 아닌, 사람의 시각 인지 방식을 모방하여 "의미 있는" 차이만 감지합니다.
Applitools Eyes는 강화 학습(Reinforcement Learning) 기반의 Visual AI를 사용합니다. 수억 장의 스크린샷 비교 데이터에서 학습하여, 사람이 "문제"라고 판단할 변화와 "무시해도 될" 변화를 구분합니다.
Applitools는 네 가지 비교 수준을 제공합니다.
| 수준 | 설명 | 사용 시나리오 |
|---|---|---|
| Exact | 픽셀 단위 정밀 비교 | 디자인 시스템 검증 |
| Strict | AI 기반 -- 사람이 인지할 수 있는 차이만 감지 | 일반적인 시각적 테스트 |
| Content | 콘텐츠(텍스트/이미지)만 비교, 스타일 무시 | 콘텐츠 중심 검증 |
| Layout | 구조와 배치만 비교, 콘텐츠 무시 | 반응형 레이아웃 검증 |
import { test } from "@playwright/test";
import { Eyes, Target, Configuration, BatchInfo } from "@applitools/eyes-playwright";
test.describe("시각적 회귀 테스트 -- Applitools", () => {
let eyes: Eyes;
test.beforeEach(async () => {
eyes = new Eyes();
const config = new Configuration();
config.setBatch(new BatchInfo("Sprint 42 Visual Tests"));
config.setApiKey(process.env.APPLITOOLS_API_KEY!);
// 반응형 테스트를 위한 다중 뷰포트
config.addBrowser(1920, 1080, "chrome");
config.addBrowser(1366, 768, "firefox");
config.addBrowser(375, 812, "chrome"); // 모바일
eyes.setConfiguration(config);
});
test.afterEach(async () => {
await eyes.close();
});
test("메인 페이지 시각적 검증", async ({ page }) => {
await eyes.open(page, "MyApp", "메인 페이지");
await page.goto("/");
// 전체 페이지 캡처
await eyes.check("전체 페이지", Target.window().fully());
// 특정 영역만 캡처
await eyes.check(
"헤더 영역",
Target.region("#header").strict()
);
// 동적 콘텐츠 무시
await eyes.check(
"메인 콘텐츠",
Target.region("#main-content")
.ignoreRegions(".ad-banner", ".datetime-display")
.layout()
);
});
test("다크 모드 시각적 검증", async ({ page }) => {
await eyes.open(page, "MyApp", "다크 모드");
await page.goto("/");
// 다크 모드 전환
await page.click("[data-testid='theme-toggle']");
await page.waitForTimeout(300); // 트랜지션 완료 대기
await eyes.check("다크 모드 전체", Target.window().fully().strict());
});
});Percy(BrowserStack)는 스크린샷 비교 기반의 시각적 테스트 플랫폼입니다. Applitools보다 단순하지만, CI 통합이 쉽고 가격이 합리적입니다.
import { test } from "@playwright/test";
import percySnapshot from "@percy/playwright";
test.describe("시각적 회귀 테스트 -- Percy", () => {
test("메인 페이지 스냅샷", async ({ page }) => {
await page.goto("/");
await percySnapshot(page, "메인 페이지", {
widths: [375, 768, 1280, 1920],
minHeight: 1024,
});
});
test("상품 목록 페이지 스냅샷", async ({ page }) => {
await page.goto("/products");
// 데이터 로딩 완료 대기
await page.waitForSelector("[data-testid='product-list']");
await percySnapshot(page, "상품 목록", {
percyCSS: `
.ad-banner { display: none !important; }
.timestamp { visibility: hidden !important; }
`,
});
});
});Chromatic은 Storybook과 깊이 통합된 시각적 테스트 플랫폼입니다. 컴포넌트 단위의 시각적 테스트에 최적화되어 있습니다.
npm install --save-dev chromatic
npx chromatic --project-token=YOUR_TOKEN| 특성 | Applitools Eyes | Percy | Chromatic |
|---|---|---|---|
| AI 수준 | Visual AI (강화 학습) | 스마트 비교 | DOM 기반 비교 |
| 동적 콘텐츠 처리 | 자동 무시 (AI) | CSS 오버라이드 | Storybook args 제어 |
| 반응형 테스트 | 다중 뷰포트 자동 | 다중 너비 지정 | 뷰포트 애드온 |
| Storybook 통합 | 가능 | 가능 | 네이티브 |
| 가격 모델 | 스크린샷 기반 | 스냅샷 기반 | 스냅샷 기반 |
| 위양성 비율 | 매우 낮음 | 낮음 | 중간 |
도구 선택 기준은 프로젝트의 특성에 따라 달라집니다. 컴포넌트 라이브러리 프로젝트라면 Chromatic이 적합하고, 풀 페이지 E2E 시각적 테스트에는 Applitools가, 합리적인 비용으로 시작하고 싶다면 Percy가 좋은 선택입니다.
시각적 테스트에서 가장 큰 위양성 원인은 동적 콘텐츠입니다. 날짜, 시간, 광고, 추천 항목, 사용자별 데이터 등이 매번 달라져 테스트가 실패합니다.
import { Page } from "@playwright/test";
/**
* 시각적 테스트를 위해 동적 콘텐츠를 안정화합니다.
*/
export async function stabilizeForVisualTest(page: Page): Promise<void> {
await page.addStyleTag({
content: `
/* 애니메이션 비활성화 */
*, *::before, *::after {
animation-duration: 0s !important;
animation-delay: 0s !important;
transition-duration: 0s !important;
transition-delay: 0s !important;
}
/* 캐러셀 자동 전환 비활성화 */
.carousel { animation-play-state: paused !important; }
/* 커서 깜빡임 비활성화 */
* { caret-color: transparent !important; }
`,
});
// 날짜/시간 표시를 고정값으로 대체
await page.evaluate(() => {
document.querySelectorAll("[data-dynamic='date']").forEach((el) => {
el.textContent = "2026-01-01";
});
document.querySelectorAll("[data-dynamic='time']").forEach((el) => {
el.textContent = "12:00:00";
});
});
// 이미지 로딩 완료 대기
await page.evaluate(() => {
return Promise.all(
Array.from(document.images)
.filter((img) => !img.complete)
.map(
(img) =>
new Promise((resolve) => {
img.onload = resolve;
img.onerror = resolve;
})
)
);
});
}반응형 디자인은 다양한 화면 크기에서 레이아웃이 올바르게 표시되는지 검증해야 합니다. Visual AI는 이 과정을 크게 단순화합니다.
import { test, expect } from "@playwright/test";
const viewports = [
{ name: "모바일", width: 375, height: 812 },
{ name: "태블릿", width: 768, height: 1024 },
{ name: "데스크톱", width: 1280, height: 800 },
{ name: "와이드", width: 1920, height: 1080 },
];
for (const viewport of viewports) {
test(`${viewport.name} 뷰포트에서 메인 페이지 검증`, async ({ page }) => {
await page.setViewportSize({
width: viewport.width,
height: viewport.height,
});
await page.goto("/");
// 레이아웃 모드로 비교 -- 콘텐츠 차이 무시, 구조만 검증
const screenshot = await page.screenshot({ fullPage: true });
expect(screenshot).toMatchSnapshot(
`main-page-${viewport.name}.png`,
{ threshold: 0.05 }
);
});
}반응형 테스트에서는 반드시 모든 기준 브레이크포인트를 포함해야 합니다. 특히 브레이크포인트 경계값(예: 767px와 768px)에서의 동작을 확인하는 것이 중요합니다. 이 경계에서 레이아웃이 갑자기 전환되면서 의도치 않은 겹침이나 오버플로우가 발생할 수 있습니다.
Storybook과 시각적 테스트를 통합하면 컴포넌트 수준에서 시각적 회귀를 감지할 수 있습니다.
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta: Meta<typeof Button> = {
title: "UI/Button",
component: Button,
argTypes: {
variant: {
control: "select",
options: ["primary", "secondary", "danger", "ghost"],
},
size: {
control: "select",
options: ["sm", "md", "lg"],
},
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: "primary", children: "기본 버튼" },
};
export const Secondary: Story = {
args: { variant: "secondary", children: "보조 버튼" },
};
export const Danger: Story = {
args: { variant: "danger", children: "삭제" },
};
export const AllVariants: Story = {
render: () => (
<div style={{ display: "flex", gap: "16px", padding: "16px" }}>
<Button variant="primary">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="danger">Danger</Button>
<Button variant="ghost">Ghost</Button>
</div>
),
};
export const Disabled: Story = {
args: { variant: "primary", children: "비활성", disabled: true },
};각 스토리가 곧 시각적 테스트 케이스가 됩니다. Chromatic이나 Applitools는 모든 스토리를 자동으로 캡처하고, 이전 빌드와 비교합니다.
이 장에서는 시각적 회귀 테스트의 전통적 방식과 AI 기반 방식을 비교하고, 실전 활용법을 살펴보았습니다.
핵심 내용을 정리하면 다음과 같습니다.
7장에서는 테스트 유지보수 자동화를 다룹니다. 테스트 부패(Test Rot) 문제의 근본 원인을 분석하고, AI 기반 셀프 힐링, 셀렉터 자동 재바인딩, 중복 테스트 감지, 커버리지 갭 분석 등 유지보수 비용을 줄이는 전략을 살펴봅니다.
이 글이 도움이 되셨나요?
관련 주제 더 보기
테스트 로트(Test Rot) 문제의 근본 원인과 AI 기반 셀프 힐링, 셀렉터 자동 재바인딩, 테스트 코드 리팩터링, 중복 테스트 감지, 커버리지 갭 분석 등 유지보수 비용 절감 전략을 다룹니다.
변이 테스트의 원리와 변이 연산자를 이해하고, Stryker, PIT, mutmut 도구로 AI 생성 테스트의 품질을 검증하는 방법을 다룹니다. 변이 점수 측정과 비용-효과 분석도 포함합니다.
변경 영향 분석 기반 테스트 선택, 위험 기반 우선순위, 플레이키 테스트 자동 격리, 병렬 실행 최적화, 결함 예측, GitHub Actions/GitLab CI 통합을 다루는 AI QA 파이프라인 구축 가이드입니다.