스토리북
JeongSeulho
2024년 01월 19일
0. 들어가며
Storybook
의 정의와 사용법에 대해 정리
1. 스토리북 시작하기
스토리북은 비즈니스 로직 및 컨텍스트(상태, props)의 간섭 없이 컴포넌트를 렌더링하여 UI를 확인할 수 있는 도구이다.
스토리북의 스토리를 대상으로 스냅샷을 찍어 시각적 회귀 테스트도 진행할 수 있다.
(1) 스토리북 설정
스토리북 실행시 필요한 설정은 .storybook
폴더에 작성한다.
- 필요한 addon을 비롯한 설정 파일
// .storybook/main.js
/** @type { import('@storybook/react-vite').StorybookConfig } */
const config = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
// form 요소 값 입력, 버튼 클릭 등의 이벤트를 스토리에서 시뮬레이션 할 수 있도록 설정
'@storybook/addon-interactions',
'storybook-addon-react-router-v6',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
docs: {
autodocs: 'tag',
},
};
export default config;
- 스토리북에서 사용할 폰트 및 스타일
- 기존의 프로젝트에서 구글 폰트를 사용한다면 이 파일에서 구글 폰트를 불러와 스토리북에서도 사용할 수 있도록 설정한다.
<!-- .storybook/preview-head.html -->
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
- 스토리 렌더링 시 전역으로 적용할 공통 설정
// .storybook/preview.js
/** @type { import('@storybook/react').Preview } */
import { withRouter } from 'storybook-addon-react-router-v6';
import { CssBaseline } from '@mui/material';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { initialize, mswDecorator } from 'msw-storybook-addon';
import { handlers } from '../src/__mocks__/handlers';
import withRHF from './withRHF';
import 'swiper/css';
import 'swiper/css/navigation';
const queryClient = new QueryClient();
initialize({
onUnhandledRequest: 'bypass',
});
const preview = {
parameters: {
// 이벤트 핸들러 시에 받은 데이터를 스토리북에서 확인할 수 있도록 설정
actions: { argTypesRegex: '^on[A-Z].*' },
// 코드 변경 없이 props 또는 인자를 바꿔가며 스토리를 확인할 수 있도록 설정
controls: {
expanded: true,
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
// 스토리 렌더링 시 API 호출을 가로채서 가짜 응답을 반환하도록 내가 만든 handlers를 msw에 등록
msw: {
handlers,
},
},
// 스토리 렌더링 시 특정 컴포넌트를 감싸는 데코레이터
decorators: [
// React Router를 사용하기 위한 HOC
withRouter,
// API 호출을 가로채서 가짜 응답을 반환하도록 msw 사용하겠다는 데코레이터
mswDecorator,
// React Hook Form을 사용하기 위한 HOC
withRHF(false),
Story => (
<QueryClientProvider client={queryClient}>
<CssBaseline />
<Story />
</QueryClientProvider>
),
],
};
export default preview;
- 스토리북에서
React Hook Form
을 사용하기 위한HOC
선언
// .storybook/withRHF.jsx
import { action } from '@storybook/addon-actions';
import { FormProvider, useForm } from 'react-hook-form';
const StorybookFormProvider = ({ children }) => {
const methods = useForm();
return (
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(action('[React Hooks Form] Submit'))}
>
{children}
</form>
</FormProvider>
);
};
export default showSubmitButton => Story => (
<StorybookFormProvider>
<Story />
{showSubmitButton && <button type="submit">Submit</button>}
</StorybookFormProvider>
);
(2) 스토리 작성
스토리북에서 렌더링한 스토리를 따로 작성해야 한다.
- 메타데이터 :
export default
로 기본적인 컴포넌트 및 컨트롤러, 데코레이터 등을 설정한다. - 스토리 :
export const
로 여러 경우의 수의 스토리를 작성할 수 있다.
import ErrorPage from '@/pages/error/components/ErrorPage';
export default {
// title은 고유한 값이어야 하며, 스토리북의 사이드바에서 스토리를 그룹화하는데 사용된다.
title: '에러 페이지/기본 에러 페이지',
component: ErrorPage,
};
export const Default = {
name: '기본 에러 페이지',
};
(3) 컨트롤러 및 인자를 포함한 스토리 작성
// home/components/stories/ProductCard.stories.jsx
import product from '@/__mocks__/response/product.json';
import ProductCard from '@/pages/home/components/ProductCard';
export default {
title: '홈/상품 카드',
component: ProductCard,
argTypes: {
product: {
control: 'object',
description: '상품의 정보',
},
},
};
export const Default = {
name: '기본',
args: {
product,
},
};
export const LongTitle = {
args: {
product: {
...product,
title:
'Long title Example Long title Example Long title Example Long title Example Long title Example Long title Example Long title Example Long title Example Long title Example Long title Example',
},
},
name: '타이틀이 긴 경우',
};
export const LongCategoryName = {
args: {
product: {
...product,
category: {
name: 'Long Category Long Category Long Category Long Category',
},
},
},
name: '카테고리가 긴 경우',
};
// product.json
// story의 args로 넘겨줄 데이터
{
"id": 6,
"title": "Handmade Cotton Fish",
"price": 809,
"description": "The slim & simple Maple Gaming Keyboard from Dev Byte comes with a sleek body and 7- Color RGB LED Back-lighting for smart functionality",
"images": [
"https://user-images.githubusercontent.com/35371660/230712070-afa23da8-1bda-4cc4-9a59-50a263ee629f.png",
"https://user-images.githubusercontent.com/35371660/230711992-01a1a621-cb3d-44a7-b499-20e8d0e1a4bc.png",
"https://user-images.githubusercontent.com/35371660/230712056-2c468ef4-45c9-4bad-b379-a9a19d9b79a9.png"
],
"creationAt": "2023-04-07T20:39:06.000Z",
"updatedAt": "2023-04-07T20:39:06.000Z",
"category": {
"id": 1,
"name": "category1",
"creationAt": "2023-04-07T20:39:06.000Z",
"updatedAt": "2023-04-07T20:39:06.000Z"
}
}
(4) play 함수 사용하기
play
메소드를 사용하면 UI와의 상호작용을 미리 정의해두고 스토리북에서 간단한 조작으로 상호작용을 확인할 수 있다.
import { within, userEvent } from '@storybook/testing-library';
import PriceRange from '@/pages/home/components/PriceRange';
export default {
title: '홈/상품 필터/가격 검색',
component: PriceRange,
};
export const Default = { name: '기본' };
export const WithValue = {
name: '가격이 입력된 상태',
// canvasElement : 스토리가 렌더링되는 부분의 루트 엘리먼트
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const [min, max] = canvas.getAllByRole('textbox');
// min 텍스트 박스에 300 입력
await userEvent.type(min, '300');
// max 텍스트 박스에 40000 입력
await userEvent.type(max, '40000');
},
};
2. 스토리 작성 대상
스토리는 props
를 받아 UI만 렌더링하는 컴포넌트에 대해 작성한다, 렌더링 결과를 빠르게 확인하고 props
에 따라 다른 결과를 쉽게 확인할 수 있기 때문이다.
또한, 복잡한 비즈니스 로직이 있는 경우 UI 확인을 위한 과한 모킹이 필요하므로 지양한다.
비즈니스 로직이 모여있는 컴포넌트는 하위의 UI만 렌더링하는 컴포넌트로 상세화하여 스토리를 작성한다. 스토리와 통합 테스트의 대상을 적절하게 나누기 위해서 비즈니스 로직 검증을 위한 컴포넌트와 UI 검증을 위한 컴포넌트가 잘 분리되어야 한다.
3. 마치며
Custom Hook
패턴을 통한 뷰와 로직 분리만 알고 있었는데, Storybook
과 통합 테스트를 위한 분리로 상위 컴포넌트에서 로직을 처리하고 결과를 하위 컴포넌트로 전달하는 방식으로 분리하면, 상위 컴포넌트의 로직을 통합 테스트로 검증하고 하위 컴포넌트의 UI를 Storybook
으로 검증할 수 있을 것 같다.