프론트엔드

Storybook 컴포넌트 만들기 2

cottoncover 2023. 4. 18. 10:46
728x90
반응형
SMALL

저번 포스팅에서 Storybook을 이용해 Task 컴포넌트를 생성했습니다. 이번 포스팅에서는 이 Task 컴포넌트를 여러개 생성하여 TaskList 컴포넌트를 만들어보겠습니다.

 

 

1. TaskList의 스토리

DEFAULT - 일반 Task들의 리스트입니다.

PINNED - 핀으로 고정된 Task들이 있을 경우 해당 Task를 일반 Task보다 위에 위치하도록 해줘야 합니다.

Empty - TaskList가 비어있는 경우입니다.

LOADING - Task가 비동기식으로 전달 될 경우 연결이 없는 상태를 렌더링해줍니다.

 

2. TaskList story 구성

우선 story default export에서 내보낼 TaskList 컴포넌트를 구현만 해줍니다.

function TaskList() {
  return <>TaskList</>;
}

export default TaskList;

그 다음, 각 상태에 대한 story를 작성해줍니다.

import { StoryFn } from '@storybook/react';
import TaskList from '.';
import * as TaskStories from 'src/components/Task/index.stories';

export default {
  component: TaskList,
  title: 'TaskList',
  decorators: [
    (Story: StoryFn) => (
      <div style={{ padding: '3rem' }}>
        <Story />
      </div>
    ),
  ],
};

const Template: StoryFn = (args) => <TaskList {...args} />;

export const Default = Template.bind({});
Default.args = {
  tasks: [
    { ...TaskStories.Default.args!.task, id: '1', title: 'Task 1' },
    { ...TaskStories.Default.args!.task, id: '2', title: 'Task 2' },
    { ...TaskStories.Default.args!.task, id: '3', title: 'Task 3' },
    { ...TaskStories.Default.args!.task, id: '4', title: 'Task 4' },
    { ...TaskStories.Default.args!.task, id: '5', title: 'Task 5' },
    { ...TaskStories.Default.args!.task, id: '6', title: 'Task 6' },
  ],
};

export const WithPinnedTasks = Template.bind({});
WithPinnedTasks.args = {
  tasks: [
    ...Default.args.tasks.slice(0, 5),
    { id: '6', title: 'Task 6 Pinned', state: 'TASK_PINNED' },
  ],
};

export const Loading = Template.bind({});
Loading.args = {
  tasks: [],
  loading: true,
};

export const Empty = Template.bind({});
Empty.args = {
  ...Loading.args,
  loading: false,
};

default export구간에 decorators가 추가되었는데, decorators는 스토리에 임의의 래퍼(Wrapper)를 제공하는 한 방법입니다. 이번엔 렌더링된 컴포넌트를 주위와 padding값을 주기 위해 사용했습니다.

데코레이터는 context와 관련된 "providers"를 스토리에 감싸줄 때 사용될 수 있습니다.

 

3. TaskList 구성

Task 컴포넌트 style을 조금씩 수정하면서 TaskList를 구성해줬습니다.

import { BsCheckCircle } from 'react-icons/bs';
import { TaskType } from 'src/types';
import style from './style.module.css';
import Task from 'src/components/Task';

interface Props {
  tasks: TaskType[];
  loading: boolean;
  onPinTask: (id: string) => void;
  onArchiveTask: (id: string) => void;
}

function TaskList({ tasks, loading, onPinTask, onArchiveTask }: Props) {
  const LoadingRow = (
    <div className={style['loader-wrapper']}>
      <span className={style.loader} />
    </div>
  );

  if (loading) {
    return (
      <div className={style['list-items']}>
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }
  if (tasks.length === 0) {
    return (
      <div className={`${style['list-items']} ${style['center']}`}>
        <BsCheckCircle size={30} />
        <p>Task List is Empty</p>
      </div>
    );
  }

  const tasksInOrder = [
    ...tasks.filter((task) => task.state === 'TASK_PINNED'),
    ...tasks.filter((task) => task.state !== 'TASK_PINNED'),
  ];

  return (
    <div className={style['list-items']}>
      {tasksInOrder.map((task) => (
        <Task
          key={task.id}
          task={task}
          onPinTask={onPinTask}
          onArchiveTask={onArchiveTask}
        />
      ))}
    </div>
  );
}

export default TaskList;
export type { Props };

각 state에 따라 반환하는 컴포넌트를 달리하도록 구성해봤습니다. storybook document처럼 조건에 따라 여러 return문을 두는 컴포넌트를 이번에 처음 작성해봤는데 해당 조건에 상관 없는 연산을 안 할 수 있다는 것이 장점인 것 같습니다.

 

4. 단위 테스트

지금까지 스토리북 스토리, 스냅샷 테스트로 UI 변동사항들을 찾아낼 수 있었습니다. 이번에는 단위테스트를 적용하여 특정 시나리오에 대하여 버그가 발생하지 않았는지 찾아보도록 하겠습니다.

 

우선 @testing-library/react 와 @storybook/testing-react 패키지를 설치해줍니다.

npm install --save-dev @testing-library/react @storybook/testing-react

@storybook/testing-react는 단위 테스트에서 스토리북 스토리를 재사용할 수 있게 해주는 애드온입니다. 스토리를 테스트에서 재사용함으로써 테스트 준비가 된 컴포넌트 시나리오 카탈로그를 갖게 됩니다. 또한, 모든 인수, 데코레이터, 그리고 스토리의 모든 정보들이 이 라이브러리에 의해 조합됩니다.

한 마디로 작성된 스토리를 단위 테스트에 사용할 수 있게 해주는 모듈입니다!

 

WithPinnedTasks 스토리의 경우 pin이 된 Task를 TaskList 가장 위쪽에 배치시켜줘야하는데, 실제로 그런지 확인하는 테스트코드를 작성해보도록 하겠습니다.

import { render, screen } from '@testing-library/react';

import { composeStories } from '@storybook/react';

import * as TaskListStories from './index.stories';

const { WithPinnedTasks } = composeStories(TaskListStories);

it('render pinned tasks at the start of the line', () => {
  render(<WithPinnedTasks />);

  const firstInput = screen.getAllByRole('textbox')[0] as HTMLInputElement;

  expect(firstInput.value).toEqual('Task 6 Pinned');
});

componseStories를 통해 이미 작성된 Story로 컴포넌트를 구성했습니다. 이 때, 스토리를 구성하면서 넣은 인자값들에 대한 것들도 모두 가져와지게 됩니다. 이 때 story에서 named export된 것들 중 WithPinnedTasks 스토리를 가져옵니다.

 

render와 screen 함수로 렌더링된 WithPinnedTasks 컴포넌트를 불러오고, getAllByRole을 통해 첫번째로 렌더링된 input태그를 불러옵니다. 해당 input태그의 value가 Task 6 Pinned인지 확인하여 pin된 Task가 가장 위쪽에 위치하고 있다는 것을 확인할 수 있습니다.

테스트 결과

여기서, pin된 Task를 가장 아래쪽으로 보내서 테스트가 제대로 동작하고 있는지 확인해보겠습니다.

바뀐 taskInOrder

TaskList에서 Task를 렌더링해줄 때 사용한 taskInOrder에서 pin된 Task와 보통 Task의 순서를 바꿔줬습니다.

테스트 결과2

테스트 결과는 실패로, pin된 Task가 가장 위쪽에 있지 않는다고 알려줍니다.

 

 

오늘은 좀 더 복잡한 컴포넌트를 구성해보았습니다. 다음 포스팅에서는 컴포넌트에서 상태관리도구를 사용했을 때의 storybook 사용방법을 알아보도록 하겠습니다.

반응형
LIST