컴포넌트 조립(확장)하기

김가빈·2020년 6월 17일
1

storybook

목록 보기
3/3
post-thumbnail

원문링크

원문의 제목은 'Assemble a composite component' 인데, 작은 컴포넌트를 엮어서 복합 컴포넌트를
만드는것을 칭하는 듯 하다.


이 글에서는 Task 컴포넌트로 TaskList 컴포넌트를 작성한다.
컴포넌트를 결합하고, 더 복잡해질 때 어떤 일이 발생하는지 확인해보자.

Tasklist

TaskboxPinned(고정) 상태인 Task를 Default(기본) Task 상단에 배치한다.
이 경우 TaskList 스토리를 만들 때 두가지 변형 Case가 생성된다.

  1. 기본 항목
  2. 기본 항목, 기본 + 고정된 항목

또한 Task data가 비동기적으로 전송될 수 있기 때문에 로드중임을 알리는 state 에 대한 작업이 필요하고, Task가 하나도 없는 경우에 대한 (empty) state도 필요하다.

설정하기

파일을 생성하도록 하자.

  1. TaskList 컴포넌트
  2. src/components/TaskList.tsx
  3. src/components/TaskList.stories.tsx

TaskList 의 간단한 구현부터 시작해보자,
Task 컴포넌트를 가져와서 속성(attribute)과 action을 input으로 전달한다.

src/components/TaskList.js

import React from 'react';
import Task from './Task';
import { TaskItem } from './Task';

type TaskListProps = {
  loading: boolean;
  tasks: TaskItem[];
  onPinTask(id: number): void;
  onArchiveTask(id: number): void;
};

function TaskList({ loading, tasks, onPinTask, onArchiveTask }: TaskListProps) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  if (loading) {
    // 로딩중
    return <div className="list-items">loading</div>;
  }

  if (tasks.length === 0) {
    // Task 목록이 비어있을 때
    return <div className="list-items">empty</div>;
  }

  return (
    <div className="list-items">
      {tasks.map((task) => (
        <Task key={task.id} task={task} {...events} />
      ))}
    </div>
  );
}

export default TaskList;

이제 TaskList 스토리 컴포넌트를 만들어준다.

  • taskData : TaskList에서 만들어 내보낼 Task의 기반 데이터
  • actionsData : Task 컴포넌트가 기대하는 actions(사용자 정의 콜백)를 묶어둔 변수

stc/components/TaskList.stories.js

import React from 'react';
import TaskList from './TaskList';
// 이미 만들어 둔 Task stories 컴포넌트에서 공통으로 쓰일 actions, data를 가져온다
import { taskData, actionsData } from './Task.stories';

// 1. 제일 먼저 할일. default export 생성
export default {
  component: TaskList,
  title: 'TaskList',
  // decorator 에 대한 설명은 코드 하단에 따로 첨부함.
  decorators: [
    (story: any) => <div style={{ padding: '3rem' }}>{story()}</div>,
  ],
  excludeStories: /.*Data$/,
};

// 초기 default data
export const defaultTasksData = [
  { ...taskData, id: 1, title: 'Task 1' },
  { ...taskData, id: 2, title: 'Task 2' },
  { ...taskData, id: 3, title: 'Task 3' },
  { ...taskData, id: 4, title: 'Task 4' },
  { ...taskData, id: 5, title: 'Task 5' },
  { ...taskData, id: 6, title: 'Task 6' },
];

// 고정되어 보일 Task data
export const withPinnedTasksData = [
  ...defaultTasksData.slice(0, 5), // 원본Data 복사 (참조 끊기)
  { id: 6, title: 'Task 6(Pinned)', state: 'TASK_PINNED' }, // 6번항목 고정
];

// 각 Test case 생성
export const Default = () => (
  <TaskList loading={false} tasks={defaultTasksData} {...actionsData} />
);
export const WithPinnedTasks = () => (
  <TaskList loading={false} tasks={withPinnedTasksData} {...actionsData} />
);
export const Loading = () => <TaskList loading tasks={[]} {...actionsData} />;
export const Empty = () => (
  <TaskList tasks={[]} loading={false} {...actionsData} />
);

decorator

데코레이터는 스토리에 사용자 정의 Wrapper 를 추가하는 방법이다.
아래 코드는 데코레이터의 'key' 로 각 스토리를 3rem 의 padding을 추가한 div로 감싸서 export하겠다는 의미가 되며,
React의 Context를 설정하는 Provider 로 story 를 감싸야 할 때도 쓰일 수 있다.

decorators: [
  (story: any) => <div style={{ padding: '3rem' }}>{story()}</div>,
],

decorator로 wrapper를 커스텀 할 경우, snapshot(storyshot) 테스트가 전부 실패한다. 마찬가지로 TaskList.stories.tsx 에도 <div style="padding:3rem"></div> 구조로 wrapping 해서 내보내야 Test 가 통과된다.
더 나은 방법(config에서 decorators를 무시하게 설정할 수 있는지)은 찾아봤으나 .. 못찾음 😨

export const Default = () => (
  <div style={{ padding: '3rem' }}> // 동일한 구조로 Wrapping
      <TaskList loading={false} tasks={defaultTasksData} {...actionsData} />
  </div>
);

이제 새롭게 반영된 새로운 TaskList 스토리를 확인해보자!


State 구축하기

컴포넌트의 완성도가 여전히 부족하다.
TaskList 컴포넌트의 wrapper 객체인 .list-item 요소가 아무런 data 처리 없이 지나치게 단순하다!

(대부분의 경우 특정 컴포넌트를 단순히 '감싸기'위해 신규 컴포넌트로 만들지는 않기 때문이다. 현재 TaskList 컴포넌트는 단순히 Task 컴포넌트를 감싸는 용도로만 쓰였을 뿐임)

이제 TaskList 컴포넌트에 withPinnedTask , loading, empty 항목을 다듬어 보도록 하자.

import React from 'react';
import Task from './Task';
import { TaskItem } from './Task';

type TaskListProps = {
  loading: boolean;
  tasks: TaskItem[];
  onPinTask(id: number): void;
  onArchiveTask(id: number): void;
};

function TaskList({ loading, tasks, onPinTask, onArchiveTask }: TaskListProps) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  const LoadingRow = (
    <div className="loading-item">
      <span className="glow-checkbox" />
      <span className="glow-text">
        <span>Loading</span> <span>cool</span> <span>state</span>
      </span>
    </div>
  );

  if (loading) {
    // 로딩중
    return (
      <div className="list-items">
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }

  if (tasks.length === 0) {
    // Task 목록이 비어있을 때
    return (
      <div className="list-items">
        <div className="wrapper-message">
          <span className="icon-check" />
          <div className="title-message">You have no tasks</div>
          <div className="subtitle-message">Sit back and relax</div>
        </div>
      </div>
    );
  }

  // Pinned Task 가 위로 오게끔 sorting
  const tasksInOrder: TaskItem[] = [
    ...tasks.filter((t) => t.state === 'TASK_PINNED'),
    ...tasks.filter((t) => t.state !== 'TASK_PINNED'),
  ];

  return (
    <div className="list-items">
      {tasksInOrder.map((task) => (
        <Task key={task.id} task={task} {...events} />
      ))}
    </div>
  );
}

export default TaskList;

이제 Pinned 상태를 가진 Task가 상위에 노출되고, empty, loading 상태 역시 아름답게 나온다!


테스트 자동화 보완

앞선 Task 컴포넌트의 테스트 케이스는 단순했기 때문에 조금 복잡해지는 단위테스트에는 적용하기가 적절하지 않다.
TaskList 를 통해 좀 더 세부적인 테스트케이스의 출력을 확인해보자.

이를 위해 Jest를 테스트 렌더러와 함께 사용하여 유닛 테스트(Unit test)를 생성한다.

Jest를 사용한 단위 테스트

이미 적용해봤던 테스트케이스를 응용해보자,
TaskList에서 'Pinned'상태를 가진 Task의 경우 상단에 고정되어 렌더링 되기를 기대하는 시나리오가 있다.
스토리북에 WithPinnedTasks 라는 스토리를 생성하고 테스트 시나리오도 알고 있지만, 만약 TaskList 컴포넌트가 Task의 순서를 정렬하는 동작을 중단하는 경우, 개발자가 단번에 이건버그야!! 라고 알기엔 모호한 부분이 있다. (Pinned 되어있는 Task가 없는 경우를 생각해보자. Pinned되어어있는 Task가 없는것인지, 정렬이 안되고있는지 모호하지 않은가?)

이 문제를 해결하기 위해 Jest를 사용하여 스토리를 DOM으로 렌더링하고, DOM 쿼리 코드(DOM querying code) 를 실행하여 Output(출력물)의 두드러진 특징을 확인할 수 있다.

스토리북을 이용하기 때문에 테스트시 별도의 Test용 컴포넌트를 생성하거나 원천 컴포넌트를 건드리는 일 없이 스토리 컴포넌트를 import 하여 렌더링 할 수 있다!

이제 테스트 파일을 생성해보자.
( **.test.js 확장자를 가진 파일을 생성할 경우 자동으로 테스트 파일로 포함된다.)

src/components/TaskList.test.js

import React from 'react';
import ReactDOM from 'react-dom';
import { WithPinnedTasks } from './TaskList.stories'; // 스토리 파일 import

it('Task 목록의 첫번째로 Pinned 항목이 렌더링 되는지 확인', () => {
  // WithPinndedTasks 항목을 임의의 DIV에 렌더링 함.
  const div = document.createElement('div');
  ReactDOM.render(<WithPinnedTasks />, div);

  // Task의 제목이 'Task 6 (pinned)' 인 항목이 제일 첫번째 항목인지 확인(기대. expect)
  const lastTaskInput = div.querySelector(
    '.list-item:nth-child(1) input[value="Task 6 (pinned)"]',
  );
  expect(lastTaskInput).not.toBe(null);

  // Test용 DOM unmount
  ReactDOM.unmountComponentAtNode(div);
});

작성 후 test를 구동하면 아래처럼 단위 테스트에 대한 통과 결과가 출력되는걸 볼 수 있다.
(파일명을 안붙이고 Test를 구동시키면 전체 .test.js 항목을 모두 시험하기 때문에 실패 뭉텅이가 떨어진다)

npm run test src/components/TaskList.test.js

(이처럼 각종 단위테스트에서 story 컴포넌트를 재사용 할 수 있음을 주목하자!)

단,
위 테스트 예제는 Task의 구현이 변경되거나, 고도화되어가는 프로젝트 기준에 맞춰 매번 업데이트를 해주지 않으면 실패할 가능성이 지속적으로 높아질 수 있는 예제이다. (취약한 테스트)

문제가 되는것은 아니지만 UI '단위 테스트'를 무분별하게 사용할 경우 유지보수가 어렵게되므로 주의해서 적용해야 한다.

가능한 경우 시각적 테스트(Visual test), 스냅샷 및 시각적 회귀(Visual regression) 테스트에 의존하는것을 추천한다.

시각적 회귀(visual regression) 테스트 - 별도의 글로 다룰지는 고민..

다음 글에서는 컴포넌트에 Data를 연결하는 방법에 대해 다룬다.

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

0개의 댓글