Storybook 튜토리얼

김가빈·2020년 6월 11일
7

storybook

목록 보기
1/3
post-thumbnail

storybook 도입기

화면 작업을 하게되면 늘 반복되는 작업중 하나가 공통 컴포넌트에 대한 스타일 가이드 작업이다.
단독으로 작업할 때나 협업이 필요할때나 일회성 생명주기를 가진 컴포넌트가 아닌 이상 어떤 구조로 만들어졌고, 어떤 속성변화에 의존하여 UI 가 변경되는지 문서화 해두는 작업이 꼭 필요하기 때문이다.

버튼만 해도 최소 3개에서 많게는 10개 이상의 case 가 발생하게 되는데,
살아있는 서비스는 UI도 지속적으로 고도화 되기 때문에, 매번 변경되는 사항을 별도로 스타일가이드에 반영하고, 또 상태변경에 의한 가이드를 수기로 작성하는것은 너무나도 고된 반복 작업인것이다 ㅠ.ㅜ

해서 이번 파일럿 프로젝트에는 UI 구성요소를 손쉽게 관리하고 설계하게 해주는 storybook 을 이용해볼 생각이다. (샘플링 프로젝트는 React + Typescript 기반으로 작성됨)

storybook 을 이용해서 아래 나열된 항목을 손쉽게 관리/구축 해보자 :)

  • 프로토타이핑
  • 문서화
  • test
  • usecases

스토리북 공식사이트의 튜토리얼이 20년 6월 현재 기준으로는 한글이 지원되지 않는다.
나는 당장 스토리북 적용을 해보고 싶은 관계로.. 튜토리얼을 번역해서 옮겨본다😱
(내용은 다 옮기겠지만 순서는 내가 이해하기 편한 순서대로 나열😎, 의역은 옵션..)

storybook 공식사이트


시작하기

CRA로 작성된 프로젝트에 스토리북을 적용해본다.
모듈 의존성은 React(CRA) + Typescript + storybook 기준으로 작성했다.

아래 코드에서 npm 프로젝트 생성이 아닌 yarn 프로젝트로 생성하고 싶은 경우,
맨뒤의 --use-npm 플래그를 삭제해주면 된다.

$ npx create-react-app [프로젝트명] --use-npm 
$ cd [프로젝트 폴더]

## storybook 추가!
$ npx -p @storybook/cli sb init

모듈이 정상적으로 설치되면 src 폴더 하위에 stories 폴더가 생성되어야 한다.
(stories 폴더 하위에는 샘플로 생성된 Welcome, Button 스토리북 컴포넌트가 위치함)

자 이제 빠르게 테스트해보자!
메인 앱, Jest, Storybook 각 애플리케이션을 구동해본다.

# test 환경 (Jest) 구동 (--watchAll 옵션은 없어도 무방)
$npm run test --watchAll

# storybook 구동 (기본포트는 9009 포트이다. 기본 앱과 별도의 환경으로 구동됨을 알 수 있다.)
$npm run storybook

# 기본 앱(리액트 앱) 구동
$npm run start

localhost:9090 주소로 접속해보면 스토리북의 첫화면을 볼 수 있다 :)
별도의 작업이 없어도 아래 이미지처럼 기본 프레임이 전부 잡혀있다. 아름다워라 😍

좌측 트리를 보면 샘플로 작성된 'Welcome, Button' 컴포넌트가 메뉴로 구성되어 있음을 알 수 있다.


Taskbox (할일 목록) 샘플 만들기

본격 튜토리얼 시작! 🐥

공식 문서에서 제공되는 Taskbox 샘플을 구현해 보자.
먼저 샘플을 작성하는데 필요한 CSS, 폰트, 아이콘을 프로젝트에 import 시켜준다.

  • CSS : 링크로 접속해서 CSS 전문을 복사하여, src 폴더 하위에 index.css 파일을 생성한 뒤 코드 붙여넣기.

  • font, icon : 링크로 접속.

    1. 프로젝트를 zip 으로 다운로드 (Download ZIP)
    2. 압축을 푼 뒤 'public' 폴더 하위 'font/icon' 폴더만 샘플 프로젝트의 'public' 폴더 하위에 import

Task 컴포넌트 구현

Task 컴포넌트 작성에 앞서,

Storybook 컴포넌트 제작 시 공식사이트에서도 Atomic, CDD(Component-Driven Development) 방식을 권장하고 있다.

분리할 수 있는 가장 작은 단위의 컴포넌트(가장 작은 UI 단위가 아닌 아닌 컴포넌트 단위.)로 쪼개고 쪼개서 구성요소로 시작하여 화면으로 끝나는 Bottom-Up 방식의 프로세스인데, UI를 구축 할 때 높은 확장성과 독립된 유지보수가 가능해진다.

그럼 이제 Task 컴포넌트를 아래 조건들을 만족하게끔 구현해보자.

  • 각 Task(할일)는 현재 state에 따라 UI가 변동된다.
  • 할일 완료/미완료 표기 체크박스 (checked, unchecked) 를 표현한다.
  • 'pin' 버튼을 눌러서 할일박스 상단에 표기되게 처리한다.

위 처리를 위해 우선 2가지 props 가 필요하다.

  1. title - 할일 내용
  2. state - 현재 목록에 포함되어 있는 할일과, 미완료된 할일 객체

Storybook 컴포넌트를 작성하기 위해서는 크게 2개의 단계로 나누어 볼 수 있다.

  1. 컴포넌트의 여러 '유형'별 테스트 state를 작성
  2. mocked data(모의 데이터)를 통해 컴포넌트의 각 상태에 따른 개별 빌드

이를 통해 상태별 UI 변화를 직관적으로 테스트 할 수 있고, 이런 테스트 과정을 'Visual test' 라고 칭한다.


이제 컴포넌트를 생성하자!
구성요소 컴포넌트와 스토리북 컴포넌트를 쌍(pair)으로 만들어준다.

  1. src/components/Task.tsx 생성
  2. src/components/Task.stories.tsx 생성

스토리북은 '구성요소(Component)' 와 '하위 스토리 컴포넌트'의 레벨로 구성된다.
하나의 컴포넌트는 필요한만큼 다수의 하위 스토리를 가질 수 있다.

  • Component
    • story
    • story
    • story

src/components/Task.tsx

import React from 'react';

export type TaskItem = {
  id: number;
  title: string;
  state: string;
};
type TaskProps = {
  task: TaskItem;
  onArchiveTask(id: number): void;
  onPinTask(id: number): void;
};

function Task({ task, onArchiveTask, onPinTask }: TaskProps) {
  return (
    <div className="list-item">
      <input value={task.title} readOnly={true} />
    </div>
  );
}

export default Task;

Task.stories 컴포넌트 구현

default export

스토리북에게 만들어진 스토리를 스토리북에 포함해줘! 라고 알리기위해, 다음 조건들을 충족하는
default export 를 생성한다.

  • component : 구성 요소 그 자체 (ex: Task)
  • title : 스토리북 좌측 트리메뉴에 표기될 스토리 명
  • excludeStories : 스토리 렌더링 대상에서 미포함 될 대상
export default {
	component:Task,
  	title: 'Task',
  	excludeStories: /.*Data$/,
}

test case export

컴포넌트의 각 테스트 state(상태)를 적용한 함수형 컴포넌트를 export 해준다.
(참고 : Stateless Functional Component)

export const Pinned = () => ( // state: 'TASK_PINNED' <- test state 반영.
  <Task task={{ ...taskData, state: 'TASK_PINNED' }} {...actionsData} />
);

action() 생성/연결

import { action } from '@storybook/addon-actions'; 코드로 스토리북 addon-actions 를 import 해준 뒤, action() 을 사용해서 스토리북의 'actions' 패널에서 콜백 함수를 볼 수 있다.
(위 예제를 예로들면, 핀 버튼(pin)을 누르고 의도한대로 동작하는지 test UI를 통해 알 수 있다.)

모든 컴포넌트에 동일한 action set을 전달해야 하므로,
단일 actionsData 변수로 묶고 , React의 {...actionsData} props 확장을 사용해 한번에 전달하는것이 편리하다.

const actionsData = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTast'),
};

// 아래 두줄의 코드는 완전히 동일하다.
<Task {...actionsData}>
<Task onPinTask={actionsData.onPinTask} onArchiveTask={actionsData.onArchiveTask}>

이렇게 actionData 라는 변수로 묶는 방법은 해당 변수(actionData)를 export 하고, 내보낸 주체 컴포넌트를 재사용하는 다른 컴포넌트의 스토리에서도 동일하게 이 액션을 재사용할 수 있다는 잇점도 있다.


위 항목들을 기반하여 만들어진 Task 스토리 컴포넌트

src/components/Task.stories.js

import React from 'react';
import { action } from '@storybook/addon-actions';

import Task from './Task';

export default {
  component: Task,
  title: 'Task',
  // Data 로 끝나는 export 항목은 stories export 대상이 아님을 표기
  excludeStories: /.*Data$/,
};

// base data (위에서 생성한 Task 컴포넌트의 state)
export const taskData = {
  id: 1,
  title: 'Test Task',
  state: 'TASK_INBOX',
  updateAt: new Date(2018, 0, 1, 9, 0),
};

export const actionsData = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTast'),
};

/**
 * Task 컴포넌트의 유형별 테스트 state 작성
 * 1. 기본
 * 2. pinned 된 task (상단 고정)
 * 3. 완료된 task
 */
export const Default = () => <Task task={{ ...taskData }} {...actionsData} />;
export const Pinned = () => (
  <Task task={{ ...taskData, state: 'TASK_PINNED' }} {...actionsData} />
);
export const Archived = () => (
  <Task task={{ ...taskData, state: 'TASK_ARCHIVED' }} {...actionsData} />
);

Config

이렇게 컴포넌트와 스토리 컴포넌트를 작성했다고 해서 자동으로 스토리북 앱에 반영이 되진 않기 때문에, 몇가지 설정을 해주도록 하자.

  1. 스토리북 config 파일 (.storybook/main.js) 의 경로를 아래처럼 수정해준다.

.storybook/main.js

module.exports = {
  stories: ['../src/components/**/*.stories.tsx'], // 경로변경
  addons: [
    '@storybook/preset-create-react-app',
    '@storybook/addon-actions',
    '@storybook/addon-links',
  ],
};
  1. .storybook 폴더 하위에 preview.js 파일을 생성해주고, 아래 내용을 작성해준다.
// .storybook/preview.js
import '../src/index.css'; // 위에서 작성했던 샘플 Task CSS file

storybook 앱을 재기동하면 아래처럼 위에서 작성한 3가지 테스트 사례가 생성된 것을 확인 할 수 있다.


DOM 구조 변경(state 반영)

Task 컴포넌트를 요구사항에 맞춰 DOM 구조를 변경하자.

src/components/Task.tsx

export type TaskItem = {
  id: number;
  title: string;
  state: string;
};
type TaskProps = {
  task: TaskItem;
  onArchiveTask(id: number): void;
  onPinTask(id: number): void;
};

function Task({ task, onArchiveTask, onPinTask }: TaskProps) {
  return (
    <div className={`list-item ${task.state}`}>
      <label className="checkbox">
        <input
          type="checkbox"
	  // ARCHIVED 상태에 따라 checked || unchecked
          defaultChecked={task.state === 'TASK_ARCHIVED'}
          disabled={true}
          name="checked"
        />
        <span
          className="checkbox-custom"
	  // onArchiveTask event : (task id 전달)
          onClick={() => onArchiveTask(task.id)}
        />
      </label>
      <div className="title">
        <input
          type="text"
          value={task.title}
          readOnly={true}
          placeholder="Input title"
        />
      </div>
      <div className="actions" onClick={(event) => event.stopPropagation()}>
	// '미완료' 상태인 Task만 Pin 버튼 보이게 처리
        {task.state !== 'TASK_ARCHIVED' && (
	  // onPinTask event : (task id 전달)
          <a onClick={() => onPinTask(task.id)}>
            <span className="icon-star" />
          </a>
        )}
      </div>
    </div>
  );
}
export default Task;

DOM 구성이 끝나고 state를 연결해주면 아래 화면처럼 구현되어야 한다.

  • Actions 패널의 log 는 Task항목의 체크박스/pin버튼을 눌렀을 때 호출되는 연결된 콜백이다.

간단한 예제로 스토리북이 어떻게 동작하는지 알아보았다.
다음 글에서는 테스트 자동화 (스냅샷 테스팅)에 대해 작성해봐야지

profile
휘둘리지 않고, 하고싶은 공부를 하는 중 :)

0개의 댓글