화면은 동작만으로 검증되지 않는다. 어떻게 보이는가도 함께 검증되어야 한다.
사용자가 마주하는 문제
버튼의 hover 색이 어느 순간 사라졌습니다. 카드의 padding이 4px만큼 줄었습니다. 모달의 그림자가 다크 모드에서만 잘못 들어가고 있습니다. 기능은 멀쩡히 동작하니 단위 테스트도, E2E 테스트도 모두 초록불입니다. 그런데 사용자는 “뭔가 이상하다”고 느낍니다.
이런 변화는 단위 테스트로 잡히지 않습니다. 함수의 출력이 아니라 픽셀의 모양이 바뀐 일이라서요. 디자인 시스템을 운영하는 팀이라면 더 곤란합니다. 한 토큰을 바꿨는데, 어느 화면에서 깨졌는지 일일이 눈으로 다 볼 수 없습니다.
무엇을 검증하나
이 영역은 두 종류의 테스트가 짝을 이룹니다.
- 컴포넌트 테스트 — 컴포넌트의 렌더링과 상호작용을 검증합니다. “버튼을 클릭하면 onClick이 호출되는가”, “비활성 상태일 때 클릭이 막히는가”. 단위 테스트와 비슷하지만 DOM과 함께 돌아간다는 차이가 있습니다.
- 시각 회귀 테스트 — 렌더링된 화면의 픽셀을 이전 버전과 비교합니다. 의도한 변경은 새 baseline으로 승인하고, 의도하지 않은 변경은 빨간불을 띄웁니다.
두 가지 모두 컴포넌트가 단위입니다. 그래서 Storybook이 자연스러운 무대가 됩니다. Storybook의 각 스토리가 곧 테스트 대상이 됩니다.
언제, 어디서 실행하나
- 개발 중 — Storybook을 띄워두고 컴포넌트를 만들면서 함께 본다
- PR 단계 (CI) — 컴포넌트 테스트는 매 PR마다 실행. 시각 회귀는 baseline과 비교해 차이가 있으면 리뷰어가 승인/거절
- 디자인 토큰을 바꾼 PR — 시각 회귀의 진짜 가치가 드러나는 순간. 의도한 변경 범위와 의도하지 않은 변경 범위가 자동으로 분리됩니다
시각 회귀는 CI에서 다소 무겁습니다 (스토리 수 × 뷰포트 × 브라우저). 그래서 보통 PR마다 도는 것은 핵심 스토리들로 한정하고, 전체 회귀는 야간 배치로 돌리는 식의 운영이 흔합니다.
도구와 예시
자바스크립트 환경 기준으로 조합은 보통 다음과 같습니다.
- 컴포넌트 테스트 — Vitest + Testing Library, 또는 Playwright Component Testing
- 시각 회귀 — Chromatic(Storybook과 한 회사), Percy, 또는 Playwright의
toHaveScreenshot() - 공통 무대 — Storybook
간단한 컴포넌트 테스트 예시입니다. 카운터 버튼이 클릭 시 숫자를 늘리는지를 검증합니다.
// Counter.tsx
import { useState } from "react";
export function Counter({ initial = 0 }: { initial?: number }) {
const [count, setCount] = useState(initial);
return (
<button onClick={() => setCount(c => c + 1)} aria-label="증가">
{count}
</button>
);
}
// Counter.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { Counter } from "./Counter";
describe("Counter", () => {
it("초기값을 표시한다", () => {
render(<Counter initial={3} />);
expect(screen.getByRole("button")).toHaveTextContent("3");
});
it("클릭하면 1 증가한다", () => {
render(<Counter initial={0} />);
fireEvent.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveTextContent("1");
});
});
핵심은 getByRole처럼 사용자가 인식하는 방식으로 요소를 찾는다는 점입니다. CSS 셀렉터나 컴포넌트 내부 ID로 찾으면 리팩토링이 곧 테스트 깨뜨리기가 됩니다.
시각 회귀는 Storybook 위에서 자동화됩니다. 각 스토리에 대해 스냅샷을 찍고, 이전 baseline과 비교합니다.
// Button.stories.ts
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta: Meta<typeof Button> = { component: Button };
export default meta;
export const Primary: StoryObj<typeof Button> = {
args: { variant: "primary", children: "확인" },
};
export const Disabled: StoryObj<typeof Button> = {
args: { variant: "primary", disabled: true, children: "확인" },
};
export const Loading: StoryObj<typeof Button> = {
args: { variant: "primary", loading: true, children: "확인" },
};
Chromatic은 PR마다 이 스토리들을 라이트/다크 모드, 여러 뷰포트로 렌더링하고, baseline과 픽셀 비교한 결과를 PR에 코멘트로 답니다.
도입 체크리스트
- Storybook 설치 및 핵심 컴포넌트 10~20개 스토리 작성
- 라이트/다크 모드 토글 등 디자인 토큰을 스토리에서 전환 가능하게
- 컴포넌트 테스트 러너 결정 (Vitest + Testing Library 권장)
- 시각 회귀 도구 도입 (Chromatic 무료 플랜으로 시작 가능)
- PR 머지 조건에 컴포넌트 테스트 통과 포함
- 시각 회귀의 baseline 승인 권한 규칙 합의 (누가 승인하는가)
디자인 시스템을 운영한다면, Storybook은 사실상 디자인 명세서 역할까지 합니다. 스토리 작성을 디자이너와 함께 진행하면 협업 비용이 크게 줄어듭니다.
흔한 함정
- 스토리 없는 시각 회귀 — 시각 회귀는 대상이 있어야 비교할 수 있습니다. Storybook이 부실하면 시각 회귀도 부실해집니다. 도입 순서는 항상 Storybook이 먼저입니다.
- 거짓 양성 폭주 — 폰트 로딩 차이, 애니메이션, 날짜·시간 표시 같은 비결정적 요소가 매번 픽셀 차이를 만듭니다. 폰트는 사전 로딩, 애니메이션은 일시 정지, 시간은 고정하는 설정이 필요합니다.
- baseline 무지성 승인 — “어차피 다 통과시켜야 머지된다”는 분위기가 생기면 시각 회귀의 가치가 사라집니다. 한 번이라도 거절되는 경험이 있어야 정착됩니다.
- 너무 큰 스크린샷 — 한 페이지 전체를 한 스냅샷으로 찍으면, 작은 변경이 큰 diff를 만들어 리뷰가 어렵습니다. 컴포넌트 단위로 쪼개는 게 원칙입니다.
- 컴포넌트 테스트로 비즈니스 로직 검증 — 카운터 안에 환율 계산을 넣지 않듯이, 컴포넌트 테스트로 도메인 로직을 검증하지 않습니다. 로직은 단위 테스트로, 컴포넌트는 상호작용 검증으로.
참고 자료
- Storybook. 공식 문서. https://storybook.js.org
- Testing Library. 공식 문서. https://testing-library.com
- Chromatic. 공식 문서. https://www.chromatic.com/docs/
- Kent C. Dodds. “Testing Implementation Details”, https://kentcdodds.com/blog/testing-implementation-details
다음 편 예고
함수와 컴포넌트가 각자 잘 동작하는 것만으로는 부족합니다. 그것들이 서로 만나는 경계 - API, 데이터베이스, 외부 서비스와의 결합 지점에서 실패하는 일이 의외로 많습니다. 다음 편에서 다룹니다.