๊ณต์ ๋ฌธ์์์๋ ์ผ์ ๊ด๋ฆฌ๋ฅผ ์ํ TaskBox๋ฅผ ๋ง๋ค๊ณ ์์ด์. ์ด๋ฅผ ๋ฐ๋ผ๊ฐ๋ฉด์ ์์ฑํ๊ฒ ์ต๋๋ค. ๋ค๋ง, ๋๋ฌด TaskBox ํ๋ก์ ํธ์ ๊ตญํ๋๊ธฐ ๋ณด๋ค๋ ์ ๊ฐ ๋ชฐ๋๋ ๋ถ๋ถ๋ค์ ์์ฃผ๋ก ์ ์ผ๋ฉด์ ๊ณต๋ถํ๊ณ ์ ํด์.
npx create-react-app taskbox
cd taskbox
npx -p @storybook/cli sb init
script์ ๋ช ๋ น์ด๋ฅผ ์ถ๊ฐํ๋ ค๊ณ ํ๋ ๊ณต์๋ฌธ์์์ ๋ค์์ ๋งํ๊ณ ์์์ด์.
# ํฐ๋ฏธ๋์์ ํ
์คํธ ์คํ
yarn test --watchAll
# 6006๋ฒ ํฌํธ๋ก ๋ธ๋ผ์ฐ์ ์์ ์คํ
yarn storybook
# 3000๋ฒ ํฌํธ๋ก App ์คํ
yarn start
- --watchAll ๋ช ๋ น์ด๋ webpack ๋น๋ํ ๋์๋ ๋ณธ์ ์ด ์์์ด์.
- ํ์ผ์ ๋ณ๊ฒฝ์ฌํญ์ด ์๋ค๋ฉด ๋ชจ๋ ํ ์คํธ๋ฅผ ๋ค์ ์๋ํด์.
- ๋ง์ฝ ๋ณ๊ฒฝ๋ ํ์ผ์ ๋ํด์๋ง ์ฌ์คํํ๊ณ ์ถ๋ค๋ฉด
--watch
์ต์ ์ ์ฌ์ฉํ์ธ์.
-
์๋ํ๋ ํ ์คํธ (Jest)
-
์ปดํฌ๋ํธ ๊ฐ๋ฐ (Storybook)
-
์ฑ ๊ทธ ์์ฒด (App)
๐ ์์ script ๋ช ๋ น์ด์์ ๋ณด์๋ ์ธ ์น๊ตฌ๋ค์ด ๋ชจ๋ ์๋ค์.
CDD๋ฅผ ๊ฒฝํํด๋ณธ์ ์ด ์์ด์.
Component Driven Development๋ผ ํ์ฌ, Bottom-up ๋ฐฉ์์ผ๋ก ์ปดํฌ๋ํธ๋ฅผ ๊ตฌํํ๋ ๋ฐฉ์์ด์ฃ .
storybook์ ๋
๋ฆฝ์ ์ผ๋ก ๊ฐ ์ปดํฌ๋ํธ์ UI๋ฅผ ํ์ธ
ํ ์ ์๊ธฐ ๋๋ฌธ์ CDD์ ๊ต์ฅํ ์ ํฉํ ๋ฐฉ์์ด๋ผ๊ณ ํ ์ ์์ด์.
Component Driven User Interfaces
import React from 'react';
import Task from './Task';
export default {
component: Task,
title: 'Task',
};
const Template = (args) => <Task {...args} />;
export const Default = Template.bind({});
Default.args = {
task: {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
updatedAt: new Date(2018, 0, 1, 9, 0),
},
};
export const Pinned = Template.bind({});
Pinned.args = {
task: {
...Default.args.task, // ์ด๋ฐ ์์ผ๋ก ์ฌ์ฌ์ฉํ๋ฉด ์ข์์!
state: 'TASK_PINNED',
},
};
๐ ์๋ Task ์ปดํฌ๋ํธ๋ฅผ ์ํ Task.stories.js ํ์ผ์ด์์.
component
: storybook์ ํ๊ณ ์ ํ๋ ์ปดํฌ๋ํธ
title
: storybook ์ฑ์ ์ฌ์ด๋๋ฐ์์ ์ปดํฌ๋ํธ๋ฅผ ์ฐธ์กฐํ๋ ๋ฐฉ๋ฒ
args
: storybook์ ๋ค์ ์์ํ์ง ์๊ณ ๋ Controls addon์ผ๋ก ์ปดํฌ๋ํธ๋ฅผ ์ค์๊ฐ์ผ๋ก ์์
์์ ํ์ผ์์ Default, Pinned ์ฒ๋ผ ๊ฐ๊ฐ์ storybook์ exportํ๊ณ ์์ด์. ์ ๊ทธ๋ด๊น์?
export
: ์ฐจํ ์คํ ๋ฆฌ์์ ์ด๋ฅผ ์ฌ์ฌ์ฉ ํ ์ ์๋๋ก ํด์ค๋๋ค.
๐ ์๋๋ Task๋ฅผ map ๋๋ฉด์ ๋ง์ด ์ฌ์ฉํ๋ TaskList๋ผ๋ ์ปดํฌ๋ํธ์ ๋ํ ์คํ ๋ฆฌ์์.
// TaskStories๋ฅผ ๊ฐ์ ธ์ด์ผ๋ก์จ ํธํ๊ฒ ์์ฑํ ์ ์์ฃ . (์ต๋ํ ์ฌํ์ฉ)
import * as TaskStories from './Task.stories';
export const Default = Template.bind({});
Default.args = {
// ์์ฑํด๋ story๋ฅผ ๋ค์ ๊ฐ์ ธ์์ 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' },
],
};
๐ ์ ์ฐธ๊ณ ๋ก, propTypes๋ ๋ง์ฐฌ๊ฐ์ง์ ๋๋ค.
(stories์์ ์ ์ํ๋ ๊ฒ์ ์ฐ ์ปดํฌ๋ํธ์์ ์ฌ์ฉํ ์ ์๊ตฌ๋...)
import Task from './Task';
import PropTypes from 'prop-types';
TaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired, // ์์ฃผ ํธํ๊ฒ ๊ฐ์ ธ์์ ์ฌ์ฉ!
onPinTask: PropTypes.func,
onArchiveTask: PropTypes.func,
};
UI ์ปดํฌ๋ํธ๋ฅผ ๋ ๋ฆฝ์ ์ผ๋ก ๋ง๋ค ๋, ์ปดํฌ๋ํธ์์ ์ํธ์์ฉ์ ํ์ธํ๋๋ฐ ๋์์ ์ค์.
-
์ข ์ข , ์ฑ์ ์ปจํ ์คํธ์์ ํจ์์ state์ ์ ๊ทผํ์ง ๋ชปํ ์ ์์ด์.
- ์ด๋ฐ ๊ฒฝ์ฐย
action()
์ ์ฌ์ฉํ์ฌ ๋ผ์ ๋ฃ์ด ์ฃผ์ธ์. - ์์๋ ์กฐ๊ธ ์๋ redux-store๋ฅผ ๋ง๋๋ ๊ณณ์์ ํ์ธ ๊ฐ๋ฅํด์.
- ์ด๋ฐ ๊ฒฝ์ฐย
-
actions
์ ํด๋ฆญ์ด ๋์์๋ Storybook UI์ actions ํจ๋์ ๋ํ๋ ์ฝ๋ฐฑ์ ์์ฑํ ์ ์๋๋ก ํด์ค๋๋ค. -
์๋ฅผ ๋ค์ด ๋ฒํผ์ ๋ง๋ค์์ ๋, ๋ฒํผ ํด๋ฆญ์ด ์ฑ๊ณต์ ์ด์๋์ง ํ ์คํธ UI์์ ํ์ธ ํ ์ ์์ ๊ฑฐ์์.
// in .storybook/preview.js
import '../src/index.css';
export const parameters = {
// click ๋ฑ์ ์ด๋ฒคํธ ๋ฑ์ ์ก์์ actions ํจ๋์ ๋ณด์ฌ์ค๋๋ค.
// preview์ parameters๋ ๋ฏธ๋ฆฌ ๋ชจ๋ ์ก์
์ ์ง์ ? ํด์ฃผ๋ ๋๋์ด์์.
actions: { argTypesRegex: '^on[A-Z].*' },
};
๋งค๊ฐ๋ณ์(parameters)
๋ ์ผ๋ฐ์ ์ผ๋ก Storybook์ ๊ธฐ๋ฅ๊ณผ ์ ๋์จ์ ๋์์ ์ ์ดํ๊ธฐ ์ํ์ฌ ์ฌ์ฉ๋ฉ๋๋ค.
๋ํ ์ด๋ ๊ฒ ๋ชจํน์ ์ํด ์ฌ์ฉํ ์๋ ์์ด์.
// redux store๋ฅผ ์ํ ์ด๊ฐ๋จ ๋ชจํน ์์
const store = {
getState: () => {
return {
tasks: TaskListStories.Default.args.tasks,
};
},
subscribe: () => 0,
dispatch: action('dispatch'), // dispatch๋ผ๋ ์ก์
์ด ๋ฐ์ํ๋ค~
};
๐ ์ฐ๋ฆฌ๋ ์๋ฒ๋ ํ๋ฐํธ์๋ ์ฑ ์ ์ฒด(npm run start)๋ฅผ ์คํํ์ง ์๊ณ ์ฑ๊ณต์ ์ผ๋ก ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค ์ ์๊ฒ ๋์์ด์. (storybook ๐๐)
์ผ์ผ์ด ํด๋ฆญ
ํ์ฌ ์ค๋ฅ๋ ๊ฒฝ๊ณ ์์ด ๋ ๋๋ง ๋๋์ง ์ดํด๋ด์ผ ํฉ๋๋ค. ์ด๋ฅผ ์๋ํ
ํ ์๋ ์์๊น์?
์ค๋
์ท ํ
์คํธ(Snapshot)๋ ์ฃผ์ด์ง ์
๋ ฅ
์ ๋ํด ์ปดํฌ๋ํธ์ "์ํธํ" ์ถ๋ ฅ ๊ฐ์ ๊ธฐ๋ก
ํ ๋ค์, ํฅํ ์ถ๋ ฅ ๊ฐ์ด ๋ณํ ๋๋ง๋ค ์ปดํฌ๋ํธ์ ํ๋๊ทธ๋ฅผ ์ง์
ํ๋ ๋ฐฉ์์ ๋งํฉ๋๋ค.
- ์ด๋ ์๋ก์ด ๋ฒ์ ์ ์ปดํฌ๋ํธ๋ฅผ ๋ณด๊ณ ๋ฐ๋ ๋ถ๋ถ์ ๋น ๋ฅด๊ฒ ํ์ธํ ์ ์๊ธฐ ๋๋ฌธ์ Storybook์ ๋ณด์ํด ์ค ์ ์์ต๋๋ค.
์ค๋ ์ท ํ ์คํธ ํ๋ ๋ฐฉ๋ฒ: Storyshots ์ ๋์จ(addon)์ ์ฌ์ฉํ๋ฉด ๊ฐ ์คํ ๋ฆฌ์ ๋ํ ์ค๋ ์ท์ด ์์ฑ๋ฉ๋๋ค!
- ๋ค์์ development dependencies๋ฅผ ์ถ๊ฐํด์ฃผ์ธ์.
yarn add -D @storybook/addon-storyshots react-test-renderer
- ์ค์น ํ ๋ค์ ํ์ผ ์์ฑํด์ฃผ์ธ์.
// src/storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();
- ์ค๋ ์ท ์์ฑ ์๋ฃ!
Task
์คํ ๋ฆฌ๋ฅผ ์ํ ์ค๋
์ท ํ
์คํธ๋ฅผ ๋ง๋ค์ด ๋ณด์์ต๋๋ค.
๋ง์ฝย Task
์ ๊ตฌํ์ ๋ณ๊ฒฝํ๊ฒ ๋๋ฉด, ๋ณ๊ฒฝ ์ฌํญ์ ํ์ธํ๋ผ๋ ๋ฉ์์ง๊ฐ ํ์๋ ๊ฑฐ์์.
์คํ ๋ฆฌ์ ์์์ ๋ํผ(wrapper)๋ฅผ ์ ๊ณตํ๋ ํ ๋ฐฉ๋ฒ์ ๋๋ค.
export default {
component: TaskList,
title: 'TaskList',
// ์ด๋ฐ ์์ผ๋ก ๋ํ ๊ฐ๋ฅ!
decorators: [(story) => <div style={{ padding: '3rem' }}>{story()}</div>],
};
์์ ์์์์ ๋ฐ์ฝ๋ ์ดํฐ key๋ฅผ ์ฌ์ฉํ์ฌ ๊ธฐ๋ณธ ๋ด๋ณด๋ด๊ธฐ์์ ๋ ๋๋ง ๋ ์ปดํฌ๋ํธ์ padding์ ์ถ๊ฐํ์์ด์.
๋ํ ๋ฐ์ฝ๋ ์ดํฐ๋ โprovidersโ(React context๋ฅผ ์ค์ ํ๋ ๋ผ์ด๋ธ๋ฌ๋ฆฌ ์ปดํฌ๋ํธ)์์ ์คํ ๋ฆฌ๋ฅผ ๊ฐ์ธ ์ค ๋ ์ฌ์ฉ๋ ์ ์์ต๋๋ค.
typescript์์๋ decorator๊ฐ ์กด์ฌํ๋๋ฐ ์ด์ ๋น์ทํ ๋๋์ธ ๊ฒ ๊ฐ์์. ํน์ ์์ ์ ์ํํ๊ธฐ ์ ์ ๋ฏธ๋ฆฌ ์ด๋ค ์์ ์ ํ๊ฒ ํด์ฃผ๋๋ก ํ๋ ๊ฒ์ด decorator๊ฐ ์๋๊น ์ถ์ต๋๋ค.
Storybook ์คํ ๋ฆฌ, ์๋ ํ ์คํธ, ์ค๋ ์ท ํ ์คํธ๋ UI ๋ฒ๊ทธ๋ฅผ ํผํ๋ ๋ฐ ํฐ ๋์์ด ๋ฉ๋๋ค.
import React from 'react';
import ReactDOM from 'react-dom';
import '@testing-library/jest-dom/extend-expect';
import { WithPinnedTasks } from './TaskList.stories'; // ๋ง๋ ์คํ ๋ฆฌ ๊ฐ์ ธ์ค๊ธฐ
it('renders pinned tasks at the start of the list', () => {
const div = document.createElement('div'); // div ํ๊ทธ ๋ง๋ค๊ณ
ReactDOM.render(<WithPinnedTasks {...WithPinnedTasks.args} />, div); // div ์์ ์คํ ๋ฆฌ ์ฌ์ฉํ๊ธฐ
// ํ ์ฒ๋ฆฌ๋ 6๋ฒ์งธ task๊ฐ ๊ฐ์ฅ ์์ ์๊ธฐ๋ฅผ ๊ธฐ๋ํด์.
const lastTaskInput = div.querySelector('.list-item:nth-child(1) input[value="Task 6 (pinned)"]');
expect(lastTaskInput).not.toBe(null);
ReactDOM.unmountComponentAtNode(div);
});
yarn add -D chromatic
๊ณต์ ๋ฌธ์์์๋ chromatic์ ์ฌ์ฉํด์ ๋ฐฐํฌํ์ด์.
์ ๋ ์คํ ๋ฆฌ๋ถ์ ๋ฐฐํฌํ ๋ netlify๋ฅผ ์ด์ฉํด์ ๋ฐฐํฌํ์๋๋ฐ์,
๐ค ์ ๊ณต์๋ฌธ์์์๋ ๋ฐฐํฌ๋ฅผ chromatic์ผ๋ก ํ๋์ง ๊ถ๊ธํ์ด์.
-
๋ต์ ๊ธ๋ฐฉ ๋์์ต๋๋ค.
-
์ง์ ๋ฐฐํฌํด๋ณด๋ chromatic์ push๋ ๋๋ง๋ค ์๋กญ๊ฒ build๋ฅผ ์งํํ์ฌ ui ๋ณ๊ฒฝ์ ๋ค์ ํ ๋์ ๋ณด๊ธฐ ์ฌ์ ์ด์. ๋, ์ด๋ค build version์ผ๋ก๋ ๋ฐ๋ก ๋์๊ฐ ์ ์์ด์ ์ข์์ต๋๋ค.
๋ฐฐํฌ๋ ์ ๊ฐ ์ ๋ฆฌํ ๊ฒ ์์ด์.. ๊ทธ๋ฅ ๊ณต์๋ฌธ์ ๋ฐ๋ผ๊ฐ๋ ๊ฒ์ด best๋ผ๊ณ ์๊ฐ์ด ๋ญ๋๋ค!
์๋ ํ ์คํธ
๊ฐ๋ฐ์๊ฐ ์ปดํฌ๋ํธ์ ์ ํ์ฑ์ ์๋์ผ๋ก ํ์ธํ์ฌ ๊ฒ์ฆํฉ๋๋ค. ๋น๋ ํ ๋ ์ปดํฌ๋ํธ์ ๋ชจ์ต์ด ์จ์ ํ์ง ์ ๊ฒํ๋๋ฐ ๋์์ด ๋ฉ๋๋ค.
์ค๋ ์ท ํ ์คํธ
Storyshots์ ์ฌ์ฉํ์ฌ ์ปดํฌ๋ํธ๊ฐ ๋ ๋๋ง ๋ ๋งํฌ์ ์ ์บก์ฒํฉ๋๋ค. ๋ ๋๋ง ์ค๋ฅ์ ๊ฒฝ๊ณ ๋ฅผ ์ ๋ฐํ๋ ๋งํฌ์ ์ ๋ณ๊ฒฝ์ฌํญ์ ํ์ ํ๋๋ฐ ๋์์ ์ค๋๋ค.
๋จ์ ํ ์คํธ
Jest๋ฅผ ์ฌ์ฉํ์ฌ ๊ณ ์ ๋ ์ ๋ ฅ๊ฐ์ ์ฃผ์์ ๋ ์ปดํฌ๋ํธ์ ์ถ๋ ฅ ๊ฐ์ด ๋์ผํ๊ฒ ์ ์ง๋๋์ง๋ฅผ ํ์ธํฉ๋๋ค. ์ปดํฌ๋ํธ์ ๊ธฐ๋ฅ์ ํ์ง์ ํ ์คํธํ๋๋ฐ ์ ์ฉํฉ๋๋ค.
์๊ฐ์ ํ๊ท ํ
์คํธ(Visual regression test)
๋ ์ธ๊ด์์ ๋ณํ๋ฅผ ํฌ์ฐฉํ๋๋ก ์ค๊ณ๋์์ด์. (์ด๊ฑฐ ์ฐํ
์ธ์์๋ ๋ณธ ๊ธฐ์ต์ด ์๋๋ฐ..!!)
์๋ก ๋ ๋๋ง ๋ UI ์ฝ๋์ ์ด๋ฏธ์ง์ ๊ธฐ์ค ์ด๋ฏธ์ง๋ฅผ ๋น๊ตํ๋ ๊ฒ์ ์์กดํฉ๋๋ค. ๋ง์ผ UI ๋ณ๊ฒฝ์ด ์๋ค๋ฉด ์ฐ๋ฆฌ๋ ๊ทธ์ ๋ํ ์๋ฆผ์ ๋ฐ์ ์ ์์ด์.
๋ฐฐํฌํ์์ฃ ? ๋ฐฐํฌ๋ chromatic ํ์ด์ง๋ก ๊ฐ๋ด ์๋ค. ์ง์! ๋ณ๊ฒฝ์ ๋ค์ ๋ฐ๋ก ๋ณผ ์ ์์ด์.
์ ๋ฐ์ดํธ๋ก ์ธํด ์ค์๋ก ๋ฒ๊ทธ๊ฐ ๋ฐ์ํ์ง ์์ ๊ฒ์ด๋ผ๋ ๊ฒํ ๊ฐ ๋๋๋ฉด UI ๋ณ๊ฒฝ ์ฌํญ์ ์์ ์๊ฒ ๋ณํฉ(merge) ํ ์ค๋น๊ฐ ๋ ๊ฒ์ ๋๋ค. ์๋ก์ดย ๋ณ๊ฒฝ ์ฌํญ์ด ๋ง์์ ๋์ ๋ค๋ฉด ๋ณ๊ฒฝ์ ์๋ฝํด์ฃผ์๊ณ , ์๋๋ผ๋ฉด ์ด์ ์ํ๋ก ๋๋๋ ค์ฃผ์ธ์.
์ ๋์จ ๋ชฉ๋ก๋ค์ธ๋ฐ์, ์ฐธ ๋ง์ฃ ?
Addons ์ค์์ ๊ฐ์ฅ ์์ฃผ ์ฐ์ด๋ Controls์ ๋ํด์ ์์๋ด ์๋ค!
-
Controls๋ ๋์์ด๋์ ๊ฐ๋ฐ์๊ฐ ์ปดํฌ๋ํธ์ ์ ๋ฌ๋๋ ๊ฐ๋ฅผย ๋ฐ๊พธ์ด๋ณด๋ฉฐย ์ฝ๊ฒ ์ปดํฌ๋ํธ์ ํ๋์ ์ดํด๋ณผ ์ ์๊ฒ ํด์ค๋๋ค.
-
๊ฒ๋ค๊ฐ.. ์ฝ๋๊ฐ ํ์ํ์ง ์์ต๋๋ค.****
๐ค ๊ทธ๋๋ ์ ์ฐ๋์ง ์๋ฟ์ง๊ฐ ์์ฃ ?
๋น ๋ฅด๊ฒ ์ฃ์ง ์ผ์ด์ค๋ฅผ ์ฌํํ๊ณ ์์ ํ ์ ์์ต๋๋ค.
Task
์ปดํฌ๋ํธ์ย ๋๋์ ๋ฌธ์์ด์ ์ถ๊ฐํ๋ค๋ฉด ์ด๋ค ์ผ์ด ๋ฒ์ด์ง๊น์?
- ์ด... ๋ง์ค์ํ๊ฐ ๋์์ผํ ๊ฒ ๊ฐ์๋ฐ ๊ทธ๋ฅ ์งค๋ ค๋ฒ๋ฆฌ๋ค์ ใ ใ
Controls๋ ์ปดํฌ๋ํธ์ ์ฌ๋ฌ๊ฐ์ง ์ ๋ ฅ์ ๋น ๋ฅด๊ฒ ์๋ํด๋ณผ ์ ์๋๋ก ํด์ค๋๋ค.
์์ ๊ธด ๋ฌธ์์ด๊ณผ ๊ฐ์ ๊ฒฝ์ฐ๋ ์ฃ์ง์ผ์ด์ค๊ฒ ์ฃ . ์ด๋ UI ๋ฌธ์ ์ ๋ค์ ๋ฐ๊ฒฌํ๋ ์ผ์ ์ค์ฌ์ค๋๋ค.
- ์ด์ ์ด๋ฌํ ์ฃ์ง ์ผ์ด์ค๋ฅผ ์ฌํํ๊ณ ์์ ํ ์ ์์ต๋๋ค.