์คํ ๋ฆฌ๋ถ ํํ ๋ฆฌ์ผ
๐งธ ์๋ก
์ด์ ์ ์งํํ๋ ํ๋ก์ ํธ๋(ํ์ฌ๋ ์งํ์ค...) CBD(Component Based Development)๋ฅผ ๊ธฐ์ค์ผ๋ก ์งํํ์๋ค. ์ปดํฌ๋ํธ๋ฅผ ํ๋ ํ๋ ํ์ธํ๊ธฐ ์ํด์, layout์ด๋ผ๋ ํด๋๋ฅผ ๋ง๋ค๊ณ ๊ทธ ๊ณณ์ ํ์ํ ์ปดํฌ๋ํธ๋ค์ ํ๋ ํ๋ ๊ทธ๋ฆฌ๋ค๋ณด๋ ์ปดํฌ๋ํธ ๊ฐ๋ฐ์ด๋ ๊ทธ๋ฆฌ๋๋ฐ ์์ด UI์ ์ปดํฌ๋ํธ์ ๋ถ๋ฆฌ๊ฐ ํ์ํ๋ค๋ ์๊ฐ์ด ๋ค์๋ค.
Storybook is an open source tool for building UI components and pages in isolation. It streamlines UI development, testing, and documentation.
์ด๋ฅผ ๊ฐ์ ํ๊ธฐ ์ํด์ ์คํ ๋ฆฌ๋ถ์ ๊ณต๋ถํ๊ณ ์ ์ฉํ๊ธฐ๋ก ํ๋ค.
๐ฅ์ค์น
์คํ ๋ฆฌ๋ถ ์ค์น๋ฅผ ํ๊ธฐ ์ํด์๋ ๋ค์๊ณผ ๊ฐ์ ์ปค๋งจ๋๋ฅผ ์ฌ์ฉํด์ผํ๋ค.
npx sb init
์ด ์ปค๋งจ๋๊ฐ ๋๋๊ณ ๋๋ฉด, package.json scripts์ ๋ค์๊ณผ ๊ฐ์ script๊ฐ ์ถ๊ฐ๋๋ค.
"scripts": {
...
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
...
},
์ฐ๋ฆฌ๋ ์คํ ๋ฆฌ๋ถ์ ์คํํ๊ธฐ ์ํด์ package manager ์คํ ๋ช ๋ น์ด์ storybook์ ์ฌ์ฉํด์ผํ๋ค(์์ yarn storybook). inquirer๋ฅผ ๋ฐ๋ผ์ localhost:6006(storybook default ์ฃผ์)๋ก ์ด๋ํ๋ฉด ์คํ ๋ฆฌ๋ถ์ ์์ ๊ฐ ์๋ ํ์ผ๋ค์ ๋ณผ ์ ์๋ค.
โณ๏ธ ์ถ๊ฐ ์ค์
์ง๊ธ ์คํ ๋ฆฌ๋ถ์ ์ ์ฉํ ํ๋ก์ ํธ๋ Styled-components์ด๊ธฐ ๋๋ฌธ์ ์ถ๊ฐ์ ์ธ ์ค์ ์ด ํ์ํ๋ค. Styled-components๋ฅผ ์ ์ฉํ๊ธฐ ์ํด์๋ preview.ts(ํน์ preview.js)ํ์ผ์ preview.tsx(preview.js ๋ผ๋ฉด jsx)๋ก ์์ ํด์ผํ๋ค.
ํ์ผ ์ด๋ฆ์ ์์ ํ์๋ค๋ฉด preview.tsx์ ThemeProvider๋ฅผ ์ ์ฉํ๋ค. ์ํ ์ฝ๋๋ https://storybook.js.org/docs/react/writing-stories/decorators ์ด ์ฃผ์์์ ๋ฐ์ ์ ์๋ค.
import React from 'react';
import { Preview } from '@storybook/react';
import { ThemeProvider } from 'styled-components';
const preview: Preview = {
decorators: [
(Story) => (
<ThemeProvider theme="default">
{/* ๐ Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it */}
<Story />
</ThemeProvider>
),
],
};
export default preview;
๋ณธ์ธ์ด ์ ์ฉํ ํ ๋ง๊ฐ ์๋ค๋ฉด ์์ผ ์ฝ๋์์ theme={theme} ๊ณผ ๊ฐ์ด ๋ถ๋ฌ์ฌ ์ ์๋ค.
import React from 'react';
import { Preview } from '@storybook/react';
import { ThemeProvider } from 'styled-components';
import { theme} from "../src/constants";
const preview: Preview = {
decorators: [
(Story) => (
<ThemeProvider theme={theme}>
<Story />
</ThemeProvider>
),
],
};
export default preview;
(์ง๊ธ์ alias๋ฅผ ์ ์ฉํ์ง ์์๊ธฐ ๋๋ฌธ์ theme์ ์ฃผ์๋ ..์ผ๋ก ์์ฑ๋์ด์๋ค)
๐ฅ ์ปดํฌ๋ํธ ๊ฐ๋ฐํ๊ธฐ
๐คนโ๏ธ ํ์ผ ๊ตฌ์กฐ
์์ ์๋ ์ธ์คํ๊ทธ๋จ์ "์๊ฐ์ ๊ณต์ ํด๋ณด์ธ์" ๋งํ์ ์ ์คํ ๋ฆฌ๋ถ์ ์ด์ฉํ์ฌ ๋ง๋ค์ด ๋ณด์. ์ด ์ปดํฌ๋ํธ์ ์ด๋ฆ์ด MyMemo๋ผ๊ณ ํ๋ฉด, ์ปดํฌ๋ํธ์ ๊ด๋ จ๋ ํ์ผ์
- MyMemo.tsx
- MyMemo.stories.tsx
๋ ๊ฐ์ง ํ์ผ์ด ๊ธฐ๋ณธ์ผ๋ก ํ์ํ๋ค. ํ์ง๋ง, styled-components ๊ฐ์ ๊ฒฝ์ฐ ํจ์์ ์ธ ๊ธฐ๋ฅ์ semantic์ ๊ฐ์ง๊ณ ์์ง ์๊ธฐ ๋๋ฌธ์ ์ด๋ฅผ MyMemo.styles.tsxํ์ผ๊ณผ ํ์ ์ด ํ์ํ ๊ฒฝ์ฐ๋ฅผ ๊ณ ๋ คํ์ฌ MyMemo.d.ts ํ์ผ์ ์ถ๊ฐํ์๋ค.
- MyMemo.tsx
- MyMemo.stories.tsx
- MyMemo.styles.tsx
- MyMemo.d.ts
๐ฎ ์คํ ๋ฆฌ๋ถ controls
์ฐ์ ์ปดํฌ๋ํธ ๊ฐ๋ฐ์์๋ ์ํฉ์ ๋ฐ๋ผ ์ปดํฌ๋ํธ๊ฐ ์ด๋ค ์์ผ๋ก ๊ทธ๋ ค์ง๋๊ฐ๋ฅผ ๋ณด๋ ๊ฒ์ด ์ค์ํ๊ธฐ ๋๋ฌธ์ ์์ ์ปดํฌ๋ํธ๋ฅผ
- ๋ก๊ทธ์ธ์ด ๋ ์ผ์ด์ค
- ๋ก๊ทธ์ธ์ด ์๋ ์ผ์ด์ค
๋ ๊ฐ์ง ์ผ์ด์ค๋ก ๋ถ๋ฆฌ๋ฅผ ํ์ฌ ์งํ์ ํ ์์ ์ด๋ค.
๋ฆฌ์กํธ์์๋
- state๋ง๋ค ๋ค๋ฅธ ์ปดํฌ๋ํธ๋ก ์ปดํฌ๋ํธ ๊ทธ๋ฆฌ๊ธฐ
- ๋ค๋ฅธ props๋ฅผ ๋ด๋ ค์ ๊ฐ์ ์ปดํฌ๋ํธ๋ฅผ ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ๊ทธ๋ฆฌ๊ธฐ
๋ ๊ฐ์ง ๋ฐฉ์์ด ์กด์ฌํ๋ค. state ๋ง๋ค ๋ค๋ฅธ ์ปดํฌ๋ํธ๋ก ๊ทธ๋ ค์ง๋ ๋ฐฉ์์ ๋ค๋ฅธ props๋ก ๋ค๋ฅด๊ฒ ๊ทธ๋ ค์ง๋ ๋ฐฉ์์ ์ผ์ด์ค๋ฅผ ํ ๊ฐ๋ก ๊ทธ๋ฆฌ๋ฉด ๋๊ฐ๊ธฐ ๋๋ฌธ์ "๋ค๋ฅธ props๋ฅผ ๋ด๋ ค์ ๊ฐ์ ์ปดํฌ๋ํธ๋ฅผ ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ๊ทธ๋ฆฌ๋ ๋ฐฉ์"์ผ๋ก ์งํํ๊ฒ ๋ค.
Step1. ์ปดํฌ๋ํธ Props ์ ํ๊ธฐ
์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค๊ธฐ ์ํด์ ์ปดํฌ๋ํธ์ ๋ค์ด์ค๋ ํ์ ์ ๋ง๋ค์ด์ผํ๋ค. ์ง๊ธ ๋ง๋ค ์ปดํฌ๋ํธ๋ user๊ฐ undefined์ด๊ฑฐ๋ object๋ฅผ ๋ง๋ค๋๋ก ๊ตฌํ์ ํ๋ ค๊ณ ํ๋ค.
//MyMemo.d.ts
interface MyMemoProps {
user: { name: string } | undefined;
}
Step2. ์ปดํฌ๋ํธ ๋ด๋ถ์ ๋ค์ด๊ฐ ์ปดํฌ๋ํธ ๋ง๋ค๊ธฐ
์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค๊ธฐ์ ์ ์ปดํฌ๋ํธ์ child์์ ๋ค์ด๊ฐ ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค์ด์ผํ๋ค. ์ง๊ธ์ ์ฐ์ ๊ฐ๋จํ๊ฒ ๋ก๊ทธ์ธ์ ์ ๋ฌด์ ๋ฐ๋ผ ์ฝ๊ฒ ๊ตฌ๋ถ์ ํ๊ธฐ ์ํ์ฌ ๋ฐฐ๊ฒฝ์์ ๋นจ๊ฐ๊ณผ ํ๋์ผ๋ก ๊ฐ์ง๋ span์ ๋ง๋ค์๋ค.
//MyMemo.styles.tsx
const ProfileIcon = styled.span`
background-color: blue;
`;
const DefaultIcon = styled.span`
background-color: red;
`;
Step3. props์ ๋ฐ๋ฅธ ์ปดํฌ๋ํธ ๋ง๋ค๊ธฐ
์คํ ๋ฆฌ๋ถ ํ์ผ์ ๋ง๋ค๊ธฐ ์ ์ user์ ๊ฐ์ ๋ฐ๋ผ์ ๋ค๋ฅธ ๋ฐฉ์์ผ๋ก ๊ทธ๋ ค์ง๋๋ก ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ ๋ค.(์ผ์ด์ค๋ง๋ค ๋๋์ด์ง๊ฒ ๋จผ์ ๋ง๋ค๊ณ ์ปดํฌ๋ํธ์ ๋ํด์ ๋ฅํ๊ฒ ์์ฑํ๋๋กํ๋ค)
export function MyMemo(props: MyMemoProps) {
const { user } = props;
return <>{user ? <ProfileIcon>์์ด์ฝ</ProfileIcon> : <DefaultIcon >์์ด์ฝ</DefaultIcon >}</>;
}
Step4. Storybook ํ์ผ ๋ง๋ค๊ธฐ
4.1 ๊ธฐ๋ณธ ์ปดํฌ๋ํธ ์ค์ ํ๊ธฐ
์คํ ๋ฆฌ๋ถ์์ ๋ง๋ ์ปดํฌ๋ํธ๋ฅผ ๋ณด๊ธฐ ์ํ์ฌ [์ปดํฌ๋ํธ์ด๋ฆ].stories.tsx์ ๋ค์๊ณผ ๊ฐ์ด ์ปดํฌ๋ํธ์, ์คํ ๋ฆฌ๋ถ์์ ์ฌ์ฉํ ์ต์ ๋ค์ ๋ฃ๋๋ค.
์ถ๊ฐ์ค๋ช :
- title: ์คํ ๋ฆฌ๋ถ์์ ์ปดํฌ๋ํธ๋ฅผ ๊ตฌ๋ถํ๋ ์ด๋ฆ
- component: title๋ก ๋ถ๋ฅํ ์คํ ๋ฆฌ๋ถ ํ์ด์ง์์ ์ฌ์ฉํ ์ปดํฌ๋ํธ ์ด๋ฆ
- tags: ["autodocs"] ์คํ ๋ฆฌ๋ถ์์ ์ฌ๋ฌ ์ผ์ด์ค์ ์ฌ์ฉ๋ฐฉ๋ฒ์ ์ค๋ช ํด์ฃผ๋ ๋ฌธ์๋ฅผ ์๋์ผ๋ก ๋ง๋ค์ด ์ค์ง์ ๊ดํ ์ต์
- parameters: { layout: "fullscreen" | "centered"}: ์คํ ๋ฆฌ๋ถ ํ์ด์ง์ ํ๋ฉด์ ๊ด๋ จ๋ ์ค์
const meta: Meta<typeof MyMemo> = {
title: "MyMemo component",
component: MyMemo,
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
tags: ["autodocs"],
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
layout: "fullscreen",
},
};
export default meta;
4.2 ์คํ ๋ฆฌ๋ถ ์ผ์ด์ค ๋ง๋ค๊ธฐ
export const LoggedIn: UserData = {
args: {
user: {
name: "Mesher",
},
},
};
export const LoggedOut: UserData = { args: { user: undefined } };
์ ์ฒด ์ฝ๋
import type { Meta, StoryObj } from "@storybook/react";
import { MyMemo } from "./MyMemo";
type UserData = StoryObj<typeof MyMemo>;
const meta: Meta<typeof MyMemo> = {
title: "MyMemo component",
component: MyMemo,
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/react/writing-docs/autodocs
tags: ["autodocs"],
parameters: {
// More on how to position stories at: https://storybook.js.org/docs/react/configure/story-layout
layout: "fullscreen",
},
};
export default meta;
export const LoggedIn: UserData = {
args: {
user: {
name: "Mesher",
},
},
};
export const LoggedOut: UserData = { args: { user: undefined } };
๐คพโ๏ธ ์คํ ๋ฆฌ๋ถ Play Function
Step1. ํ๋ฌ๊ทธ์ธ ์ค์น
์คํ ๋ฆฌ๋ถ Play Function์ ์ฌ์ฉํ๊ธฐ ์ํด์๋ ์คํ ๋ฆฌ๋ถ์์ ์ ๊ณตํ๋ interaction ํ๋ฌ๊ทธ์ธ์ ์ค์นํด์ผํ๋ค.
//yarn
yarn add --dev @storybook/testing-library @storybook/jest @storybook/addon-interactions
//npm
npm install @storybook/testing-library @storybook/jest @storybook/addon-interactions --save-dev
//pnpm
pnpm add --save-dev @storybook/testing-library @storybook/jest @storybook/addon-interactions
Step2. addon ์ถ๊ฐ
.storybook/main.ts ํ์ผ์ addon props์ @storybook/addon-interactions๊ฐ ์๋์ง ํ์ธํด์ผํ๋ค.
// .storybook/main.ts
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { StorybookConfig } from '@storybook/your-framework';
const config: StorybookConfig = {
framework: '@storybook/your-framework',
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
// Other Storybook addons
'@storybook/addon-interactions', // ์ด ๋ถ๋ถ์ด ํ์๋ก ํ์ํ๋ค.
],
};
export default config;
Step3. ์ปดํฌ๋ํธ ์์ฑ
Play Function์ ์ฌ์ฉํ๊ธฐ ์ํด์ ์ปดํฌ๋ํธ๋ฅผ ์์ฑํ์. ์ฐ์ ์ด ๊ธ์ ํํ ๋ฆฌ์ผ์ด๊ธฐ ๋๋ฌธ์ ์ด๋ ค์ด ์ปดํฌ๋ํธ๋ฅผ ๋ง๋ค๊ณ ๊ทธ ์ปดํฌ๋ํธ์ ๋ํ Interaction์ ๋ง๋๋ ๊ฒ๋ณด๋ค๋ count button์ ์ด์ฉํ์ฌ ๊ฐ๋จํ Interaction์ ์์๋ณด์.
//CountButton.tsx
import { useState } from "react";
export function CountButton() {
const [count, setCount] = useState(0);
const onClickHandler = () => setCount((count) => count + 1);
return (
<>
<button onClick={onClickHandler}>{count}</button>
</>
);
}
Step4. ์คํ ๋ฆฌ๋ถ ์ผ์ด์ค ์์ฑ
//CountButton.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { CountButton } from "./CountButton";
import { fireEvent, userEvent, within } from "@storybook/testing-library";
type Story = StoryObj<typeof CountButton>;
const meta: Meta<typeof CountButton> = {
title: "CountButton component",
component: CountButton,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
};
export default meta;
export const CountButtonExample = { args: {} };
export const ClickOnceExample: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button"));
},
};
๋ค์๊ณผ ๊ฐ์ ์์ ์์๋ CountButtonExample์ ์ด์ฉํ์ฌ ์ปดํฌ๋ํธ๋ฅผ ์ฒดํํด ๋ณผ ์ ์๊ณ , ClickOnceExample์ ์ด์ฉํ์ฌ ํ๋ฒ ํด๋ฆญ์ด ๋์์ ๋๋ฅผ ํ์ธํ ์ ์๋ค. ์์ ์์ ๋ฅผ ์ด์ฉํ๋ฉด
export const ClickExample: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function clicked() {
await fireEvent.click(canvas.getByRole("button"));
await sleep(50);
}
await Array(1000).fill(0).reduce(async (prev, current) => {
const previousPromise = await prev.then();
await func(current);
return Promise.resolve(previousPromise)
await clicked();
}, Promise.resolve());
},
};
ํด๋ฆญ์ 100๋ฒ์ ํ๊ธฐ ํ๋ ๊ฒฝ์ฐ ์ด๋ฅผ ๋์ ํด์ค ์ ์๋ UIํ ์คํธ์ ๊ฒฐ๊ณผ๋ฅผ ๋ณด์ฌ์ค ์์๋ ์คํ ๋ฆฌ๋ถํ์ด์ง๋ฅผ ๋ง๋ค ์ ์๋ค.
์ฌ๊ธฐ์ ํ ๊ฐ์ง ์ฃผ์ ํ ๊ฒ์ด ์๋ค๋ฉด, async์ await๋ forEach, map์ ์ฌ์ฉํด๋ ์๋ฌด๋ฐ ํจ๊ณผ๊ฐ ์๋ค๋ ๊ฒ์ ๊ธฐ์ตํ์. ๋ง์ผ ๊ฐ์ ์ด๋ฒคํธ๋ฅผ ํน์ ์ํํ๋ฉด์ ํจ์๋ฅผ ์ฌ์ฉํ๊ณ ์ถ๋ค๋ฉด reduce์ reduce ๋ด๋ถ์ ์๋ ๋์ ๋ ๊ฐ์ await์์ผ์ ํจ์๊ฐ ํ๋๊ฐ ๋๋ ๋๋ง๋ค ๋ค์ ํจ์๋ฅผ ์ ์ฉํ๋ ๊ฒ์ด ์ข๋ค.
๐ ๋ง๋ฌด๋ฆฌ
์ด ๊ธ์์๋ ์คํ ๋ฆฌ๋ถ์ ์ด์ฉํ์ฌ ๋๊ฐ์ง๋ฅผ ์์๋ณด์๋ค.
1. ์คํ ๋ฆฌ๋ถ์ ์ด์ฉํ์ฌ Props์ ๋ฐ๋ผ ์ด๋ค ๋ฐฉ์์ผ๋ก ๊ทธ๋ ค์ง๋์ง
2. ์คํ ๋ฆฌ๋ถ์ ์ด์ฉํ์ฌ ์ด๋ฒคํธ์ UIํ ์คํธ ๊ฒฐ๊ณผ๋ฅผ ๋ณด๊ธฐ
ํ ๋ฒ ์ ํด์ง ์ด๋ฒคํธ์ ์๋ฌ์ผ์ด์ค์ ๋ํด์๋ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ๋ก ๋ณด๊ฑฐ๋ ์ด๋ค ์์ผ๋ก ๊ทธ๋ ค์ง๋์ง ์ ์ ์๊ธฐ ๋๋ฌธ์ ์คํ ๋ฆฌ๋ถ์ ์ด์ฉํ๋ฉด QA์๊ฐ์ ๋จ์ถ์์ผ์ค ์ ์์ ๊ฒ ๊ฐ๋ค. ์ค๋์ ํด๋ฆญ ์ด๋ฒคํธ๋ง ์ฌ๋ ค๋์์ง๋ง ์๊ฐ์ด ๋๋๋๋ก ๋ค๋ฅธ ์ด๋ฒคํธ์ ๊ด๋ จ๋ ๊ธ์ ์ถ๊ฐ๋ก ์์ฑํ๊ฑฐ๋ ์ด ๊ธ์ ์์ ํด์ ๋ฃ์ด์ผ๊ฒ ๋ค.
๋์ค์๋ ์คํ ๋ฆฌ๋ถ์ ์ด๋ฒคํธ๋ฅผ ์๋ค๋ฃฐ ์ ์๋ค๋ฉด ๋ค์๊ณผ ๊ฐ์ด ์์ ํ ์ ์์ง ์์๊น...?
์ถ์ฒ(https://storybook.js.org/docs/react/writing-tests/interaction-testing)