seungjun.dev

Storybook 알아보기

프론트엔드 테스트 도구 중 하나인 Storybook에 대해

Storybook

Storybook은 리액트 컴포넌트를 앱의 전체적인 맥락(라우팅, API 통신 등)과 완전히 분리하여, 독립적인 UI 개발 환경에서 구축하고 시각적으로 테스트할 수 있게 해주는 도구이다.

단위 테스트가 컴포넌트의 로직을 분리하여 테스트한다면, Storybook은 컴포넌트의 시각적 결과물을 분리하여 개발하고 검토한다고 생각하면 쉽다.

npm create storybook@latest

프로젝트 구성을 자동으로 감지하여 필요한 패키지를 설치하고, .storybook 설정 폴더와 몇 가지 예시 *.stories.tsx 파일을 생성한다.

Story 작성

스토리는 컴포넌트의 특정 시각적 상태를 캡처한 것이다.

// 간단한 예시 버튼 컴포넌트
interface ButtonProps {
  label: string;
  primary?: boolean;
  onClick?: () => void;
}

export const Button = ({ label, primary = false, ...props }: ButtonProps) => {
  const mode = primary ? 'button--primary' : 'button--secondary';
  return (
    <button type="button" className={['button', mode].join(' ')} {...props}>
      {label}
    </button>
  );
};

예를 들어 버튼 컴포넌트가 있다면, Primary 버튼, Disabled 버튼, Loading 버튼 등이 각각의 스토리가 된다.

import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

// 1. Meta: 컴포넌트에 대한 메타데이터 (정보)
// - Storybook UI에서 어떻게 보일지 (제목, 컴포넌트 자체)를 정의합니다.
const meta: Meta<typeof Button> = {
  title: 'Components/Button', // Storybook UI의 사이드바 경로
  component: Button, // 스토리를 작성할 대상 컴포넌트
  argTypes: {
    // (선택) props의 타입을 Storybook이 추론/제어
    onClick: { action: 'clicked' }, // 'Actions' 탭에서 클릭 이벤트를 로깅
  },
};

export default meta;

// 2. StoryObj: 해당 컴포넌트의 특정 상태를 나타내는 스토리 객체
type Story = StoryObj<typeof meta>;

// 3. 스토리를 export
// - export된 객체 하나가 하나의 스토리가 됩니다.
// - args: 해당 스토리에 전달할 props입니다.

export const Primary: Story = {
  args: {
    primary: true,
    label: 'Primary Button',
  },
};

export const Secondary: Story = {
  args: {
    label: 'Secondary Button',
  },
};

스토리 파일은 컴포넌트 파일명 옆에 .stories.tsx 확장자로 생성한다.

작성하고 npm run storybook으로 실행하면 Storybook UI를 실행할 수 있다.

Storybook을 테스트에 활용하는 방법

시각적 회귀 테스트

  • 목표: UI가 의도치 않게 변경되는 것(회귀)을 방지
  • 방법: Primary 버튼의 CSS를 수정했다고 가정한다. 이 변경이 Secondary 버튼이나 다른 컴포넌트에 영향을 주지 않았는지 어떻게 확신할 수 있을까?
  • 연동: Chromatic 같은 도구를 Storybook에 연동
  • 동작:
    1. Chromatic이 현재 모든 수토리의 스크린샷을 찍어 저장
    2. 코드를 수정한 후 PR을 올리면, CI(GitHub Actions 등)에서 CHromatic이 다시 스크린샷을 찍음
    3. 이전 스크린샷과 픽셀 단위로 비교하여 1px이라도 달라진 부분이 있다면 CI를 실패시키고 개발자에게 시각적 차이를 리포트

접근성 테스트

  • 목표: 장애가 있는 사용자도 컴포넌트를 사용할 수 있는지 검사
  • 방법: @storybook/addon-a11y 애드온을 설치
  • 동작:
    1. Storybook UI에 'Accessibility' 탭이 추가됨
    2. 스토리를 볼 때 해당 애드온이 axe-core 엔진을 실행하여, 접근성 관점에서 어떤 문제가 있는지 실시간으로 알려줌

인터랙션 테스트

  • 목표: 컴포넌트의 단위 테스트와 통합 테스트를 Storybook 내에서 수행
  • 방법: Storybook의 play 함수와 @storybook/test-runner를 사용
  • 동작: 컴포넌트 스토리 파일을 수정
// ... (이전 코드)
import { userEvent, within } from '@storybook/testing-library';
import { expect } from '@storybook/jest'; // Vitest의 expect와 호환됨

export const Primary: Story = {
  // ... (args)

  // 'play' 함수: 스토리가 렌더링된 후 실행될 테스트 스크립트
  play: async ({ canvasElement }) => {
    // 캔버스(컴포넌트가 렌더링된 영역) 가져오기
    const canvas = within(canvasElement);

    // 1. RTL(Testing Library)처럼 DOM 요소 찾기
    const button = canvas.getByRole('button', { name: /Primary Button/i });

    // 2. DOM 요소 검증 (Vitest의 expect 사용)
    await expect(button).toBeInTheDocument();

    // 3. 사용자 상호작용 시뮬레이션
    await userEvent.click(button);

    // 4. (예시) 클릭 후 상태 변화 검증
    // (이 예제에서는 'action'을 사용했지만, 실제로는 상태 변화를 검증)
  },
};
  • 실행:
    1. 브라우저: Storybook UI의 'Interactions' 탭에서 play 함수가 실행되는 과정을 시각적으로 단계별로 보여줌
    2. CI (Headless): npm run test-storybook 명령어를 실행하면, @storybook/test-runner가 모든 스토리의 play 함수를 실제 브라우저(Headless) 환경에서 실행하여 Vitest처럼 테스트 리포트를 출력

요약

정리하자면, Storybook은 단순히 컴포넌트를 모아보는 카탈로그가 아니라, 다음과 같은 역할을 하는 중심적인 테스트 허브이다.

  1. 개발: 컴포넌트를 독립적으로 시각화하고 개발 (기본 기능)
  2. 검증:
    • 시각적: 변경 사항이 UI를 깨뜨리지 않았는지 (시각적 회귀)
    • 접근성: 표준을 준수하는지
    • 기능적: 사용자의 클릭/입력에 올바르게 반응하는지 (인터랙션)

RTL/Vitest를 사용한 테스트와 뭐가 다르냐 할 수 있다.

RTL/Vitest를 사용한 단위/통합 테스트가 기능/로직/데이터 흐름을 검증하며 코드가 올바르게 동작하는가의 관점이라면, Storybook은 시각적/접근성/인터랙션을 검증하며 컴포넌트가 올바르게 보이고 반응하는가의 관점으로 테스트할 수 있는 환경을 제공한다.

두 가지가 합쳐져야 비로소 견고한 프론트엔드 테스트가 완성된다.