예시로 배우는 Storybook

예시로 배우는 Storybook

Tags
Storybook
msw
문서화
Published
February 11, 2024
Author
Seongbin Kim
 
1. Storybook을 쓰면 좋은 상황1-1. 컴포넌트의 상태가 다양할 때 좋아요1-2. 재현 과정이 복잡할 때 좋아요1-3. 의도치 않은 화면의 변경을 알려줘요2. 스토리 작성 예시2-1. 가장 기본적인 Story 파일 형태2-2. Button 컴포넌트 예시2-2-1. 버튼의 각 variant를 모두 문서화하기2-2-2. 버튼의 자식으로 Text(children)를 렌더링하기2-3. Checkbox 컴포넌트 예시2-3-1. 체크박스 클릭 시 화면에 반영 되게 하기2-3-2. 체크박스의 배경으로 Modal을 쓰기 (Modal 속에서 렌더링하기)2-3-3. 자동으로 체크 됐다가 체크 해제하는 스토리 작성하기2-4. [WIP] TodoList 페이지 예시2-4-1. Storybook에서 페이지를 만들 때 주의사항2-4-3. 페이지 예시3. Storybook 기본 설정 방법3-1. 기본 설정3-1-1. TailwindCSS 설정3-1-2. 컴포넌트 분류 설정3-1-3. control에서 Color, Date Picker 설정3-2. 페이지 컴포넌트 스토리 기본 설정3-2-1. Router에 의존하는 컴포넌트를 위해 router 애드온 사용하기3-2-2. API mock을 할 수 있도록 msw 애드온 설정하기3-2-3. React-query에 의존하는 컴포넌트를 위해 QueryProvider 렌더링하기3-2-4. localstorage에 의존하는 컴포넌트를 위해 Story마다 설정/초기화하기3-2-5. pageParam에 의존하는 컴포넌트를 위해 주소 설정하기3-3. Chromatic으로 시각 회귀 테스트하기3-4. Chromatic으로 스토리북 배포하기3-4-1. Chromatic에 가입 후 repo를 연결해요.3-4-2. GitHub Actions로 PR 푸시 시 자동으로 빌드해요
 

1. Storybook을 쓰면 좋은 상황

 

1-1. 컴포넌트의 상태가 다양할 때 좋아요

공통 컴포넌트 혹은 페이지의 상태를 문서화할 수 있어요.
  • 버튼 컴포넌트의 유형 별로 옵션만 선택하면 쉽게 확인할 수 있어요.
  • 게시글 목록이 비었을 때의 화면을 쉽게 확인할 수 있어요.
 

1-2. 재현 과정이 복잡할 때 좋아요

 
특정 상태의 화면을 재현하기 불편한 화면이 있을 거에요.
  • 로그인을 하고 글을 등록하고 난 후의 페이지 등이 그렇죠.
  • Storybook 없이 개발한다면 코드 변경 후 매번 수동으로 테스트를 해야 돼요.
    • Storybook으로 이를 자동화하면 이후에는 클릭 한 번으로 확인할 수 있어요.
      • 미리 <로그인 된 사용자>, <글을 등록한 상태>로 상태를 구성해놓을 수 있어요.
      • 그러면 개발자는 해당 페이지 상태를 조회만으로 확인할 수 있어요.
 

1-3. 의도치 않은 화면의 변경을 알려줘요

Pixel 단위로 캡쳐한 후 자동으로 비교해줘요.
  • 의도하지 않은 화면 변경이 발생했을 때 경고를 받을 수 있어요.
 

2. 스토리 작성 예시

 

2-1. 가장 기본적인 Story 파일 형태

Story 파일은 컴포넌트마다 하나씩 작성하게 돼요.
  • Meta 타입의 객체를 default export 해야 돼요.
    • metacomponent를 필수값으로 가져요.
  • Story 타입의 객체를 최소 하나 이상 Named export 해야 돼요.
import { Button } from '@/components/common/Button'; import type { Meta, StoryObj } from '@storybook/react'; const meta: Meta<typeof Button> = { component: Button, }; export default meta; type Story = StoryObj<typeof Button>; export const Primary: Story = { args: { variant: 'primary', }, };
 

2-2. Button 컴포넌트 예시

 

2-2-1. 버튼의 각 variant를 모두 문서화하기

방법은 2가지가 있어요.
  • 수동으로 variant마다 Story를 만들기
    • 자동으로 만드는 기능은 없어요.
    • export const Primary: Story = { args: { variant: 'primary', }, }; export const Outline: Story = { args: { variant: 'outline', }, };
  • 자동 생성되는 controls 옵션으로 떼우기
    • 옵션은 Prop Type을 읽어 자동으로 생성돼요.
      • variant 변경 시 문서도 함께 변경해줄 수밖에 필요가 없어요.
      // 기본 값을 지정하는 경우 export const DefaultTemplate: Story = { args: { variant: 'primary', size: 'default', children: 'Button Text', }, }; // 기본 값을 지정하지 않는 경우는 생략해도 돼요. export const DefaultTemplate = {};
      notion image
 

2-2-2. 버튼의 자식으로 Text(children)를 렌더링하기

  • Button의 Text처럼 렌더링을 위해 기본 Prop이 필요할 때가 있어요.
  • 스토리 파일 내의 모든 스토리 객체들의 공통 prop으로 정의할 수 있어요.
    • meta.args 혹은 meta.render 를 작성하면 돼요.
    • meta.args를 사용하는 방법
      • const meta: Meta<typeof Button> = { component: Button, args: { children: 'Hello World', }, };
    • meta.render를 사용하는 방법
      • const meta: Meta<typeof Button> = { component: Button, args: { render: (args) => <Button {...args}>Hello World</Button> }, };
 

2-3. Checkbox 컴포넌트 예시

 

2-3-1. 체크박스 클릭 시 화면에 반영 되게 하기

Checkbox 컴포넌트가 체크 여부를 변경하려면 props로 주입 받아야 해요.
  • 이 경우 상태를 관리할 상위 컴포넌트가 필요해요.
  • 상위 컴포넌트를 정의해줄 수 있어요.
    • meta 객체 혹은 각 Story 객체의 render 필드에 정의해줄 수 있어요.
      • 공통으로 쓰려면 meta 객체에, 개별로 다른 상위 컴포넌트가 필요하면 Story에 정의하면 돼요.
      • const meta: Meta<typeof Checkbox> = { // !!!주의사항: args를 받지 않으면 controls 자동 타입 추론이 안 돼요!!! render: function Container(args: CheckboxProps) { const [, updateArgs] = useArgs<CheckboxProps>(); function handleChange() { updateArgs({ checked: !args.checked }); } return <Checkbox {...args} onChange={handleChange} />; }, };
 

2-3-2. 체크박스의 배경으로 Modal을 쓰기 (Modal 속에서 렌더링하기)

notion image
2가지 방법이 있어요.
  • meta.decorators 필드
    • react-router의 <Outlet />처럼 쓸 수 있는 <Story />를 제공해줘요.
    • 단순한 Element를 반환하면 돼요.
    • const meta: Meta<typeof Checkbox> = { decorators: [ Story => ( <div className='rounded-lg border-2 border-gray-200 p-8'> <Story /> </div> ), ], };
  • meta.render 필드
    • render를 사용하는 것도 좋겠지만, render는 너무 강력하기 때문에 의도를 알기 위해서는 코드를 읽어야 해요.
 

2-3-3. 자동으로 체크 됐다가 체크 해제하는 스토리 작성하기

가만히 있으면 알아서 상태를 바꿔주는 스토리를 만들 수도 있어요.
  • Story 객체의 play 필드에 정의하면 돼요.
    • testing-library API를 활용하는 것 외에 특별한 지식은 필요 없어요.
    • 이름을 짓기가 어려울 수도 있는데, 이 경우 Story 객체의 name 필드로 사이드바에 표시되는 이름을 설정할 수 있어요.
notion image
import { userEvent, within } from '@storybook/testing-library'; export const PlayingChecky: Story = { name: '체크 후 체크 취소', args: { checked: false, label: 'label test', name: 'name', }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const checkbox = canvas.getByLabelText('label test'); await userEvent.click(checkbox, { delay: 500, }); await userEvent.click(checkbox, { delay: 500, }); }, };
 

2-4. [WIP] TodoList 페이지 예시

 

2-4-1. Storybook에서 페이지를 만들 때 주의사항

  • 페이지는 외부 의존성을 곧장 활용해서, mock을 하려면 API 수준에서 해야 돼요.
    • 실제 개발 서버에 영향이 없으면서도 CRUD를 하려면 msw가 필요해요.
  • mock할 대상은 크게 API, localstorage, router인 것 같아요.
    • API
      • /me API → ‘나’를 가리키는 사용자
      • 리소스 API → ‘나’를 기준으로 데이터를 조회하고 변경해요.
      • react-query의 queryClient는 정품 (?)으로 global decorator 설정해도 좋을 것 같아요.
    • localstorage
      • 의존성이 있다면 꼭 mock해야 돼요.
        • CI 환경에 localstorage가 없어요.
        • 브라우저 환경에서 실제 localstorage가 조작되면 안돼요.
    • router
 

2-4-3. 페이지 예시

목록 조회 페이지가 있다면 다음의 상태를 Storybook으로 재현하면 좋을 것 같아요.
  • 목록을 로딩 중인 상태 화면
  • 조회에 실패한 화면
  • 조회 결과가 비었을 때의 화면
  • 조회가 성공한 화면
 

3. Storybook 기본 설정 방법

 

3-1. 기본 설정

 

3-1-1. TailwindCSS 설정

Tailwind 사용 시에는 Tailwind CSS 파일을 import 해야 해요.
  • .storybook/preview.ts에 설정하는 내용이에요.
/** * Tailwind 사용 시 Tailwind CSS를 preview에서 import 해야 한다. * @see https://storybook.js.org/recipes/tailwindcss */ import '../src/index.css';
/* Tailwind CSS 파일 내용 */ @tailwind base; @tailwind components; @tailwind utilities; /* ... */
 

3-1-2. 컴포넌트 분류 설정

  • 스토리를 그냥 만들면 STORIES 하위의 최상위 요소가 돼요.
  • 상위 분류를 만드려면 meta.title을 직접 지정해서 가능해요.
    • / 를 경계로 각 분류가 생성돼요.
const meta: Meta<typeof Button> = { title: 'common/Button', component: Button, };
notion image
notion image

3-1-3. control에서 Color, Date Picker 설정

특정 prop의 control에 Color Picker, Date Picker를 적용할지 여부를 설정할 수 있어요.
  • .storybook/preview.ts에 설정하는 내용이에요.
parameters: { controls: { matchers: { color: /(background|color)$/i, date: /Date$/i, }, }, },
 

3-2. 페이지 컴포넌트 스토리 기본 설정

 

3-2-1. Router에 의존하는 컴포넌트를 위해 router 애드온 사용하기

  • <Link /> 등을 쓰는 경우 React-router의 Router 하위 컴포넌트로 렌더링돼야 해요.
    • 그렇지 않으면 아래와 같은 오류가 발생해요.
    • notion image
 

3-2-2. API mock을 할 수 있도록 msw 애드온 설정하기

페이지에서 API 요청을 하는 경우 API 응답을 mocking해야 돼요.
  • 실제 API 요청을 하게 되면, 스토리가 재현하는 화면이 계속 달라지기 때문이에요.
 
msw와 msw-storybook-addon을 설치해야 돼요.
pnpm install -D msw msw-storybook-addon@2.0.0-beta.1 # Vite 사용 시 오류가 있어서 2.0 버전을 받아야 돼요.
 
.storybook/preview.ts에 아래 내용을 추가해주세요.
import { initialize as initializeMSW, mswLoader } from 'msw-storybook-addon'; // msw의 worker.start 옵션을 전달할 수 있어요. /* 예시: initializeMSW({ onUnhandledRequest: ignoreDevResources, }); */ initializeMSW(); const preview: Preview = { // ... loaders: [mswLoader], }; export default preview;
 
msw handler들은 각 스토리 파일의 meta.parameters.msw.handlers 내에 위치시키면 돼요.
  • 각 스토리 별로 parameters를 별도로 정의해도 돼요.
import { HttpResponse, http } from 'msw'; const meta: Meta<typeof HomePage> = { parameters: { msw: { handlers: [ http.get('/api/echo', () => HttpResponse.json('hello from server')), ], }, }, };
 
실제 예시
  • 페이지에서 fetch 요청을 하는 코드에요.
export const HomePage = () => { const [response, setResponse] = useState(''); useEffect(() => { (async () => { const apiResponse = await fetch('/api/echo', { method: 'GET', headers: { 'Content-Type': 'application/json', }, }).then(response => response.json() as Promise<string>); setResponse(apiResponse); })(); }, []); return (/* ... */); };
 
아래와 같이 API mock이 됨을 확인할 수 있어요.
notion image
 

3-2-3. React-query에 의존하는 컴포넌트를 위해 QueryProvider 렌더링하기

아래와 같이 react-query를 사용하는 페이지의 경우 QueryProvider가 부모로 렌더링되어야 해요.
import { useQuery } from '@tanstack/react-query'; const fetchApiResponse = () => fetch('/api/echo', { method: 'GET', headers: { 'Content-Type': 'application/json', }, }).then(response => response.json() as Promise<string>); export const HomePage = () => { const { data, isFetching } = useQuery({ queryFn: fetchApiResponse, queryKey: ['echo'], }); return (/* ... */); };
notion image
 
.storybook/preview.tsx에 다음 내용을 추가하면 돼요.
import React from 'react'; import { ReactQueryProvider } from '../src/ReactQueryProvider'; const preview: Preview = { decorators: [ (Story) => <ReactQueryProvider><Story /></ReactQueryProvider> ], }; export default preview;
 
ReactQueryProviderqueryClient의 중복 정의를 막기 위한 것으로 아래와 같이 작성된 코드에요.
import { PropsWithChildren } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 5, retry: 0, refetchOnWindowFocus: false, }, }, }); export const ReactQueryProvider = ({ children }: PropsWithChildren) => { return ( <QueryClientProvider client={queryClient}>{children}</QueryClientProvider> ); };
 

3-2-4. localstorage에 의존하는 컴포넌트를 위해 Story마다 설정/초기화하기

localstorage 상태에 의존하는 컴포넌트의 경우 각 스토리마다 loaders 필드와 decorators 필드에 함수를 등록해 localstorage의 값을 변경하거나 초기화하는 코드를 추가해야 돼요.
 
localstorage의 상태를 Storybook 탭에 보여주는 애드온도 있어요
 

3-2-5. pageParam에 의존하는 컴포넌트를 위해 주소 설정하기

예시: :boardGameId 라는 pathParam을 사용할 때의 설정
const meta: Meta<typeof BoardGameDetailPage> = { component: BoardGameDetailPage, parameters: { reactRouter: reactRouterParameters({ routing: { useStoryElement: true, path: '/board-games/:boardGameId', }, location: { pathParams: { boardGameId: 1, }, }, }), }, };
 

3-3. Chromatic으로 시각 회귀 테스트하기

Storybook은 Chromatic으로 배포하면 무료로 시각 회귀 테스트를 제공해요.
시각 회귀 테스트는 화면이 바뀌었는지를 비교해요.
 
Pixel을 diff해서 변경된 부분이 있으면 보여줘요.
 
스냅샷을 찍기 때문에 월 5,000회의 제한이 있어요.
  • 각 스토리마다 1개의 스냅샷을 찍기 때문이에요.
 
스냅샷 상으로 달라진 부분이 있다면 이렇게 표시돼요.
notion image

3-4. Chromatic으로 스토리북 배포하기

 
Storybook은 무료로 배포할 수 있어요
 

3-4-1. Chromatic에 가입 후 repo를 연결해요.

레포 연결 후 CLI로 chromatic을 직접 실행해서 최초 배포를 해야 넘어갈 수 있어요.
notion image
 
만약 빌드가 영원히 끝나지 않는다면 빌드 오류가 발생한 것일 수 있어요.
build-storybook을 실행하면 오류를 확인할 수 있으니 확인해보세요
Running chromatic storybook build for the first time hangs indefinitely
Updated Aug 18, 2023
 
배포가 되면 아래처럼 대시보드가 생겨요.
notion image
 

3-4-2. GitHub Actions로 PR 푸시 시 자동으로 빌드해요

GitHub Action으로 배포되도록 Action 파일을 하나 생성해야 돼요.
  • .github/workflows/publish-storybook.yml 파일을 만든 후 아래 내용으로 채우면 돼요.
name: 'Publish Strybook to Chromatic' on: push jobs: publish-storybook-to-chromatic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: actions/setup-node@v3 with: node-version: 18 cache: 'npm' - run: npm install - uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }} exitZeroOnChanges: true
 
만약 pnpm을 사용하신다면 아래의 내용으로 사용하시면 돼요
name: 'Publish Storybook to Chromatic' on: push jobs: publish-storybook-to-chromatic: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: pnpm/action-setup@v2 with: version: 8.15.1 - uses: actions/setup-node@v4 with: node-version: 18 cache: 'pnpm' - run: pnpm install - uses: chromaui/action@v1 with: projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }} exitZeroOnChanges: true
 
이제 레포의 Secret을 생성해줘요.
  • ${{ secrets.CHROMATIC_PROJECT_TOKEN }}에 들어갈 값이에요.
  • Settings > Secrets and variables > New Secret로 설정할 수 있어요.
notion image
 
CI가 정상적으로 설치됐다면 PR을 생성했을 때 아래처럼 배포되는 것을 확인할 수 있을 거에요.
notion image