Storybook 알아보기

최근에 디자인 시스템에 관심이 생겨서 관련 정보를 찾아보다가 Storybook을 알게 되었습니다. 호기심이 생겨 공식 문서를 통해 학습하여 정리했습니다.

Storybook

Storybook은 데이터, API 또는 비즈니스 로직으로 벗어나 UI 컴포넌트와 페이지를 독립적으로 구축할 수 있는 프론트엔드 워크샵으로 UI 개발, 테스트 및 문서화를 제공해준다.

  • 내구성이 뛰어난 UI를 개발할 수 있다.
  • 팀이 재사용할 수 있도록 UI를 문서화할 수 있다.
  • 스토리를 통해 UI가 실제로 어떻게 작동하는지 공유할 수 있다.
  • CI 단계에 추가하여 UI 테스트를 자동화할 수 있다.
  • 스토리를 한번 작성하면 다양한 개발 환경에서 재사용할 수 있다.
프론트엔드 워크샵
애플리케이션 환경 외부에서 UI 코드를 작성할 수 있는 환경

Storybook 사용하는 이유

웹의 보편성으로 인해 프론트엔드의 복잡성이 더욱 커지고 있습니다. 반응형 웹 디자인으로 디바이스 크기 등에 따라 사용자는 서로 다른 UI를 보게 되었습니다. 이렇게 사용자가 사용하는 UI에는 디바이스, 브라우저, 접근성, 성능, 비동기 상태 등 추가적인 요구 사항이 생겼습니다.

 

React, Vue 3, Angular 등 컴포넌트 기반 도구는 복잡한 UI를 단순한 컴포넌트로 분해하는 데 도움을 주지만 비즈니스 로직, 인터랙티브 상태, 앱 컨텍스트에 얽혀 있어 디버깅하기가 어렵고, 문제를 더욱 복잡하게 만들 수 있습니다.

 

UI를 독립적으로 구축할 수 있다.

컴포넌트의 강력한 장점은 렌더링 방식을 확인하기 위해 전체 앱을 실행할 필요가 없다는 것입니다. props을 전달하거나 데이터를 모킹하거나 이벤트를 가짜로 만들어 특정 변형을 따로 렌더링할 수 있습니다.

 

스토리북은 앱과 함께 제공되는 작은 개발 전용 워크샵으로 패키징되어 있습니다. 앱 비즈니스 로직 및 컨텍스트의 간섭 없이 컴포넌트를 렌더링할 수 있는 환경을 제공합니다. 이를 통해 컴포넌트의 각 변형, 심지어 도달하기 어려운 엣지 케이스까지 테스트할 수 있습니다.

 

UI 변형을 스토리로 캡처할 수 있다.

스토리는 컴포넌트 변형을 시뮬레이션하기 위해 props와 mock data를 제공하는 선언적 구문입니다. 각 컴포넌트는 여러 개의 스토리를 가질 수 있습니다. 각 스토리를 통해 해당 컴포넌트의 특정 변형을 시연하여 모양과 동작을 검증할 수 있습니다.

 

세분화된 UI 컴포넌트 변형을 위한 스토리를 작성한 다음 개발, 테스트 및 문서화에서 해당 스토리를 사용합니다.

 

모든 스토리를 추적할 수 있다.

Storybook은 UI 컴포넌트와 해당 스토리의 대화형 디렉토리입니다. 과거에는 앱을 실행하고 페이지로 이동한 다음 UI를 올바른 상태로 조정해야 했습니다. Storybook을 사용하면 특정 상태의 UI 컴포넌트로 바로 이동할 수 있습니다.

Stories

스토리는 UI 컴포넌트의 렌더링된 상태를 캡처합니다. 개발자는 컴포넌트가 지원할 수 있는 모든 상태를 설명하는 스토리를 컴포넌트당 여러 개 작성할 수 있습니다.

 

스토리는 컴포넌트 예제 작성을 위한 ES6 모듈 기반 표준인 Component Story Format(CSF)으로 작성됩니다. Component Story Format(CSF)는 스토리 작성에 권장되는 방식입니다. ES6 모듈을 기반으로 하는 개방형 표준으로 Storybook을 넘어 이식할 수 있습니다.

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

import { Button } from './Button';

const meta: Meta<typeof Button> = {
  component: Button,
};

export default meta;
type Story = StoryObj<typeof Button>;

// args에 지정된 값이 컴포넌트를 렌더링하는 데 어떻게 사용되고 컨트롤 탭에 표시되는 값과 일치하는 확인할 수 있다.
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

Addons

스토리북의 핵심 기능을 확장하는 플러그인입니다. Storybook 하단의 예약된 위치인 Addons 패널에서 찾을 수 있습니다. 각 탭에는 선택한 스토리에 대해 생성된 메타데이터, 로그 또는 정적 분석이 표시됩니다.

  • Controls : 컴포넌트의 인수(입력)와 동적으로 상호작용할 수 있습니다.
  • Actions : 콜백을 통해 상호작용이 올바른 출력을 생성하는지 확인하는 데 도움이 됩니다.
  • Interactions : 플레이 기능으로 인터렉션 테스트를 디버깅하는 데 유용한 사용자 인터페이스를 제공합니다.
  • Visual Tests : 이 기능을 사용하면 Storybook에서 바로 피드백을 제공하여 로컬 개발 환경의 UI 버그를 찾아낼 수 있습니다.

React & Vite에서 설치

  • React 16.8 이상
  • Vite 4.0 이상
  • Storybook 8.0 이상
npm create vite@latest storybook-example -- --template react-ts
npx storybook@latest init // 설치
npm run storybook // 실행

스토리 작성하기

스토리를 정의하기 위해 Component Story Format 3(CSF 3)을 사용하여 각 테스트 케이스를 구축했습니다. Component Story Format은 스토리 작성에 권장되는 방식으로 ES6 모듈을 기반으로 하는 개방형 표준이기에 스토리북을 넘어 이식할 수 있습니다.

 

아래의 코드는 할 일을 나타내는 <Task />의 Default, Pinned, Archived 세가지 테스트 상태(스토리)를 구축한 코드입니다. 스토리북은 컴포넌트와 그 하위 스토리라는 두 가지 기본 수준의 구성이 있습니다. 하나의 컴포넌트는 필요한 만큼의 스토리를 가질 수 있습니다.

 

ActionsData 객체를 보면 fn()을 사용하고 있습니다. 스토리북을 통해 UI 컴포넌트를 개별적으로 구축할 때 앱의 컨텍스트에 있는 함수나 상태에 접근할 수 없는 경우가 종종 있는데 이런 상황에서 fn()을 사용하면 됩니다. 이는 스토리의 이벤트 핸들러(콜백) 인수가 수신한 데이터를 표시하는 데 사용되는 Actions 애드온과 관련이 있습니다.

 

args를 사용하면 Controls 애드온으로 컴포넌트를 실시간으로 편집할 수 있습니다. args 값이 변경되면 컴포넌트도 변경됩니다. 스토리북을 실행시켜 애드온 패널에서 기본으로 제공되는 <Button />의 인수를 조작해보면 어떤 의미인지 알 수 있습니다.

// Task.stories.tsx

import { fn } from '@storybook/test';
import Task from './Task';

export const ActionsData = {
  onArchiveTask: fn(),
  onPinTask: fn(),
};

// export default를 통해 문서화하고 테스트 중인 컴포넌트를 스토리북에 알린다.
export default {
  component: Task, // 컴포넌트 자체
  title: 'Task', // 스토리북 사이드바에서 표시되는 이름
  tags: ['autodocs'], // 문서를 자동으로 수정
  excludeStories: /.*Data$/, // 스토리에 필요하지만 스토리북에서 렌더링하면 안되는 정보
  args: {
    ...ActionsData, // 컴포넌트 인수로 액션 전달
  },
};

export const Default = {
  args: {
    task: {
      id: '1',
      title: 'Test Task',
      state: 'TASK_INBOX',
    },
  },
};

export const Pinned = {
  args: {
    task: {
      ...Default.args.task,
      state: 'TASK_PINNED',
    },
  },
};

export const Archived = {
  args: {
    task: {
      ...Default.args.task,
      state: 'TASK_ARCHIVED',
    },
  },
};

접근성 테스트 애드온 추가하기

npm i -D @storybook/addon-a11y

.storybook/main.ts에서 stories 프로퍼티를 통해 스토리 파일 저장 위치를 지정할 수 있고 addons 프로퍼티를 통해 애드온을 추가할 수 있습니다. 설치 후 추가하면 애드온 패널에서 접근성 테스트 애드온이 추가된걸 확인할 수 있습니다.

// .storybook/main.ts

import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-onboarding',
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@chromatic-com/storybook',
    '@storybook/addon-interactions',
    '@storybook/addon-a11y',
  ],
  framework: {
    name: '@storybook/react-vite',
    options: {},
  },
};
export default config;

스토리북에 CSS 파일 가져오기

.storybook/preview.ts에서 CSS 파일을 가져올 수 있습니다. 참고로 parameters 프로퍼티는 일반적으로 스토리북의 기능 및 애드온의 동작을 제어하는 데 사용됩니다.

// .storybook/preview.ts

import type { Preview } from '@storybook/react';
import '../src/index.css'; // 스타일 추가

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
};

export default preview;

Decorators

스토리에 임의의 wrapper를 제공하는 방법입니다. 아래 코드에서는 여백을 주는 wrapper를 추가했지만 주로 React 컨텍스트를 설정하는 라이브러리 컴포넌트에서 스토리를 래핑하는 데 사용할 수 있습니다.

// ./TaskList.stories.tsx

import type { Meta } from '@storybook/react';
import TaskList from './TaskList';
import * as TaskStories from './Task.stories'; // 스토리 재사용

const meta: Meta<typeof TaskList> = {
  component: TaskList,
  title: 'TaskList',
  decorators: [
    (Story) => (
      <div style={{ margin: '3rem' }}>
        <Story />
      </div>
    ),
  ],
  tags: ['autodocs'],
  args: {
    ...TaskStories.ActionsData,
  },
};

export default meta;

리덕스 연동

decorators를 통해 리덕스를 연결합니다. decorators는 컴포넌트 전체에 적용할 수 있고 각각의 테스트 케이스에 개별적으로 적용할 수도 있습니다.

 

리덕스 관련 내용이 궁금하신 분은 제가 정리한 내용을 참고 부탁드립니다.

// ./TaskList.stories.tsx

import { Provider } from 'react-redux';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import type { Meta, StoryObj } from '@storybook/react';
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';

export const MockedState = {
  tasks: [
    { ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
    { ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
    ...
  ],
  status: 'idle',
  error: null,
};

const Mockstore = ({
  taskboxState,
  children,
}: {
  taskboxState: typeof MockedState;
  children: React.ReactNode;
}) => (
  <Provider
    store={configureStore({
      reducer: {
        taskbox: createSlice({
          name: 'taskbox',
          initialState: taskboxState,
          reducers: {
            updateTaskState: (state, action) => {
              const { id, newTaskState } = action.payload;
              const task = state.tasks.findIndex((task) => task.id === id);
              if (task >= 0) {
                state.tasks[task].state = newTaskState;
              }
            },
          },
        }).reducer,
      },
    })}
  >
    {children}
  </Provider>
);

type Story = StoryObj<typeof TaskList>;

const meta: Meta<typeof TaskList> = {
  component: TaskList,
  title: 'TaskList',
  decorators: [
    (Story) => (
      <div style={{ margin: '3rem' }}>
        <Story />
      </div>
    ),
  ],
  tags: ['autodocs'],
  excludeStories: /.*MockedState$/,
};

export default meta;

export const Defaul: Story = {
  decorators: [
    (Story) => (
      <Mockstore taskboxState={MockedState}>
        <Story />
      </Mockstore>
    ),
  ],
};

HTTP 요청 모킹하기

Mock Service Worker와 MSW 애드온을 설치하고 public/ 디렉토리에 서비스 워커 파일을 생성합니다.

npm i -D msw msw-storybook-addon
npx msw init public/

.storybook/preview.ts에서 애드온을 등록하고 MSW를 초기화합니다.

// .storybook/preview.ts

import type { Preview } from '@storybook/react';
import '../src/index.css';

import { initialize, mswLoader } from 'msw-storybook-addon';

initialize();

const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  loaders: [mswLoader],
};

export default preview;

아래처럼 테스트 케이스를 작성하면 MSW가 원격 API 호출을 가로채서 적절한 응답을 제공합니다.

import { http, HttpResponse } from 'msw';
import { MockedState } from './TaskList.stories';

export const Default = {
  parameters: {
    msw: {
      handlers: [
        http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
          return HttpResponse.json(MockedState.tasks);
        }),
      ],
    },
  },
};

export const Error = {
  parameters: {
    msw: {
      handlers: [
        http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
          return new HttpResponse(null, {
            status: 403,
          });
        }),
      ],
    },
  },
};

인터렉션 테스트

스토리북의 play 함수와 인터렉션 애드온을 통해 인터렉션 테스트를 사용하면 컴포넌트의 상호작용을 자동으로 테스트할 수 있습니다. play 함수에는 스토리 렌더링 후에 실행되는 작은 코드 조각이 포함되어 있습니다.

 

play 함수는 작업이 업데이트될 때 UI에 어떤 일이 발생하는지 확인하는 데 도움이 됩니다. DOM API를 사용하므로 프레임워크에 구애받지 않습니다.

import type { Meta, StoryObj } from '@storybook/react';
import { Provider } from 'react-redux';
import { http, HttpResponse } from 'msw';
import {
  fireEvent,
  waitFor,
  within,
  waitForElementToBeRemoved,
} from '@storybook/test';

const meta: Meta<typeof InboxScreen> = {
  component: InboxScreen,
  title: 'InboxScreen',
  decorators: [
    (Story) => (
      <Provider store={store}>
        <Story />
      </Provider>
    ),
  ],
  tags: ['autodocs'],
};

export default meta;

type Story = StoryObj<typeof InboxScreen>;

export const Default: Story = {
  parameters: {
    msw: {
      handlers: [
        http.get('https://jsonplaceholder.typicode.com/todos?userId=1', () => {
          return HttpResponse.json(MockedState.tasks);
        }),
      ],
    },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    await waitForElementToBeRemoved(await canvas.findByTestId('loading'));

    await waitFor(async () => {
      await fireEvent.click(canvas.getByLabelText('pinTask-1'));
      await fireEvent.click(canvas.getByLabelText('pinTask-3'));
    });
  },
};

테스트 자동화

테스트 자동화를 하지 않으면 스토리를 직접적으로 볼 때만 인터렉션 테스트를 실행합니다. 따라서 변경 사항이 있을 때 모든 테스트가 자동적으로 실행하도록 테스트 러너로 테스트 자동화를 할 수 있습니다.

npm i -D @storybook/test-runner
npx test-storybook --watch