스토리북

스토리북

  • 디자이너나 프로젝트 리더와 구현된 UI 컴포넌트의 공유가 가능해지면 협업시 능률이 높아진다.

  • UI 컴포넌트 탐색기는 구현된 UI 컴포넌트를 쉽게 공유할 수 있도록 도와주는 협업 도구

  • 스토리북의 UI 컴포넌트 테스트는 통합 테스트, E2E 테스트 중간에 위치한 테스트

    • 스토리북은 UI 컴포넌트 탐색기지만 테스트 기능도 강화했음

설치

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

스토리

  • 스토리 최신 포맷 CSF3.0

  • CSF3.0은 객체를 개별적으로 export 하여 스토리 등록함

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라고 한다.

  • @Storybook/addon-controls 라는 에드온,

    • @storybook/addon-essentials 에 포함되어있음

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

  • 이벤트 핸들러가 어떻게 호출됐는지 로그를 출력하는 기능

  • @storybook/addon-actions

    • 마찬가지로 @storybook/addon-essentials 에 포함되어있음

  • Global 단계 설정인 .storybook/preview.js 를 보면 argTypesRegex: "^on[A-Z].*" 라는 설정이 on 으로 시작하는 모든 이벤트 핸들러는 자동적으로 Actions 패널에 로그를 출력하게 된다.

    • ⚠️ 만약 프로젝트에 이벤트 핸들러의 이름으로 사용하는 다른 네이밍 컨벤션이 있다면 해당 컨벤션에 따라 정규표현식을 수정해야 한다.

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

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

  • 반응형으로 구현된 UI 컴포넌트는 화면 크기별로 스토리를 등록할 수 있다.

  • @storybook/addon-viewport 에서 지원

  • SP(스마트폰) 레이아웃으로 스토리를 등록하려면 parameters.viewport 를 설정해야 한다.

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,
    }
  }
}
// SPStory: SP 레이아웃용 커스텀 공통 설정 모듈
import { SPStory } from '@/tests/storybook';

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

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

  • 리액트의 Context API 에 의존하는 스토리에는 스토리북의 데코레이터(decorator) 를 활용

  • 초깃값을 주입할 수 있도록 Provider를 만들면 Context 의 상태에 의존하는 UI를 간단하게 재현 가능

스토리북의 데코레이터

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

import { ChildComponent } from "./";

export default { // 컴포넌트 단계의 설정
  title: "ChildComponent",
  component: ChildComponent,
  decorators: [
    (Story) => (
      <div style={{ padding: "60px" }}> // 해당 파일의 스토리들이 모두 적용됨
        <Story /> // 각 스토리가 전개됨
      </div>
    )
  ]
}
  • 로그인한 사용자 정보가 있는 Provider(LoginUserInfoProvider) 를 데코레이터가 소유했다면 ContextProvider에 의존하는 UI 컴포넌ㅌ의 스토리에서도 로그인한 사용자의 정보를 표시할 수 있다.

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를 데코레이터로 만드는것이 좋다.

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 라는 고차 함수를 사용해 설정을 최소화

// 사용 예시
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)를 쉽게 주입 가능

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를 사용해야 한다.

에드온 설정

npm i msw msw-storybook-addon --save-dev
  • 스토리북에서 MSW를 사용하려면 mswmsw-storybook-addon 을 설치해야 한다.

  • .storybook/preview.js 에서 initialize 함수를 실행해 MSW를 활성화

    • mswDecorator 는 모든 스토리에 필요하므로 글로벌 단계에서 설정

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

export const decorators = [mswDecorator];

initialize();
  • 프로젝트에 처음 MSW를 설치한다면 public/ 경로를 다음의 커맨드로 실행

    • (<PUBLIC_DIR> 을 프로젝트의 public 디렉토리 명으로 변경)

    • 커맨드를 실행하면 mockServiceWorkjer.js 가 생성되며 커밋해야함

npx msw init <PUBLIC_DIR>
  • 스토리북에도 public/ 경로를 명시

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

요청 핸들러 변경

  • 다른 parameters 와 동일하게 Global, Component, Story 설정을 통해 스토리에 사용할 요청 핸들러가 결정됨

  • .storybook/preview.js 에 필요한 설정을추가

    • 예를 들어 모든 스토리에 로그인한 사용자 정보가 필요하다면 로그인한 사용자 정보를 반환하는 MSW 핸들러를 Global 단계에 설정하는 것이 좋다.

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 요청 핸들러

  • 클라이언트 설정 부분에서 요청 핸들러와, 고차 함수(데코레이터)를 제데로 활용하면 코드가 간결해짐

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()];
export const NotLoggedIn: Story = {
  parameters: {
    msw: { handlers: [handleGetMyProfile({ status: 401 })] }
  }
}
// 로그인 화면의 스토리 파일
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

    • 에드온을 추가하면 Router 상태를 스토리마다 설정 가능해짐

에드온 설정

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

// .storybook/main.js
module.exports = {
  //...
  stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"],
  addons: ["storybook-addon-next-router"]
}
// .storybook/preview.js
import { RouterContext } from "@next/dist/shared/lib/router-context";

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

Router에 의존하는 스토리 등록

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

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 를 사용하면 인터렉션 할당 상태를 스토리로 등록 가능

에드온 설정

$ npm i @storybook/testing-library @storybook/jest @storybook/addon-interactions --save-dev
  • @sotrybook/testing-library , @storybook/jest, @storybook/addon-interactions

// .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 이 자동으로 실행돼 문자가 입력된 것을 확인 가능

    • 인터렉션이 제데로 할당하지 않으면 도중에 인터렉션이 중단됨

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

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

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

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()
  }
}
// 아무것도 입력하지 않고 비공개 상태로 저장을 시도하면 유효성 검사가 실패해 오류가 표시됨
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 단계로 나눠 적용 가능

// .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을 참고해 적용

    • 해당 에드온은 접근성 검증도구로 axe를 사용한다.

    • parameters 등의 세부 설정은 axe-core의 공식문서를 참고하자.

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

접근성 검증 생략

  • 접근성 검증 자체를 생략하고 싶다면 parameters.a11y.disabletrue 로 설정

  • 일부 규칙 위반 무효화와 달리 접근성 자체를 검증 대상에서 제외시키므로 신중히 사용

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

스토리북 테스트 러너

  • 스토리북의 테스트 러너는 스토리를 실행가능한 테스트로 변환한다.

  • 테스트로 변환된 스토리는 제스트와 플레이라이트에서 실행된다.

  • 이 기능을 활용해서 스토리북에 스모크 테스트는 물론 play function 을 활용한 인터렉션 테스트, 접근성 테스트 등을 할 수 있어 UI 컴포넌트 테스트로도 활용 가능

    • 스모크 테스트: 제품의 기본적인 작동 여부를 대략적으로 검증하는 테스트를 말함

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

  • 컴포넌트가 변경되면 등록된 스토리에도 변경 사항을 반영해야 한다.

    • 예를 들면 props 가 변경되거나 의존하는 웹 API의 데이터가 변경됐다면 스토리에도 변경사항을 반영해야 오류가 발생하지 않게됨

  • @storybook/test-runner 를 사용해서 명령줄 인터페이스나 CI에서 테스트 러너를 실행하면 등록된 스토리에 오류가 없는지 검증할 수 있다.

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

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

  • play function을 사용하는 스토리에 컴포넌트의 변경사항을 반영하지 않으면 도중에 인터렉션이 실패됨

  • 테스트 러너는 play function 을 사용하는 스토리를 대상으로 인터렉션 오류 없이 정상적으로 종료됐는지 검증한다.

  • 복잡한 인터렉션이 있는 경우에도 육안으로 확인하면서 테스트할 수 있기 떄문에 테스팅 라이브러리와 jest-dom 을 사용할 때보다 편하게 테스트 코드를 작성할 수 있다. (스토리북의 장점)

// 모바일 한정으로 드로어 메뉴를 렌더링하고, 스토리에는 버튼을 클릭해서 드로어 메뉴를 여는 인터렉션이 등록됨
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();
  }
}

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

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를 사용하는 라이브러리로서 접근성 관련 문제점을 찾음

$ npm i axe-playwright --save-dev
  • 기본 설정을 사용하면 Incomplete도 오류로 검출된다.

  • 만약 오류가 너무 많다면 includedImpactscritical 만 설정해서 Violations 에 해당하는 오류만 검출되도록 변경 가능

  • 오류 검출 수준을 조정하면서 단계적으로 개선을 시도할 수 있다.

// .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,
    })
  }
}

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

  • 제스트로 작성한 테스트 코드와 더불어 스토리까지 커밋해야 한다면 운용비가 너무 많이 증가하는것 아닌가 염려됨

    • 스토리를 통합 테스트에 재사용해서 해결할 수 있다.

    • 양쪽을 모두 커밋하면서도 운용비가 줄어든다.

스토리 재사용

  • 컴포넌트 테스트는 검증을 시작하기 전에 상태를 만들어야 한다.

  • 상태를 만드는 일과 스토리를 만드는 일은 거의 동일한 작업

// 스토리 등록용 함수
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 를 추가해야 한다.

    • 매번 이를 추가하는 것이 언급한 상태를 미리 만들어 두는 작업에 해당한다.

  • 스토리를 등록할 때 이미 createDecorator 함수로 상태를 만들었지만 테스트 코드에도 같은 작업을 반복한다면 중복 작업이라고 느낄 수 있다.

  • 스토리를 테스트 대상으로 만들어 재활용하면 컴포넌트 테스트를 위한 상태를 재차 정의하지 않아도 된다.

    • 이것이 스토리의 재사용 방법

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

  • 테스트에 스토리를 재사용하기 위해 import 하려면 전용 라이브러리인 @storybook/testing-react 를 사용해야 한다.

  • 스토리 파일의 내용을 불러온 후 composeStories(stories) 로 각 스토리를 선언하면 테스트 준비 완료

  • 단언문은 스토리를 render 한 후 작성할 수 있기 때문에 스토리를 테스트의 일부라고 생각하자.

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가 적용된 상황 재현 가능)

Last updated