실용적인 프론트엔드 테스트 전략 (2) : TOAST Meetup
실용적인 프론트엔드 테스트 전략 (2)
meetup.toast.com
소스코드 github.com/dongwoo-kim/react-redux-todomvc-storybook-cypress
스토리북 시작하기
- 스토리북은 처음에 리액트 스토리북으로 시작했지만, 지금은 프론트 대부분의 프레임워크를 지원한다. RN도 된다.
npx -p @storybook/cli sb init
- 위의 명령을 실행하면 package.json의 의존성을 읽어들여 어떤 프레임워크를 사용하고 있는지를 자동으로 판별하고 적절한 버전의 스토리북을 설치해준다. 그 뿐만이 아니라, 처음 시작에 필요한 몇가지 보일러 플레이트를 같이 설치해주기 때문에 별도의 설정 과정 없이 바로 스토리북을 시작할 수 있다.
- 프로젝트 폴더에 .storybook 폴더와, stories 폴더가 추가된다.
.storybook - 스토리북을 사용하기 위한 설정 파일이 저장되는 곳
stories - 실제로 컴포넌트를 등록하는 코드를 작성하는 곳.
- npm run storybook 명령을 실행하면 9009 포트에 로컬 웹서버가 실행되고, 브라우저가 자동 실행되어 페이지를 보여준다.
스토리 작성하기
- 스토리북은 테스트케이스라는 이름 대신 '스토리'라는 이름을 사용한다. 보통 테스트 케이스가 하나의 모듈의 한 가지 입력값에 대한 결과를 검증하는 것과 유사하게, 스토리도 보통 하나의 컴포넌트의 한 가지 상태를 표현한다고 볼 수 있다.
- stories/index,js 수정
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를 사용해서 페이지 새로고침 없이 변경 사항을 적용하기 위해 필요하므로, 항상 전달해줘야해 한다.
- add 메소드를 이용해 스토리를 등록할 수 있다. 첫번째 인자는 스토리의 이름이며, 두 번째 인자는 컴포넌트를 렌더링 하기 위한 리액트 엘리먼트를 반환하는 함수이다. div의 클래스네임은 css때매 쓴건데 신경안써도됨.
단일 컴포넌트의 상태에 따른 스토리 작성
- 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이 필요하다.
- 결과화면

단일 컴포넌트 스토리의 문제점
- 위 예시는 단일 컴포넌트에 대한 스토리였다. 실제 애플리케이션에서는 컴포넌트들의 조합에 의해 만들어지며, 여러 개의 컴포넌트가 조합된 복합 컴포넌트도 다수 존재한다. 이런 복합 컴포넌트에 대한 스토리를 작성하지 않고 단일 컴포넌트에 대해서만 스토리를 작성하는 것은, 마치 통합 테스트를 작성하지 않고 최소 단위의 단위테스트만을 작성하는 것과 같다.
1부에서 살펴본 좋은 테스트의 조건을 고려하면, 이러한 방식은 다음과 같은 문제가 생긴다.
1. 실제 애플리케이션의 컴포넌트 조합을 검증할 수 없다.
HTML/CSS로 이루어진 UI는 각 DOM 엘리먼트의 부모/자식 관계 및 순서, CSS 선택자, z-index등 많은 요인에 의해 영향을 받는다. 실제 애플리케이션이 문제없이 표시되는지를 확인하려면 각 컴포넌트들이 올바른 순서로 조합되어 있는지, 서로 영향을 주고 있지 않은지 등을 확인해야 한다.
또한 너무 작은 단위로 작성된 스토리는 실제 디자인 시안과 시각적으로 비교하기가 어렵다.
2. 부모 컴포넌트의 내부 구현 변경 시 깨지기 쉽다.
위의 예시에서 <Todoitem>에 대한 스토리에서 단일 컴포넌트가 제대로 표시되기 위해 <div className="todoapp"> 과 <ul> 등을 추가한 것을 생각해보자. 이는 사실 <TodoItem> 의 부모 컴포넌트에서 하는 일이며, 부모 컴포넌트의 내부 구현을 목킹한 것이나 마찬가지다. 이 경우 디자인적인 변경 사항이 없더라도 리팩토링 등으로 인해 부모 컴포넌트의 내부 구현이 바뀌게 되면 스토리가 제대로 표시되지 않는다. 즉 부모 컴포넌트의 내부 구현이 변경될 때마다 스토리를 같이 변경해야한다. 또한 컴포넌트의 props 값을 직접 주입해주고 있기 때문에, 해당 컴포넌트의 prop 인터페이스가 변경되는 경우에도 마찬가지다.
복합 컴포넌트 스토리의 문제점
1. 개별 컴포넌트의 엣지 케이스를 검증하기 힘들다.
3개의 컴포넌트가 3개의 상태를 갖는다면, 모든 컴포넌트가 조합된 상태에서는 최대 27개의 상태를 가질 것이고, 이 경우 모든 케이스를 개별 스토리로 등록하려면 많은 양의 중복이 발생한다.
2. 컴포넌트의 입력값을 제공하기가 어렵다.
컴포넌트가 복잡할수록 입력값의 조합도 복잡해진다. 하나의 컴포넌트는 3~4개의 입력값만 제공하면 되지만, 컴포넌트 5개가 모이면 20개에 가까운 입력값을 제공해야 한다. 또한 리덕스의 스토어와 같은 별도의 상태 관리 객체를 사용하는 경우, 자식 컴포넌트 중 스토어 등에 연결된 컴포넌트가 하나라도 있다면 해당 상태 관리 객체 또한 주입해 주어야 한다.
3. 외부 환경에 대한 의존성이 증가한다.
컴포넌트는 단순히 시각적인 요소를 표현할 뿐 아니라, 외부 환경에 반응해서 다양한 부수 효과를 만들어내기도 한다. 브라우저의 URL 변경에 따른 라우팅을 처리하거나, 컴포넌트가 마운트 될 때 API 서버에 요청을 보내서 데이터를 받아오는 일 등을 예로 들 수 있다. 컴포넌트의 단위가 높아질수록 이러한 역할을 하는 컴포넌트가 포함되어 있을 확률이 높아지기 때문에, 외부환경에 대한 의존성을 제어할 방법이 필요해진다.
스토리의 단위 정하기
- 단일 / 복합 둘다 각각의 장단점이 있다. 그러므로 앱의 성격에 맞게 적절한 크기로 스토리의 단위를 나누는 것이 중요하다.
- 작성자님은 페이지 단위의 컴포넌트를 사용하되, 레이아웃상 컨텐츠 영역에 해당하는 컴포넌트만 따로 분리해서 스토리로 등록하는 것을 선호한다고 한다. 페이지의 컨텐츠와 연관이 없는 레이어의 경우 모두 별도의 스토리로 분리하는 것을 선호한다. 또한 특별히 페이지의 컨텐츠와 연관이 없는 레이어의 경우 모두 별도의 스토리로 분리하는 것이 좋다. 이렇게 하면 단일 컴포넌트를 등록할 때 발생하는 문제들을 대부분 해결할 수 있다.
- 대신 복합 컴포넌트 스토리의 문제점들은 별도의 해결 방법이 필요하다.
1번의 경우 Knobs 애드온 등을 이용해서 하나의 스토리에서 다수의 상태를 검증하는 식으로 해결 할 수 있다.
2번은 사실 피하기 어려운 문제인데, 다양한 상태를 한번에 표현할 수 있는 입력값을 만들어 공통으로 사용하는 방식으로 완화할 수 있다. 그리고 리덕스의 스토어 등을 목킹해서 커스텀 애드온 형태로 만들면 스토어 등에 입력값을 주입하는 코드를 단순하게 만들 수 있다.
3번의 경우 실제 애플리케이션 코드를 잘 구성하는 것이 중요하다. 외부 환경에 의존성을 갖는 컴포넌트를 최대한 최상위로 이동시키고, 시각적 요소를 담당하는 컴포넌트와 역할을 확실히 분리하는 것이 도움이 된다. 그리고 대부분의 부수효과를 redux-thunk, redux-saga 등과 같은 별도의 레이어에서 처리하도록 만들어 컴포넌트를 최대한 순수하게 유지하는 것이 좋다.
할일 관리 애플리케이션 적용
컴포넌트의 시각적 요소 분리
사실 투두리스트앱의 경우 규모가 작고 단순하기 때문에, 최상위 컴포넌트를 바로 스토리로 등록해도 된다. 하지만 최상위 컴포넌트는 라우팅, 스토어 생성 및 주입, 초기 데이터 로드 등의 역할을 같이하고 있기 때문에 만일 이런 코드가 섞여있다면 별도로 분리하는 것이 좋다.
// 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 컴포넌트를 함께 렌더링한다. 일단 입력값은 최대한 단순하게 할 일 목록 하나만 제공하도록 하자.
import React from 'react';
import { storiesOf } from '@storybook/react';
import { StaticRouter, Route } from 'react-router-dom';
import { withStore, createMockStore } from './addons/store';
import { Provider } from 'react-redux';
import App from '../components/App';
const stories = storiesOf('TodoApp', module);
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>
);
});
결과

입력값 구성하기
createMockStore의 인자값을 수정하면 된다.
Knobs 애드온 사용하기
- 애드온 : 스토리상에 등록된 컴포넌트와 상호작용을 하기위해 사용하며, 스토리가 보이는 프리부 영역 외부에 있는 패널영역을 통해 스토리를 조작하거나 내부 정보를 확인할 수 있다.
- Knobs : 패널에 입력 컨트롤을 추가해서 컴포넌트에 제공되는 입력값을 동적으로 변경할 수 있도록 도와주는 애드온이다. 이를 이용해서 하나의 스토리에서 세부 상태를 변경하며 화면을 호가인할 수 있다.
npm install @storybook/addon-knobs --save-dev
설치
import '@storybook/addon-knobs/register';
./stories/addons.js에 import
import { withKnobs, radios } from '@storybook/addon-knobs';
스토리가 등록된 src/stories/index.js에 임포트
const stories = storiesOf('Todo-App', module)
.addDecorator(withKnobs);
storiesOf 로 생성된 객체에 데코레이터를 추가
라우터의 상태 제어하기
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>
);
});
- 라디오 버튼을 사용하기위해 radios 함수 사용한다. 첫번째 이름은 레이블명, 두번째 인자는 라디오 버튼의 옵션 목록, 마지막은 기본값이다.
- <StaticRouter>는 location 값을 사용해서 라우터의 URL을 임의로 설정할 수 있다. location 값을 지정할 때 문자열 대신에 radios 함수가 반환하는 값을 사용하면 Knobs 애드온에 연결된다.

아래에 Knobs 애드온이 적용됨
스토어의 상태 제어하기
스토어와 Knobs 애드온을 연결하기 위해서는 스토리에 등록된 함수가 실행될 때마다 스토어를 새로 생성하지 않고 기존 스토어의 상태만 변경해 주어야 한다. 이 경우 직접 애드온을 만들어 데코레이터 형태로 사용하면 일련의 작업을 훨씬 단순하게 만들 수 있다. 애드온 만드는 방법은 생략한다. 스토리북 튜토리얼 문서(storybook.js.org/docs/react/api/addons)와 이 프로젝트의 소스코드(github.com/dongwoo-kim/react-redux-todomvc-storybook-cypress/blob/master/src/stories/addons/store.js)를 보고 익혀라.
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 함수를 사용하면 토글 기능을 쉽게 추가할 수 있다.
스토리북 공유하기
위와 같이 작성된 스토리는 정적 파일 형태로 만들어서 웹 서버에 배포할 수 있다. build-storybook을 사용하면 된다.
나중에 다시볼거 : 애드온. 커스텀하는거 어렵다. 그냥 리덕스 자체가 오랫만에 보니깐 헷갈림
'공부' 카테고리의 다른 글
리액트 Docs - 주요개념 (0) | 2020.12.15 |
---|---|
실용적인 프론트엔드 전략 3 (0) | 2020.12.10 |
실용적인 프론트엔드 테스트 전략 - 1 (0) | 2020.12.09 |
lazy loading (0) | 2020.12.08 |
프레젠테이션/컨테이너 컴포넌트 (0) | 2020.12.08 |