실용적인 프론트엔드 테스트 전략 (2)


1부에서는 테스트 자동화와 테스트 전략의 중요성, 시각적 테스트를 자동화하는 것이 어려운 이유 등을 살펴보았다. 사실 시각적 테스트를 자동화하는 것이 불가능하지는 않지만, 현재 우리가 가진 도구로는 기대하는 만큼 효용을 얻기가 어렵다. 이 글은 "실용적인" 테스트 전략을 다루고 있으므로, 한발 물러서서 다른 접근 방식을 시도해 보도록 하겠다. 즉, 결과물을 시각적으로 검증하는 행위는 자동화하지 않고 "사람의 눈"에 맡기되, 검증을 위한 준비 작업을 최대한 자동화하는 것이다. 이러한 작업을 위한 가장 효과적인 도구가 바로 스토리북이다.

1부에서도 말했지만, 스토리북은 사실 테스트 도구라기 보다는 UI 개발 환경에 가깝다. 스토리북의 가장 큰 목적은 "UI 컴포넌트를 애플리케이션 외부의 독립된 환경에서 개발할 수 있도록" 하는 것이다. 하지만 우리가 일반적으로 사용하는 테스트 도구가 "모듈 혹은 함수를 애플리케이션 외부의 독립된 환경에서 실행해서 결과를 검증할 수 있도록" 돕는다는 것을 생각해보면, 스토리북도 테스트 도구의 역할을 일부 수행하고 있다는 것을 알 수 있을 것이다.

(이 글에서 작성한 모든 소스코드는 깃헙 리포지토리에 공개되어 있다. 글에서 대부분의 중요 소스 코드를 보여주고 있지만, 전체 소스 코드가 궁금하다면 리포지토리에서 직접 소스 코드를 확인하길 바란다.)

스토리북 시작하기

스토리북은 처음에 리액트 스토리북으로 시작했지만, 현재는 리액트 네이티브, 뷰, 앵귤러, Ember, Riot 등 대부분의 프레임워크를 지원한다. 물론 프레임워크 없이 DOM을 직접 사용하는 경우에도 사용할 수 있으며, 지원하는 프레임워크의 목록은 리포지토리의 app 폴더에서 확인할 수 있다.

지원하는 프레임워크마다 별도의 npm 모듈을 제공하고 있기 때문에, 스토리북을 사용하기 위해서는 프로젝트 환경에 맞는 npm 모듈을 설치해야 한다. 예를 들어, 이 글에서는 리액트 기반의 할 일 관리 애플리케이션을 테스트할 것이므로, 리액트 버전의 스토리북을 설치해야 한다. 스토리북은 이런 설치 과정을 편하게 만들기 위해 CLI 도구를 제공하고 있으며, 다음의 npx 명령을 통해 별도의 설치 없이도 간단하게 사용할 수 있다.

npx -p @storybook/cli sb init

커맨드 라인에 위의 명령을 입력하고 실행하면, package.json의 의존성을 읽어들여 어떤 프레임워크를 사용하고 있는지를 자동으로 판별하고 적절한 버전의 스토리북을 설치해준다. 그뿐만 아니라, 처음 시작하기 위해 필요한 몇 가지 보일러 플레이트를 같이 설치해주기 때문에 별도의 설정 과정 없이 바로 스토리북을 시작할 수 있다.

프로젝트 폴더를 열어 보면 .storybook 폴더와 stories 폴더가 추가된 것을 볼 수 있다. .storybook 폴더는 스토리북을 사용하기 위한 설정 파일이 저장되는 곳이고, stories 폴더는 실제로 컴포넌트를 등록하는 코드를 작성하는 곳이다. 또한 package.json 파일에 storybookbuild-storybook 이라는 스크립트가 추가된 것을 확인할 수 있다. build-storybook은 나중에 설명하기로 하고, 먼저 storybook 스크립트를 사용해서 스토리북을 실행해보자.

npm run storybook

위의 명령을 실행하면 9009 포트에 로컬 웹 서버가 실행되고, 브라우저가 자동 실행되어 해당 페이지(localhost:9009)를 보여줄 것이다. 커맨드 라인 명령 단 두 줄로 모든 설치 및 설정 과정이 끝났다. 만약 CLI 도구의 도움 없이 직접 설치 및 설정을 해 보고 싶다면 공식 문서를 참고하면 된다.

스토리 작성하기

스토리북은 테스트 케이스라는 이름 대신 "스토리"라는 이름을 사용한다. 보통 테스트 케이스가 하나의 모듈의 한 가지 입력값에 대한 결과를 검증하는 것과 유사하게, 스토리도 보통 하나의 컴포넌트의 한 가지 상태를 표현한다고 볼 수 있다. 스토리를 어떤 단위로 구분하는 게 좋을지는 차차 살펴보기로 하고, 먼저 가장 간단한 컴포넌트인 <Header> 컴포넌트를 스토리로 등록해 보자.

먼저, stories/index.js 파일을 열어 보면, CLI 도구가 <Button> 컴포넌트에 대한 예제를 등록해 놓은 것을 볼 수 있다. 이를 삭제하고, 다음과 같이 코드를 작성자다.

import React from "react";

import { storiesOf } from "@storybook/react";
import { Header } from "../components/Header";
import "../components/App.css";

const stories = storiesOf("TodoApp", module);

stories.add("Header", () => (
  <div className="todoapp">
    <Header addTodo={() => {}} />
  </div>
));

storiesOf 함수는 스토리를 등록하고 여러 개의 스토리를 관리할 수 있는 있는 객체를 반환한다. 첫 번째 인자는 일종의 카테고리 명과 같은 역할을 하며, 등록되는 스토리들을 하나의 카테고리로 묶어서 표시할 때 사용된다. 두 번째 인자인 module은 스토리북이 내부적으로 Hot Module Replacement를 사용해서 페이지 새로고침 없이 변경 사항을 적용하기 위해 필요하므로, 항상 전달해 주어야 한다.

storiesOf를 통해 반환된 객체의 add 메소드를 사용하면 스토리를 등록할 수 있다. 첫 번째 인자는 스토리의 이름이며, 두 번째 인자는 컴포넌트를 렌더링하기 위한 리액트 엘리먼트를 반환하는 함수이다. 이 예제에서는 <Header> 컴포넌트가 todoapp 클래스를 갖는 DOM 엘리먼트 하위에 있어야만 CSS가 제대로 적용되기 때문에 <div className="todoapp">을 최상위 노드에 추가했다. 또한 행위를 테스트하는 것은 아니기 때문에, addTodo를 빈 함수로 제공해서 에러가 발생하지 않게만 한다.

이 코드를 저장하면, 브라우저에서 다음과 같은 화면을 볼 수 있을 것이다.

Storybook-Header

단일 컴포넌트의 상태에 따른 스토리 작성

<Header> 컴포넌트는 props에 따라 변하는 상태가 없기 때문에 위와 같이 간단하게 스토리를 등록할 수 있다. 하지만 props에 따라 상태가 변하는 컴포넌트는 각 상태에 대한 스토리를 따로 등록해 주는 것이 좋다. 예를 들어 컴포넌트는 할 일의 내용 외에도 "일반", "완료", "편집 중"이라는 세 가지의 상태를 갖기 때문에 다음과 같이 각각에 대한 스토리를 따로 등록해 주어야 한다.

stories.add("TodoItem - Normal", () => (
  <div className="todoapp">
    <ul className="todo-list">
      <TodoItem
        id={1}
        text="Have a Breakfast"
        completed={false}
        editing={false}
      />
    </ul>
  </div>
));

stories.add("TodoItem - Completed", () => (
  <div className="todoapp">
    <ul className="todo-list">
      <TodoItem
        id={1}
        text="Have a Breakfast"
        completed={true}
        editing={false}
      />
    </ul>
  </div>
));

stories.add("TodoItem - Editing", () => (
  <div className="todoapp">
    <ul className="todo-list">
      <TodoItem
        id={1}
        text="Have a Breakfast"
        completed={false}
        editing={true}
      />
    </ul>
  </div>
));

주의할 점은 <TodoItem>가 제대로 표시되기 위해서는 부모 엘리먼트인 <ul className="todo-list">가 필요하다는 것이다. 이렇게 스토리를 작성하고 나면 다음과 같이 <TodoItem>의 개별 상태에 대한 스토리가 추가된 것을 볼 수 있다.

StoryBook-TodoItem

단일 컴포넌트 스토리의 문제점

지금까지는 작성한 스토리는 모두 자식 컴포넌트를 갖지 않는 단일 컴포넌트에 대한 스토리였다. 보통 컴포넌트라고 하면 "목록 아이템", "버튼", "입력 박스"와 같이 아주 작은 단위의 컴포넌트를 떠올리고, 스토리도 이러한 작은 단위에 대해서만 작성하게 된다. 하지만 실제 애플리케이션은 컴포넌트들의 조합에 의해 만들어지며, 여러 개의 컴포넌트가 조합된 복합 컴포넌트도 다수 존재한다. 이런 복합 컴포넌트에 대한 스토리를 작성하지 않고 단일 컴포넌트에 대해서만 스토리를 작성하는 것은, 마치 통합 테스트를 작성하지 않고 최소 단위의 단위 테스트만을 작성하는 것과 같다.

1부에서 살펴본 "좋은 테스트의 조건"을 고려해보면, 이러한 접근 방식은 다음과 같은 문제가 있다.

1. 실제 애플리케이션의 컴포넌트 조합을 검증할 수 없다.

당연한 이야기지만, 개별 컴포넌트가 시각적으로 문제없이 표시된다는 것이 전체 애플리케이션이 시각적으로 문제없이 표시된다는 의미가 될 수 없다. 특히 HTML/CSS로 이루어진 UI는 각 DOM 엘리먼트의 부모/자식 관계 및 순서, CSS 선택자, z-index 등 많은 요인에 의해 영향을 받는다. 실제 애플리케이션이 문제없이 표시되는지를 확인하려면 각 컴포넌트들이 올바른 순서로 조합되어 있는지, 서로 영향을 주고 있지 않은지 등을 확인해야만 한다.

또한 너무 작은 단위로 작성된 스토리는 실제 디자인 시안과 시각적으로 비교하기가 어렵다. 여러 개의 버튼이 한 화면에 정렬된 디자인 시안에 대해 버튼 하나만 표시되고 있는 스토리를 일일이 바꿔가며 검증하는 것은 번거롭고 어려울 것이다.

2. 부모 컴포넌트의 내부 구현 변경 시 깨지기 쉽다.

앞서 작성했던 <Todoitem>에 대한 스토리에서 단일 컴포넌트가 제대로 표시되기 위해 <div className="todoapp"><ul className="todo-list"> 등을 추가해 주었던 것을 생각해보자. 이는 사실 <TodoItem>의 부모 컴포넌트에서 하는 일이며, 부모 컴포넌트의 내부 구현을 목킹한 것이나 마찬가지다. 이 경우 디자인적인 변경 사항이 없더라도 리팩토링 등으로 인해 부모 컴포넌트의 내부 구현이 바뀌게 되면 스토리가 제대로 표시되지 않는다. 즉, 부모 컴포넌트의 내부 구현이 변경될 때마다 스토리를 같이 변경해야만 한다.

또한 컴포넌트의 prop 값을 직접 주입해주고 있기 때문에, 해당 컴포넌트의 prop 인터페이스가 변경되는 경우에도 스토리를 함께 변경해 주어야 한다. 사실 어떤 컴포넌트의 props 인터페이스가 변경되든, 그 컴포넌트를 사용하는 부모 컴포넌트 입장에서는 내부 구현이 변경되는 것이나 마찬가지다. 보통 컴포넌트를 작은 단위로 사용할수록 영향을 받는 부모 컴포넌트의 수가 늘어난다는 점을 생각해보면, 컴포넌트 단위가 작아질수록 스토리에 대한 관리 비용이 증가한다고 볼 수 있다.

복합 컴포넌트 스토리의 문제점

이번에는 반대로 아주 큰 단위의 컴포넌트를 사용해서 스토리를 작성하는 경우를 생각해 보자. 이 경우는 다음과 같은 문제가 발생할 수 있다.

1. 개별 컴포넌트의 엣지 케이스를 검증하기 힘들다.

3개의 컴포넌트가 각각 3개의 상태를 갖는다고 가정하면, 모든 컴포넌트가 조합된 상태에서는 최대 27(3 _ 3 _ 3)개의 상태를 갖는다고 할 수 있다. 이 경우 모든 케이스를 개별 스토리로 등록하려면 많은 양의 중복이 발생하게 될 것이다.

2. 컴포넌트의 입력값을 제공하기가 어렵다.

컴포넌트가 복잡할수록 입력값의 조합도 복잡해진다. 하나의 컴포넌트는 3~4개의 입력값만 제공하면 되지만, 컴포넌트 5개가 모이면 20개에 가까운 입력값을 제공해야만 한다. 또한 리덕스의 스토어와 같은 별도의 상태 관리 객체를 사용하는 경우, 자식 컴포넌트 중 스토어 등에 연결된 컴포넌트가 하나라도 있다면 해당 상태 관리 객체 또한 주입해 주어야 한다.

3. 외부 환경에 대한 의존성이 증가한다.

컴포넌트는 단순히 시각적인 요소를 표현할 뿐 아니라 외부 환경에 반응해서 다양한 부수 효과를 만들어내기도 한다. 브라우저의 URL 변경에 따른 라우팅을 처리하거나, 컴포넌트가 마운트될 때 API 서버에 요청을 보내서 데이터를 받아오는 일 등을 예로 들 수 있다. 컴포넌트의 단위가 높아질수록 이러한 역할을 하는 컴포넌트가 포함되어 있을 확률이 높아지기 때문에, 외부 환경에 대한 의존성을 제어할 방법이 필요해진다.

스토리의 단위 정하기

이처럼 양쪽 극단을 이용할 경우 각각의 장단점이 있다. 그러므로 애플리케이션의 성격에 맞게 적절한 크기로 스토리의 단위를 나누는 것이 중요하다. 개인적으로는 페이지 단위의 컴포넌트를 사용하되, 레이아웃상 컨텐츠 영역에 해당하는 컴포넌트만 따로 분리해서 스토리로 등록하는 것을 선호한다. 또한 특별히 페이지의 컨텐츠와 연관이 없는 레이어의 경우 모두 별도의 스토리로 분리하는 것이 좋다. 이렇게 하면 단일 컴포넌트를 등록할 때 발생하는 문제들을 대부분 해결할 수 있다.

대신 복합 컴포넌트 스토리의 문제점들은 별도의 해결 방법이 필요하다. 1번의 경우 Knobs 애드온 등을 사용해서 하나의 스토리에서 다수의 상태를 검증하는 식으로 해결할 수 있다. 2번은 사실 피하기 어려운 문제인데, 다양한 상태를 한 번에 표현할 수 있는 입력값을 만들어 공통으로 사용하는 방식으로 완화할 수 있다. 그리고 리덕스의 스토어 등을 목킹해서 커스텀 애드온 형태로 만들면 스토어 등에 입력값을 주입하는 코드를 단순하게 만들 수 있다.

3번의 경우 실제 애플리케이션 코드를 잘 구성하는 것이 중요하다. 외부 환경에 의존성을 갖는 컴포넌트를 최대한 최상위로 이동시키고, 시각적 요소를 담당하는 컴포넌트와 역할을 확실하게 분리하는 것이 도움이 된다. 그리고 대부분의 부수 효과를 redux-thunk, redux-saga 등과 같은 별도의 레이어에서 처리하도록 만들어 컴포넌트를 최대한 순수(pure)하게 유지하는 것이 좋다.

할 일 관리 애플리케이션 적용

아마 지금까지 설명한 것만으로는 쉽게 감이 오지 않을 것이다. 이제 이 내용을 실제 애플리케이션에 적용하면서 문제점들을 하나씩 해결해 보도록 하자.

컴포넌트의 시각적 요소 분리

사실 할 일 관리 애플리케이션은 일반적인 애플리케이션에 비해 규모가 작고 단순하기 때문에, 최상위 컴포넌트를 바로 스토리로 등록해도 된다. 하지만 최상위 컴포넌트는 라우팅, 스토어 생성 및 주입, 초기 데이터 로드 등의 역할을 같이 담당하고 있기 때문에 만일 이런 코드가 섞여 있다면 별도로 분리하는 것이 좋다. 예제 소스를 보면 src/index.js 파일에서 대부분의 작업을 처리하고 컴포넌트는 렌더링 역할만 담당하고 있는 것을 확인할 수 있을 것이다.

// components/App.js

import React from "react";
import Main from "./Main";
import Header from "./Header";
import Footer from "./Footer";
import "./App.css";

export default class App extends React.Component {
  render() {
    return (
      <div className="todoapp">
        <Header />
        <Main />
        <Footer />
      </div>
    );
  }
}

스토어 목킹

이제 이 <App> 컴포넌트에 대한 스토리를 작성하면 된다. 하지만 스토리를 작성하려고 보니, 컴포넌트에 입력값을 제공할 방법이 없다. <App> 컴포넌트에는 별도의 prop이 없고, 자식 컴포넌트를 렌더링할 뿐이기 때문이다. 그리고 자식 컴포넌트들은 모든 입력값을 리덕스의 스토어로부터 주입받는다. 즉, 입력값을 제공하기 위해서는 스토어가 필요하다. 하지만 스토어는 리듀서와 액션을 통해서만 상태를 변경할 수 있기 때문에 입력값을 원하는 형태로 제공하기가 불편하다. 대신 스토어의 API는 사실 매우 단순하기 때문에, 다음과 같이 간단하게 모의 객체를 만들 수 있다.

function createMockStore(initialState) {
  return {
    dispatch() {},
    subscribe() {},
    getState() {
      return initialState;
    }
  };
}

현재 이 스토어의 역할은 초기 입력값을 제공할 뿐, 스토어의 상태를 변경할 필요가 없기 때문에 dispatch, subscribe 등의 메소드는 기능을 구현할 필요가 없다. 단지 getState 메소드가 초기 입력값을 제대로 반환해 주기만 하면 된다.

스토리 작성

이제 입력값을 제공할 수 있게 되었으니 스토리를 작성할 수 있다. 하지만 문제는 자식 컴포넌트 중 스토어 외의 입력값이 필요한 컴포넌트가 있다는 것이다. 그 입력값은 바로 react-router로부터 전달받는 데이터이다. <Footer><Main> 컴포넌트 모두 withRouter를 통해 라우터로부터 현재 페이지의 파라미터 정보를 가져오고 있다. 그렇기 때문에 최상위 컴포넌트를 렌더링할 때 라우터의 정보를 제공하는 컴포넌트로 감싸주어야 한다. 다만 실제 애플리케이션에서 사용되는 BrowserRouter를 사용하게 되면 브라우저의 URL에 영향을 받기 때문에 입력값을 제어할 수 있는 다른 종류의 라우터를 사용하거나 직접 모의 라우터를 만들어야 한다. 여기서는 백엔드에서 환경에서 주로 사용하는 StaticRouter를 사용하기로 하겠다.

그럼, 이제 실제 스토리를 작성해보자. 먼저 createMockStore를 이용해 모의 스토어를 생성한 다음, <Provider> 컴포넌트와 <StaticRouter> 컴포넌트를 함께 렌더링한다. 일단 입력값은 최대한 단순하게 할 일 항목 하나만 제공하도록 하자.

stories.add("App", () => {
  const store = createMockStore({
    todos: [
      {
        id: 1,
        text: "Have a Breakfast",
        completed: false
      }
    ]
  });

  return (
    <Provider store={store}>
      <StaticRouter location="/" context={{}}>
        <Route path="/:nowShowing?" component={App} />
      </StaticRouter>
    </Provider>
  );
});

이제 화면에 다음과 같이 전체 애플리케이션이 표시되는 것을 볼 수 있다.

Storybook-App1

입력값 구성하기

전체 애플리케이션을 하나의 스토리로 작성했기 때문에, 한눈에 최대한 다양한 상태를 볼 수 있는 것이 좋다. 예를 들어 단일 컴포넌트를 사용할 때는 할 일 항목의 상태에 따라 각각 다른 스토리를 등록했지만, 지금은 각각 다른 상태를 갖는 할 일 항목을 동시에 보여줄 수 있다.

const store = createMockStore({
  todos: [
    {
      id: 1,
      text: "Have a Breakfast",
      completed: false
    },
    {
      id: 2,
      text: "Have a Lunch",
      completed: true
    },
    {
      id: 3,
      text: "Have a Dinner",
      completed: false
    }
  ],
  editing: 3
});

입력값을 이렇게 작성하면 다음과 같이 각각 "일반(1)", "완료됨(2)", "수정 중(3)" 상태인 할 일 항목을 한눈에 볼 수 있다.

Storybook-App2

Knobs 애드온 사용하기

이제 할 일 항목의 다양한 상태를 한눈에 볼 수 있게 되었지만, 여전히 고려해야 할 상태가 많이 남아있다. 하단의 "All", "Active", "Completed" 버튼이 각각 활성화 상태가 되었을 때와, 상단의 "전체 선택" 체크 박스의 상태 등이다. 이러한 개별 컴포넌트의 상태를 위해 각각 다른 <App> 스토리를 등록하면 변경을 알아보기도 어렵고 관리 비용도 많이 든다. 이 때 애드온을 사용하면 이런 문제를 해결할 수 있다.

애드온은 스토리북의 핵심 기능 중의 하나이다. 주로 스토리상에 등록된 컴포넌트와 상호작용을 하기 위해 사용하며, 스토리가 보이는 "프리뷰" 영역 외부에 있는 "패널" 영역을 통해 스토리를 조작하거나 내부 정보를 확인할 수 있다. 애드온에 대한 자세한 설명사용법은 공식 문서에서 확인할 수 있으며, 애드온 갤러리에서 수십 개의 유용한 애드온들을 확인할 수 있다.

이 중 Knobs 애드온은 패널에 입력 컨트롤을 추가해서 컴포넌트에 제공되는 입력값을 동적으로 변경할 수 있도록 도와주기 때문에, 이를 이용하면 하나의 스토리에서 세부 상태를 변경하면서 화면을 확인할 수 있다. 이 글에서는 실제 사용 예를 중심으로 설명할 것이므로, Knobs 애드온의 자세한 사용법은 깃헙 리포지토리를 참조하기 바란다.

Knobs 애드온 설치 및 설정

설치 및 설정은 아주 간단하다. 먼저 다음 npm 명령을 통해 애드온을 설치한다.

npm install @storybook/addon-knobs --save-dev

그 다음, ./stories/addons.js 파일을 열어보면 기존에 스토리북 CLI가 기본으로 추가해 놓은 코드가 있을 것이다. 모두 삭제한 다음 아래의 코드를 추가한다.

import "@storybook/addon-knobs/register";

다음으로 스토리가 등록된 src/stories/index.js 파일을 열고 상단에 아래의 코드를 추가하자.

import { withKnobs, radios } from "@storybook/addon-knobs";

마지막으로 storiesOf로 생성된 객체에 데코레이터를 추가해 주면 모든 준비가 끝난다.

const stories = storiesOf("Todo-App", module).addDecorator(withKnobs);

라우터의 상태 제어하기

이제 라우터의 입력값을 제어해서 하단 버튼의 상태를 바꿔보겠다. 여기서는 라디오 버튼을 사용하기 위해 radios 함수를 사용한다. 첫 번째 인자는 레이블명, 두 번째 인자는 라디오 버튼의 옵션 목록, 마지막은 기본값이다.

stories.add(App, () => {
  // ... 기존 코드와 동일

  const location = radios(
    "Filter",
    {
      All: "/All",
      Active: "/Active",
      Completed: "/Completed"
    },
    "/All"
  );

  return (
    <Provider store={store}>
      <StaticRouter location={location}>
        <Route path="/:nowShowing" component={App} />
      </StaticRouter>
    </Provider>
  );
});

<StaticRouter>는 location 값을 사용해서 라우터의 URL을 임의로 설정할 수 있다. location 값을 지정할 때 문자열 대신에 radios 함수가 반환하는 값을 사용하면 Knobs 애드온에 해당 연결된다. 이제 코드를 저장하면 다음과 같이 Knobs 패널에 Filter 항목이 추가된다. 각 라디오 버튼을 클릭할 때마다 Footer에 있는 버튼의 상태가 변경되는 것을 확인할 수 있을 것이다.

Storybook-App3

스토어의 상태 제어하기

이제, "전체 선택" 체크박스가 제대로 표시되는지를 확인해보자. "전체 선택" 체크박스는 모든 할 일 항목이 completed 상태가 되어야만 체크되기 때문에 상태를 변경하기 위해서는 스토어의 상태를 변경해야 한다. 하지만 <Provider> 컴포넌트는 처음에 지정된 스토어 객체의 참조가 변경되는 것을 허용하지 않기 때문에, 매번 createMockStore를 호출하는 방식으로는 스토어의 상태를 변경할 수 없다. 또한 스토어는 보통 dispatch에 의해서만 상태를 변경하기 때문에, 원하는 상태를 임의로 지정하기 위해서는 setState와 같은 별도의 메소드를 추가로 구현해야 한다.

즉, 스토어와 Knobs 애드온을 연결하기 위해서는 스토리에 등록된 함수가 실행될 때마다 스토어를 새로 생성하지 않고 기존 스토어의 상태만 변경해 주어야 한다. 이 경우 직접 애드온을 만들어 데코레이터 형태로 사용하게 되면 일련의 작업을 훨씬 단순하게 만들 수 있다. 이 작업이 몹시 어렵지는 않지만, 이 글에서 커스텀 애드온을 만들는 과정을 모두 설명하면서 구현 코드를 모두 보여주기에는 분량상 부담스럽기 때문에 실제 구현 코드는 생략하기로 한다. 관심 있으신 분들은 스토리북의 튜토리얼 문서store 애드온의 소스코드를 확인하길 바란다.

여기서는 만들어진 애드온을 사용해서 Knobs 애드온과 스토어를 연결하는 과정만 살펴보도록 하겠다. 완성된 코드는 다음과 같다.

import React from "react";
import { storiesOf } from "@storybook/react";
import { withKnobs, radios, boolean } from "@storybook/addon-knobs";
import { StaticRouter, Route } from "react-router-dom";
import { withStore } from "./addons/store";
import App from "../components/App";

const stories = storiesOf("Todo-App", module)
  .addDecorator(withKnobs)
  .addDecorator(withStore);

stories.add(
  "App",
  () => {
    const options = {
      All: "/All",
      Active: "/Active",
      Completed: "/Completed"
    };

    const location = radios("Filter", options, options.All);

    return (
      <StaticRouter location={location} context={{}}>
        <Route path="/:nowShowing" component={App} />
      </StaticRouter>
    );
  },
  {
    state: () => {
      const isAllCompleted = boolean("Complete All", false);
      const editing = boolean("Editing", false) ? 3 : null;

      return {
        todos: [
          {
            id: 1,
            text: "Have a Breakfast",
            completed: isAllCompleted || false
          },
          {
            id: 2,
            text: "Have a Lunch",
            completed: isAllCompleted || true
          },
          {
            id: 3,
            text: "Have a Dinner",
            completed: isAllCompleted || false
          }
        ],
        editing
      };
    }
  }
);

기존에 모의 스토어를 생성하고 <Provider>를 통해 스토어를 제공하던 로직이 제거되고, add 함수의 세 번째 인자로 state 라는 키를 갖는 객체를 넘겨주는 것을 볼 수 있을 것이다. add 함수의 세 번째 인자는 addDecorator로 등록한 데코레이터가 전달받을 값을 지정하기 위해 사용된다. withStore 데코레이터는 state라는 키의 값을 받아서 내부적으로 스토어의 상태를 갱신하게 되어 있다. 또한 Knobs의 패널에서 값을 변경할 때마다 세 번째 인자의 값도 새로 갱신되어야 하므로 state의 값은 함수로 전달하고 있다.

addon-knobs 모듈에서 제공하는 boolean 함수를 사용하면 토글 기능을 쉽게 추가할 수 있다. 위의 코드에서는 전체 선택 상태와 더불어, 현재 편집중인 할 일 항목을 토글할 수 있는 기능도 추가했다. 이제 위의 코드를 실행하면 다음과 같이 Knobs 패널에 Complete All, Editing 이라는 레이블이 추가된 것을 볼 수 있다.

Storybook-App4

스토리북 공유하기

위와 같이 작성된 스토리는 정적 파일 형태로 만들어서 웹서버에 배포할 수도 있다. 처음에 설명하지 않고 넘어갔던 npm 스크립트인 build-storybook을 실행해보자. 그러면 프로젝트 루트에 storybook-static 폴더가 생성되는 것을 확인할 수 있다. 이 폴더를 깃헙 페이지 등의 정적 서버를 활용해서 배포하면 개발자뿐만 아니라 기획자, 디자이너 등 다른 부서의 동료들도 이 프로젝트의 모든 스토리를 확인할 수 있게 된다. 이렇게 서버에 배포된 페이지는 디자인 QA, 문서화 등의 커뮤니케이션 도구로 사용하면 매우 유용하다.

(이렇게 스토리북을 활용해서 커뮤니케이션 도구로 사용하는 방법은 이전에 FE 위클리에서 즐거운 스토리북 워크플로우를 통해 소개한 적이 있다.)

이 글에서 작성한 스토리도 깃헙 페이지에 등록되어 있으니 확인해보길 바란다.

2부를 마치며

이처럼 할 일 관리 애플리케이션은 단 하나의 스토리로 모든 상태를 검증할 수 있다. 물론 단순히 prop을 주입하기만 하면 되는 단일 컴포넌트에 비해서는 코드가 더 복잡해졌지만, 마지막으로 작성된 스토리의 코드를 보면 검증을 위한 입력값 외에는 불필요한 코드가 없이 한눈에 잘 읽히는 것을 알 수 있다. 또한 이 스토리를 위한 코드의 전체 라인 수가 40줄 정도밖에 안되는 걸 고려해 보면, 단일 컴포넌트를 여러 개 등록하는 것보다 코드의 양도 상당히 줄어든다는 것을 알 수 있을 것이다.

좀 더 복잡한 애플리케이션의 경우 하나의 스토리로 모든 상태를 검증할 수는 없을 것이다. 이 경우 애플리케이션의 규모나 특징에 따라 어떤 단위로 스토리를 나눌 것인가를 신중하게 고려해야 한다. 앞서 언급했던 단일 컴포넌트 스토리와 복합 컴포넌트 스토리의 장단점과 할 일 애플리케이션의 예제를 참고해서 적절한 전략을 세울 수 있길 바란다.

이 글에서 작성한 스토리북 페이지를 확인해 보면, 시각적인 요소는 표시되지만 사용자의 입력을 처리하는 부분은 대부분 동작하지 않는 것을 볼 수 있을 것이다. 스토리북은 단지 시각적인 요소를 눈으로 확인하기 위한 용도라는 것을 잊지 말자. 스토리북을 이용해서 기능적인 요소까지 테스트하려는 것은 도구의 용도를 벗어나서 사용하는 것이다. 1부에서도 언급했지만, 시각적인 요소를 나머지 부분과 분리하는 이유는 테스트를 자동화하기가 어렵기 때문이다. 나머지 기능적인 요소에 대한 테스트를 자동화하는 내용은 3부에서 Cypress를 사용해서 다루도록 하겠다.

추가: 시각적 회귀 테스트

분량상 다루지는 못했지만, 시각적 회귀 테스트 자동화를 위한 도구들도 최근 대부분 스토리북을 지원하고 있다. 이런 도구에는 대표적으로 Perci, Applitools, Chromatic 등이 있으며, 아래에 관련 링크를 첨부하니 관심 있는 분들은 확인해보길 바란다.


김동우, FE Development Lab2019.01.16Back to list