> For the complete documentation index, see [llms.txt](https://taewoongs-organization.gitbook.io/jtwjs-dev-wiki/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://taewoongs-organization.gitbook.io/jtwjs-dev-wiki/dev_note/testing/undefined-6/undefined-4.md).

# 스토리북

## 스토리북

* 디자이너나 프로젝트 리더와 구현된 UI 컴포넌트의 공유가 가능해지면 협업시 능률이 높아진다.
* UI 컴포넌트 탐색기는 구현된 UI 컴포넌트를 쉽게 공유할 수 있도록 도와주는 협업 도구
* 스토리북의 UI 컴포넌트 테스트는 통합 테스트, E2E 테스트 중간에 위치한 테스트
  * 스토리북은 UI 컴포넌트 탐색기지만 테스트 기능도 강화했음

### 설치

```bash
npx storybook init
> webpack 5 //or vite 번들러 설정
> y //샘플 코드 설치할지
> react // 어떤 라이브러리의 샘플 코드를 선택할지
> npm run storybook
```

### 스토리

* 스토리 최신 포맷 `CSF3.0`
* CSF3.0은 객체를 개별적으로 export 하여 스토리 등록함

```typescript
import { Button } from './Button';

export default {
  title: "Example/Button",
  component: Button,
}
// 다른 이름으로 export 하게 되면 다른 스토리로 등록됨
export const Default = {
  args: {
    label: "Button",
  },
}
```

* 모든 스토리에는 `Global`, `Component`, `Story` 라는 세 단계의 설정이 깊은 병합 방식으로 적용됨
  * Global < Component < Story 순으로 설정의 우선순위가 높아진다.
* 공통으로 적용할 항목을 적절한 스코프에 설정하여 스토리마다 개별적으로 설정해야하는 작업을 최소화
* **Global 단계**: 모든 스토리에 적용할 설정 (.storybook/preview\.ts)
* **Component 단계**: 스토리 파일에 적용할 설정 (export default)
* **Story 단계**: 개별 스토리에 적용할 설정 (export const)

### 에드온

* 에드온으로 필요한 기능을 추가할 수 있음
* 스토리북을 설치할 때 기본적으로 추가되는 `@storybook/addon-essentials` 은 필수 에드온

#### Controls를 활용한 디버깅

* Props에 전달된 값에 따라 컴포넌트는 다른 스타일과 기능을 제공한다.
* 스토리북은 Props를 변경해 컴포넌트가 어떻게 표시되는지 실시간으로 디버깅 가능
  * 이를 **`Controls`**&#xB77C;고 한다.
* `@Storybook/addon-controls`  라는 에드온,
  * `@storybook/addon-essentials` 에 포함되어있음

#### Actions를 활용한 이벤트 핸들러 검증

* 이벤트 핸들러가 어떻게 호출됐는지 로그를 출력하는 기능
* `@storybook/addon-actions`&#x20;
  * 마찬가지로 `@storybook/addon-essentials` 에 포함되어있음
* Global 단계 설정인 `.storybook/preview.js` 를 보면 `argTypesRegex: "^on[A-Z].*"` 라는 설정이 `on` 으로 시작하는 모든 이벤트 핸들러는 자동적으로 `Actions` 패널에 로그를 출력하게 된다.
  * <mark style="color:orange;">⚠️</mark> 만약 프로젝트에 이벤트 핸들러의 이름으로 사용하는 다른 네이밍 컨벤션이 있다면 해당 컨벤션에 따라 정규표현식을 수정해야 한다.

```typescript
export const parameters = {
  actions: { argTypesRegex: "^on[A-Z].*" },
}
```

#### 반응형 대응을 위한 뷰포트 설정

* 반응형으로 구현된 UI 컴포넌트는 화면 크기별로 스토리를 등록할 수 있다.
* `@storybook/addon-viewport` 에서 지원
* SP(스마트폰) 레이아웃으로 스토리를 등록하려면 `parameters.viewport` 를 설정해야 한다.

```typescript
import { INITIAL_VIEWPORTS } from '@storybook/addon-viewport';

export const SPStory = {
  parameters: {
    viewport: {
      viewports: INITIAL_VIEWPORTS,
      defaultViewport: "iphone6"
    },
    screenshot: {
      viewport: {
        width: 375,
        height: 667,
        deviceScaleFactor: 1,
      },
      fullPage: false,
    }
  }
}
```

```typescript
// SPStory: SP 레이아웃용 커스텀 공통 설정 모듈
import { SPStory } from '@/tests/storybook';

export const SPLoggedIn: Story = {
  parameters: {
    ...SPStory.parameters,
  }
}
```

### Context API에 의존하는 스토리 등록

* 리액트의 `Context API` 에 의존하는 스토리에는 스토리북의 **데코레이터(decorator)** 를 활용
* 초깃값을 주입할 수 있도록 `Provider`를 만들면 `Context` 의 상태에 의존하는 UI를 간단하게 재현 가능

#### 스토리북의 데코레이터

* 데코레이터란 스토리의 렌더링 함수에 적용할 래퍼(wrapper)

```typescript
import { ChildComponent } from "./";

export default { // 컴포넌트 단계의 설정
  title: "ChildComponent",
  component: ChildComponent,
  decorators: [
    (Story) => (
      <div style={{ padding: "60px" }}> // 해당 파일의 스토리들이 모두 적용됨
        <Story /> // 각 스토리가 전개됨
      </div>
    )
  ]
}
```

* 로그인한 사용자 정보가 있는 `Provider(LoginUserInfoProvider)` 를 데코레이터가 소유했다면 `Context`의 `Provider`에 의존하는 UI 컴포넌ㅌ의 스토리에서도 로그인한 사용자의 정보를 표시할 수 있다.

```typescript
import { LoginUserInfoProvider } from "@/components/providers/LoginUserInfo"
import { Args, PartialStoryFn } from '@storybook/csf';
import { ReactFramework } from '@storybook/react';

export const LoginUserInfoProviderDecorator = (
  Story: PartialStoryFn<ReactFramework, Args>
) => (
  <LoginUserInfoProvider>
    <Story /> // 스토리가 Context를 통해 LoginUserInfo를 참조
  </LoginUserInfoProvider>
)
```

* 유사하게 공통 레이아웃을 제공할 데코레이터를 만들면 상황에 따라 사용할 수 있다.
* 앱에서 필요한 `Provider` 라면 실제 구현 코드와 똑같이 사용해도 상관없지만,  이 밖에는 스토리북 전용 Provider를 데코레이터로 만드는것이 좋다.

```typescript
import { BasicLayout } from "@/components/layouts/BasicLayout"
import { Args, PartialStoryFn } from '@storybook/csf';
import { ReactFramework } from '@storybook/react';

export const BasicLayoutDecorator = (
  Story: PartialStoryFn<ReactFramework, Args>
) => BasicLayout(<Story />)
```

#### 데코레이터 고차함수

* 데코레이터를 만드는 함수(고차 함수)를 작성하면 쉽게 데코레이터 작성 가능
* example
  * `Provider Context` 는 `{ message, style }` 라는 상태를 소유하는데, 이 상태를 통해 정보를 통지
  * 각 스토리는 `createDecorator` 라는 고차 함수를 사용해 설정을 최소화

```typescript
// 사용 예시
export const Succeed: Story = {
  decorators: [createDecorator({ message: "성공했습니다.", style: "succeed" })],
};

export const Failed: Story = {
  decorators: [createDecorator({ message: "실패했습니다.", style: "failled" })],
}

export const Busy: Story = {
  decorators: [createDecorator({ message: "통신 중.", style: "busy" })],
}
```

* `<ToastProvider>` 가 제공하는 정보를 `<Toast>` 컴포너트가 표시
* 이와 같이 고차함수를 만들면 초기값(`defaultState`)를 쉽게 주입 가능

```typescript
import { ToastProvider, ToastState } from '@/components/providers/ToastProvider';
import { ComponentMeta, ComponentStoryObj } from '@storybook/react';
import { Toast } from '.';

function createDecorator(defaultState?: Partial<ToastState>) {
  return function Decorator() {
    return (
      <ToastProvider defaultState={{ ...defaultState, isShown: true }}>
        {null}
      </ToastProvider>
    )
  }
}
```

### 웹 API에 의존하는 스토리 등록

* 웹 API에 의존하는 컴포넌트는 스토리에도 웹 API가 필요함
  * 컴포넌트를 렌더링하려면 웹 API 서버를 실행하고 있어야 한다.
* 스토리북을 빌드해서 정적 사이트로 호스팅할 대도 서버 상태가 좋지 않다면 API 요청이 원활히 이뤄지지 않게됨
* 이와 같은 UI 컴포넌트는 **`MSW`**&#xB97C; 사용해야 한다.

#### 에드온 설정

```bash
npm i msw msw-storybook-addon --save-dev
```

* 스토리북에서 MSW를 사용하려면 `msw`와 `msw-storybook-addon` 을 설치해야 한다.
* `.storybook/preview.js` 에서 `initialize` 함수를 실행해 MSW를 활성화
  * `mswDecorator` 는 모든 스토리에 필요하므로 글로벌 단계에서 설정

```typescript
// .storybook/preview.js
import { initialize, mswDecorator } from 'msw-storybook-addon';

export const decorators = [mswDecorator];

initialize();
```

* 프로젝트에 처음 MSW를 설치한다면 `public/` 경로를 다음의 커맨드로 실행
  * (`<PUBLIC_DIR>` 을 프로젝트의 public 디렉토리 명으로 변경)
  * 커맨드를 실행하면 `mockServiceWorkjer.js` 가 생성되며 커밋해야함

```bash
npx msw init <PUBLIC_DIR>
```

* 스토리북에도 `public/` 경로를 명시

```typescript
// .storybook/main.js
module.exports = {
  //...
  staticdirs: ["../public"],
}
```

#### 요청 핸들러 변경

* 다른 `parameters` 와 동일하게 Global, Component, Story 설정을 통해 스토리에 사용할 요청 핸들러가 결정됨
* `.storybook/preview.js` 에 필요한 설정을추가
  * 예를 들어 모든 스토리에 로그인한 사용자 정보가 필요하다면 로그인한 사용자 정보를 반환하는 `MSW` 핸들러를 Global 단계에 설정하는 것이 좋다.&#x20;

```typescript
export const parameters = {
  // 기타 설정 생략
  msw: {
    handlers: [
      rest.get("/api/my/profile", async (_, res, ctx) => {
        return res(
          ctx.status(200),
          ctx.json({
            id: 1,
            name: "EonsuBae",
            bio: "..."
            likeCount: 1,
            //...
          })
        )
      }
    ]
  }
}
```

* 요청 핸들러가 스토리에 적용되는 우선순위는 Story > Component > Global
* 요청 핸들러는 스토리마다 독립적으로 설정할 수 있기 때문에 같은 컴포넌트에서 웹 API 응답에 따라 다른 내용 표시 쌉가능
* 오류 응답 상태 코드에 따라 다른 내용이 표시되는 상황도 검증 가능

#### 고차 함수로 요청 핸들러 리팩터링

* 웹 API에서 URL과 응답 내용은 불가분의 관계
* 스토리나 테스트에 URL을 하드코딩하면 URL 사양이 변경돼도 변경된 사양이 반영되지 않게됨
* 이를 피하려면 웹 API 클라이언트 설정에 있는 고차 함수를 통해 만든 요청 핸들러에 URL을 정의해야함
  * `handleGetMyProfile` 는 따로 정의한 msw 요청 핸들러
* 클라이언트 설정 부분에서 요청 핸들러와, 고차 함수(데코레이터)를 제데로 활용하면 코드가 간결해짐

```typescript
import { rest } from "msw";
import { path } from "..";
import { getMyProfileData } from "./fixture";

export function handleGetMyProfile(args?: {
  mock?: jest.Mock<any, any>;
  status?: number;
}) {
  return rest.get(path(), async (_, res, ctx) => {
    args?.mock?.();
    if (args?.status) {
      return res(ctx.status(args.status));
    }
    return res(ctx.status(200), ctx.json(getMyProfileData));
  });
}

export const handlers = [handleGetMyProfile()];
```

```typescript
export const NotLoggedIn: Story = {
  parameters: {
    msw: { handlers: [handleGetMyProfile({ status: 401 })] }
  }
}
```

```typescript
// 로그인 화면의 스토리 파일
export default {
  component: Login,
  parameters: {
    nextRouter: { pathname: "/login" },
    msw: { handlers: [handleGetMyProfile({ status: 401 })] },
  },
  decorators: [BasicLayoutDecorator],
} as ComponentMeta<typeof Login>;
```

### Next.js Router에 의존하는 스토리

* 컴포넌트 중 특정 URL에서만 사용할 수 있는 컴포넌트가 존재함
* 이와같은 컴포넌트의 스토리를 등록하려면 에드온이 필요
  * `storybook-addon-next-router`&#x20;
  * 에드온을 추가하면 `Router` 상태를 스토리마다 설정 가능해짐

#### 에드온 설정

`.storybook/main.js` 와 `.storybook/preview.js`  에  설정을 추가

```typescript
// .storybook/main.js
module.exports = {
  //...
  stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: ["storybook-addon-next-router"]
}
```

```typescript
// .storybook/preview.js
import { RouterContext } from "@next/dist/shared/lib/router-context";

export const parameters = {
  //...
  nextRouter: {
    Provider: RouterContext.Provider
  }
}
```

#### Router에 의존하는 스토리 등록

* 헤더의 네비게이션에는 `pathname(브라우저 URL)` 에 따라 메뉴 하단의 스타일이 달라진다고 해보자

```typescript
export const RouteMyPosts: Story = {
  parameters: {
    nextRouter: { pathname: "/my/posts" },
  }
}

export const RouterMyPostsCreate: Story = {
  parameters: {
    nextRouter: { pathname: "my/posts/create" },
  }
}
```

### Play function을 활용한 인터렉션 테스트

* `props` 를 전달해 다양한 상황을 재현 가능하지만  UI에 인터렉션을 할당하는 방법으로도 재현하는 상황도 존재
  * 폼에서 값을 전송하기 전 브라우저에 입력된 내용에 유효성 검사를 실시해 문제가 있으면 오류를 표시하는 상황
  * 문자 입력, 포커스 아웃 이벤트, 전송 버튼 클릭 등 과 같은 인터렉션이 필요함
  * 스토리북 기능인 `play function` 를 사용하면 인터렉션 할당 상태를 스토리로 등록 가능

#### 에드온 설정

```bash
$ npm i @storybook/testing-library @storybook/jest @storybook/addon-interactions --save-dev
```

* `@sotrybook/testing-library` , `@storybook/jest`, `@storybook/addon-interactions`

```typescript
// .storybook/main.js
module.exports = {
  // 다른 설정은 생략
  stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: ["@storybook/addon-interactions"],
  features: {
    interactionsDebugger: true,
  }
}
```

#### 인터렉션 할당

* 인터렉션을 할당하기 위해 스토리에 `play` 함수를 설정
* 테스팅 라이브러리 및 `jsdom` 를 사용할 때와 동일하게 `userEvent` 를 사용해서 컴포넌트에 인터렉션 할당
* 테스팅 라이브러리에서 사용하는 `getBy` , `userEvent` 거의 동일한 API라 테스트를 작성하는 느낌으로 작성
* 스토리가 렌더링될 때 `play function` 이 자동으로 실행돼 문자가 입력된 것을 확인 가능
  * 인터렉션이 제데로 할당하지 않으면 도중에 인터렉션이 중단됨

```typescript
export const SucceedSaveAsDraft: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await user.type(
      canvas.getByRole("textbox", { name: "제목" }),
      "나의 기사"
    )
  }
}
```

#### 단언문 작성 (테스트 검증)

* `@storybook/jest` 의 `expect` 함수를 사용하면 컴포넌트에 인터렉션을 할당한 상태에서 단언문 작성가능

```typescript
export const SavePublish: Story = {
  play: async ({ canvasElement }) => {
    const canavs = within(canvasElement);
    await user.type(
      canvas.getByRole("textbox", { name: "제목" }),
      "나의 기사"
    );
    await user.click(canvas.getByRole("switch", { name: "공개 여부" }));
    await expect(
      canvas.getByRole("button", { name: "공개하기" })
    ).toBeInTheDocument()
  }
}
```

```typescript
// 아무것도 입력하지 않고 비공개 상태로 저장을 시도하면 유효성 검사가 실패해 오류가 표시됨
export const FailedSaveAsDraft: Story = {
  play: async ({ canvasElement }) => {
    const canavs = within(canvasElement);
    await user.click(canvas.getByRole("switch", { name: "비공개 상태로 저장" }));
    const textbox = canvas.getByRole("textbox", { name: "제목" });
    await waitFor(() => 
      expect(textbox).toHaveErrorMessage("한 글자 이상의 문자를 입력하세요")
    );
  }
}
```

### addon-a11y를 활용한 접근성 테스트

* 스토리북은 컴포넌트 단윙로 접근성을 검증하는 용도로도 활용됨
* 스토리북을 확인하면서 코드를 작성하면 접근성에 문제가 있는지 조기에 발견 가능

#### 에드온 설정

* `@storybook/addon-a11y` 에드온을 추가하면 스토리북 탐색기에서 접근성 관련 우려사항을 알려줌
* parameters.a11y에 해당 에드온 설정을 사용
  * 다른 `parameters` 와 동일하게 Global > Compeonnt > Story 단계로 나눠 적용 가능

```typescript
// .storybook/main.js
module.exports = {
  // 다른 설정 생략
  stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: ["@storybook/addon-a11y"],
}
```

#### 접근성과 관련한 주의 사항 점검

* 에드온 패널에 추가된 Accessibility 패널을 열어보면 검증한 내용이 Violations, Passes, Incomplete로 구분됌
  * **`Violations`**: 접근성을 위반했다
  * **`Incomplete`**:수정이 필요하다
* `Highlight results` 를 체크하면 주의점이 점선으로 표시되며 강조됨

#### 일부 규칙 위반을 무효화하기

* 규칙이 지나치게 엄격하다고 느끼거나 원하지 않는 보고 내용이 있는 경우 일부 내용을 무효화 할 수 있다.
* 무효화 기능도 마찬가지로 Global, Compoennt, Story 나눠 설정 가능
* `rules` 에 있는 `id`(여기선 `"label"`)은  axe-core의 [Rule Descriptions](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)을 참고해 적용
  * 해당 에드온은 접근성 검증도구로 **axe**를 사용한다.
  * `parameters` 등의 세부 설정은 axe-core의 공식문서를 참고하자.

```typescript
export default {
  component: Switch,
  parameters: {
    a11y: {
      config: { rules: [{. id: "label", enabled: false }] }
    }
  }
} as ComponentMeta<typeof Switch>;
```

#### 접근성 검증 생략

* 접근성 검증 자체를 생략하고 싶다면 `parameters.a11y.disable` 를 `true` 로 설정
* 일부 규칙 위반 무효화와 달리 접근성 자체를 검증 대상에서 제외시키므로 신중히 사용

```typescript
export default {
  component: Switch,
  parameters: {
    a11y: { disable: true }
  }
} as ComponentMeta<typeof Switch>;
```

### 스토리북 테스트 러너&#x20;

* 스토리북의 **테스트 러너**는 스토리를 실행가능한 테스트로 변환한다.
* 테스트로 변환된 스토리는 제스트와 플레이라이트에서 실행된다.
* 이 기능을 활용해서 스토리북에 스모크 테스트는 물론 `play function` 을 활용한 인터렉션 테스트, 접근성 테스트 등을 할 수 있어 UI 컴포넌트 테스트로도 활용 가능&#x20;
  * **스모크 테스트**: 제품의 기본적인 작동 여부를 대략적으로 검증하는 테스트를 말함

#### 테스트 러너를 활용한 테스트 자동화

* 컴포넌트가 변경되면 등록된 스토리에도 변경 사항을 반영해야 한다.
  * 예를 들면 `props` 가 변경되거나 의존하는 웹 API의 데이터가 변경됐다면 스토리에도 변경사항을 반영해야 오류가 발생하지 않게됨
* `@storybook/test-runner` 를 사용해서 명령줄 인터페이스나 CI에서 테스트 러너를 실행하면 등록된 스토리에 오류가 없는지 검증할 수 있다.

```json
// package.json
{
  "scripts": {
    "test:storybook": "test-storybook"
  }
}
```

#### 테스트 러너를 활용한 play function 테스트 자동화

* `play function`을 사용하는 스토리에 컴포넌트의 변경사항을 반영하지 않으면 도중에 인터렉션이 실패됨
* 테스트 러너는 `play function` 을 사용하는 스토리를 대상으로 인터렉션 오류 없이 정상적으로 종료됐는지 검증한다.
* 복잡한 인터렉션이 있는 경우에도 육안으로 확인하면서 테스트할 수 있기 떄문에 테스팅 라이브러리와 `jest-dom` 을 사용할 때보다 편하게 테스트 코드를 작성할 수 있다. (스토리북의 장점)

```typescript
// 모바일 한정으로 드로어 메뉴를 렌더링하고, 스토리에는 버튼을 클릭해서 드로어 메뉴를 여는 인터렉션이 등록됨
export const SPLoggedInOpenedMenu: Story = {
  storyName: "Sp 레이아웃에서 드로어 메뉴를 연다",
  parameters: {
    ...SPStory.parameters,
    screenshot: {
      ...SPStory.parameters.screenshot,
      delay: 200,
    }
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = await canvas.findByRole("button", {
      name: "메뉴 열기",
    });
    await user.click(button);
    const navigation = canvas.getByRole("navigation", {
      name: "내비게이션"
    });
    await expect(navigation).toBeInTheDocument();
  }
}
```

#### 뷰포트 설정이 반영되지 않는 문제 (이미 해결됨)

* [스토리마다 설정한 뷰포트가 테스트 러너에 적용되지 않는 이슈](https://github.com/storybookjs/test-runner/issues/85)~~가 있을 수 있음~~
* 이슈를 해결하기 위한 임시방편 코드

```typescript
module.exports = {
  async preRender(page, context) {
    if (context.name.startsWith("SP")) {
    // SP로 시작하는 스토리의 뷰포트를 스마트폰 사이즈로 고정
      page.setViewportSize({ width: 375, height: 667 }); 
    } else { // 그외 모든 스토리의 뷰포트를 PC 사이즈로 고정
      page.setviewportSize({ width: 1280, height: 800 });
    }
  }
}
```

#### 테스트 러너를 활용한 접근성 테스트 자동화

* 스토리북의 테스트 러너는 플레이라이트와 헤드리스 브라우저에서 실행된다.
  * 덕분에 플레이라이트의 생태계에 있는 기능들을 테스트 러너에서 활용할 수 있다.
  * `axe-playwright` 는 접근성 검증 도구인 `axe`를 사용하는 라이브러리로서 접근성 관련 문제점을 찾음

```bash
$ npm i axe-playwright --save-dev
```

* 기본 설정을 사용하면 `Incomplete`도 오류로 검출된다.
* 만약 오류가 너무 많다면 `includedImpacts` 에 `critical` 만 설정해서 `Violations` 에 해당하는 오류만 검출되도록 변경 가능
* 오류 검출 수준을 조정하면서 단계적으로 개선을 시도할 수 있다.

```typescript
// .storybook/test-runner.js
const { getStoryContext } = require("@storybook/test-runner");
const { injectAxe, checkA11y, configureAxe } = require("axe-playwright");

module.exports = {
  async preRender(page, context) {
   if (context.name.startsWith("SP")) {
      page.setViewportSize({ width: 375, height: 667 }); 
    } else { 
      page.setviewportSize({ width: 1280, height: 800 });
    }
    await injectAxe(page); // axe를 사용하는 검증 설정
  },
  async postRender(page, context) {
    const storyContext = await getStoryContext(page, context);
    if (storyContext.parameters?.a11y?.disable) {
      return;
    }
    await configureAxe(page, {
      rules: storyContext.parameters?.a11y?.config?.rules,
    });
    await checkA11y(page, '#root', { // axe를 사용한 검증
      includedImpacts: ["critical", "serious"], // Vioations에 해당하는 오류만 검출
      detailedReport: false,
      detailedReportOptions: { html: true },
      axeOptions: storyContext.parameters?.a11y?.options,
    })
  }
}
```

### 스토리를 통합 테스트에 재사용하기

* 제스트로 작성한 테스트 코드와 더불어 스토리까지 커밋해야 한다면 운용비가 너무 많이 증가하는것 아닌가 염려됨
  * 스토리를 통합 테스트에 재사용해서 해결할 수 있다.
  * 양쪽을 모두 커밋하면서도 운용비가 줄어든다.

#### 스토리 재사용

* 컴포넌트 테스트는 검증을 시작하기 전에 상태를 만들어야 한다.
* 상태를 만드는 일과 스토리를 만드는 일은 거의 동일한 작업

```tsx
// 스토리 등록용 함수
function createDecorator(defaultState?: Partial<AlertDialogState>) {
  return function Decorator(Story: PartialStroyFn<ReactFramework, Args>) {
    return (
      <AlertDialogProvider defaultState={{ ...defaultState, isShown: true }}>
        <Story />
      </AlertDialogProvider>
    )
  }
}

// 실제로 등록할 스토리
export const Default: Story = {
  decorators: {createDecorator({ message: "성공했습니다." })],
}

export const CustomButtonLabel: Story = {
  decorators: {
    createDecorator({ 
      message: "기사를 공개합니다. 진행하시겠습니까?" 
      cancleButtonLabel: "CANCEL",
      okButtonLabel: "OK",
    })
  ],
}

export const ExcludeCancel: Story = {
  decorators: {
    createDecorator({ 
      message: "전송됐습니다" 
      cancleButtonLabel: undefined,
      okButtonLabel: "OK",
    })
  ],
}
```

* 해당 컴포넌트는 `AlertDialogProvider` 에 의존하지만 `createDecorator` 를 사용하면 초기값을 주입할 수 있어 다양한 스토리를 등록할 수 있다.
* Context API에 의존하는 컴포넌트, 즉 `AlertDialogProvider`가 없으면 작동하지 않는다.
  * 이는 테스트할 때도 마찬가지
  * 테스트의 `render` 에 매번 `AlertDialogProvider` 를 추가해야 한다.
  * 매번 이를 추가하는 것이 <mark style="color:purple;">**언급한 상태를 미리 만들어 두는 작업**</mark>에 해당한다.
* 스토리를 등록할 때 이미 `createDecorator` 함수로 상태를 만들었지만 테스트 코드에도 같은 작업을 반복한다면 중복 작업이라고 느낄 수 있다.
* <mark style="color:blue;">**스토리를 테스트 대상으로 만들어 재활용**</mark>하면 컴포넌트 테스트를 위한 상태를 재차 정의하지 않아도 된다.
  * 이것이 **스토리의 재사용 방법**

#### **스토리를 import하여 테스트 대상으로 만들기**

* 테스트에 스토리를 재사용하기 위해 `import` 하려면 전용 라이브러리인 `@storybook/testing-react` 를 사용해야 한다.
* 스토리 파일의 내용을 불러온 후 `composeStories(stories)` 로 각 스토리를 선언하면 테스트 준비 완료
* **단언문은 스토리를 `render` 한 후 작성할 수 있기 때문에 스토리를 테스트의 일부라고 생각하자.**

```typescript
import { composeStories } from "@storybook/tresting-react";
import { render, screen } from "@testinb-library/react";
import * as stories from "./index.stories";  // jest를 사용하는 테스트에 스토리 파일 불러오기

const { Default, CustomButtonLabel, ExcludeCancel } = composeStories(stories);

describe("AlertDialog", () => {
  test("Default", () => {
    render(<Default />); // 스토리 렌더링
    expect(screen.getByRole("alertDialog")).toBeInTheDocument();
  })
  
  test("CustomButtonLabel", () => {
    render(<CustomButtonLabel />); // 스토리 렌더링
    expect(screen.getByRole("button", { name: "OK" })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: "CANCEL" })).toBeInTheDocument();
  })
  
  test("ExcludeCancel", () => {
    render(<ExcludeCancel />); // 스토리 렌더링
    expect(screen.getByRole("button", { name: "OK" })).toBeInTheDocument();
    expect(screenqueryByRole("button", { name: "CANCEL" })).not.toBeInTheDocument()
  })
})

```

#### @storybook/test-runner와의 차이점

* 테스트와 스토리 등록을 한번에 처리해서 중복을 줄이는 방법은 테스트 러너 활용법(스토리의 play function에 단언문 작성하기)과 유사하다.
* 어떤 방법을 적용할지는 테스트 목적과 각 방법의 장단점을 비교해 결정하자.
* Jest에서 스토리를 재사용할 때의 장점
  * 목 모듈 혹은 스파이가 필요한 테스트를 작성할 수 있다.(제스트의 목함수 사용)
  * 실행 속도가 빠르다. (헤드리스 브라우저를 사용하지 않기 때문)
* 테스트 러너의 장점
  * 테스트 파일을 따로 만들지 않아도 된다.(적은 작업량)
  * 실제 환경과 유사성이 높다. (브라우저를 사용해 CSS가 적용된 상황 재현 가능)
