화면 작업을 하게되면 늘 반복되는 작업중 하나가 공통 컴포넌트에 대한 스타일 가이드 작업이다.
단독으로 작업할 때나 협업이 필요할때나 일회성 생명주기를 가진 컴포넌트가 아닌 이상 어떤 구조로 만들어졌고, 어떤 속성변화에 의존하여 UI 가 변경되는지 문서화 해두는 작업이 꼭 필요하기 때문이다.
버튼만 해도 최소 3개에서 많게는 10개 이상의 case 가 발생하게 되는데,
살아있는 서비스는 UI도 지속적으로 고도화 되기 때문에, 매번 변경되는 사항을 별도로 스타일가이드에 반영하고, 또 상태변경에 의한 가이드를 수기로 작성하는것은 너무나도 고된 반복 작업인것이다 ㅠ.ㅜ
해서 이번 파일럿 프로젝트에는 UI 구성요소를 손쉽게 관리하고 설계하게 해주는 storybook 을 이용해볼 생각이다. (샘플링 프로젝트는 React + Typescript 기반으로 작성됨)
storybook 을 이용해서 아래 나열된 항목을 손쉽게 관리/구축 해보자 :)
스토리북 공식사이트의 튜토리얼이 20년 6월 현재 기준으로는 한글이 지원되지 않는다.
나는 당장 스토리북 적용을 해보고 싶은 관계로.. 튜토리얼을 번역해서 옮겨본다😱
(내용은 다 옮기겠지만 순서는 내가 이해하기 편한 순서대로 나열😎, 의역은 옵션..)
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 샘플을 구현해 보자.
먼저 샘플을 작성하는데 필요한 CSS, 폰트, 아이콘을 프로젝트에 import 시켜준다.
CSS
: 링크로 접속해서 CSS 전문을 복사하여, src 폴더 하위에 index.css 파일을 생성한 뒤 코드 붙여넣기.
font, icon
: 링크로 접속.
Task 컴포넌트 작성에 앞서,
Storybook 컴포넌트 제작 시 공식사이트에서도 Atomic, CDD(Component-Driven Development) 방식을 권장하고 있다.
분리할 수 있는 가장 작은 단위의 컴포넌트(가장 작은 UI 단위가 아닌 아닌 컴포넌트 단위.)로 쪼개고 쪼개서 구성요소로 시작하여 화면으로 끝나는 Bottom-Up 방식의 프로세스인데, UI를 구축 할 때 높은 확장성과 독립된 유지보수가 가능해진다.
그럼 이제 Task 컴포넌트를 아래 조건들을 만족하게끔 구현해보자.
- 각 Task(할일)는 현재 state에 따라 UI가 변동된다.
- 할일 완료/미완료 표기 체크박스 (checked, unchecked) 를 표현한다.
- 'pin' 버튼을 눌러서 할일박스 상단에 표기되게 처리한다.
위 처리를 위해 우선 2가지 props 가 필요하다.
title
- 할일 내용state
- 현재 목록에 포함되어 있는 할일과, 미완료된 할일 객체Storybook 컴포넌트를 작성하기 위해서는 크게 2개의 단계로 나누어 볼 수 있다.
이를 통해 상태별 UI 변화를 직관적으로 테스트 할 수 있고, 이런 테스트 과정을 'Visual test' 라고 칭한다.
이제 컴포넌트를 생성하자!
구성요소 컴포넌트와 스토리북 컴포넌트를 쌍(pair)으로 만들어준다.
스토리북은 '구성요소(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;
스토리북에게 만들어진 스토리를 스토리북에 포함해줘! 라고 알리기위해, 다음 조건들을 충족하는
default export
를 생성한다.
component
: 구성 요소 그 자체 (ex: Task)title
: 스토리북 좌측 트리메뉴에 표기될 스토리 명excludeStories
: 스토리 렌더링 대상에서 미포함 될 대상export default {
component:Task,
title: 'Task',
excludeStories: /.*Data$/,
}
컴포넌트의 각 테스트 state(상태)를 적용한 함수형 컴포넌트를 export 해준다.
(참고 : Stateless Functional Component)
export const Pinned = () => ( // state: 'TASK_PINNED' <- test state 반영.
<Task task={{ ...taskData, state: 'TASK_PINNED' }} {...actionsData} />
);
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 하고, 내보낸 주체 컴포넌트를 재사용하는 다른 컴포넌트의 스토리에서도 동일하게 이 액션을 재사용할 수 있다는 잇점도 있다.
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} />
);
이렇게 컴포넌트와 스토리 컴포넌트를 작성했다고 해서 자동으로 스토리북 앱에 반영이 되진 않기 때문에, 몇가지 설정을 해주도록 하자.
.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',
],
};
.storybook
폴더 하위에 preview.js
파일을 생성해주고, 아래 내용을 작성해준다. // .storybook/preview.js
import '../src/index.css'; // 위에서 작성했던 샘플 Task CSS file
storybook 앱을 재기동하면 아래처럼 위에서 작성한 3가지 테스트 사례가 생성된 것을 확인 할 수 있다.
Task 컴포넌트를 요구사항에 맞춰 DOM 구조를 변경하자.
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버튼을 눌렀을 때 호출되는 연결된 콜백이다.간단한 예제로 스토리북이 어떻게 동작하는지 알아보았다.
다음 글에서는 테스트 자동화 (스냅샷 테스팅)에 대해 작성해봐야지