Dev Diary

[Question Cloud] Storybook 과 Testing 설정하기 본문

Projects

[Question Cloud] Storybook 과 Testing 설정하기

sik9252 2024. 8. 23. 21:22
SMALL

지금까지의 프로젝트는 기능 구현에만 집중하여 진행했었다. 결과적으로 나중에 새로운 기능의 추가 혹은 기존 기능에 대한 수정 요청이 들어오면서 코드를 변경하게 되고 코드를 변경하면서 사소한 버그들이 자주 발생했던 경험을 겪었기 때문에 이번 프로젝트는 개발 시작 전 초기 설정 관련하여 시간이 훨씬 많이 걸리더라도 Storybook과 Testing Library 등을 도입하여 중요하거나 수정사항이 많이 발생할 것 같은 부분에 테스트 코드등을 추가하여 나중에 리팩토링을 진행하게 되었을때 사소한 버그가 출몰하여 그것도 수정하고 기존 코드도 수정해야하는데에 대한 스트레스를 줄이고 안정감을 느끼고 싶었다.

 

Storybook과 Testing Library를 도입했을때의 전체적인 작업 흐름은 다음과 같다고한다.

  • Storybook을 이용한 컴포넌트 분리, props와 모의 데이터를 사용하여 각 상태를 재현하는 테스트 케이스 작성
  • Chromatic을 이용한 시각적 요소 버그 포착 및 구성 확인
  • Jest와 Test Library를 이용한 상호작용 검증
  • Axe를 이용한 접근성 심사
  • Cypress를 이용해 e2e 테스트 코드를 작성하여 사용자 흐름 검증
  • Github Actions를 통해 자동으로 테스트를 실행해 회귀 포착

나는 일단 지금은 Storybook을 도입하고, Chromatic을 이용한 시각적 테스트 그리고 Jest를 이용한 상호작용 테스트까지만 설정해보려고한다. 이후 시간이 되면 Cypress를 도입해 전체적인 UI가 사용자의 작업 흐름에 따라 정상적으로 동작하는지 E2E 테스트도 작성해보고, 배포 자동화 파이프라인을 구축해볼 것이다.

 

이런 시스템을 도입하는 것은 처음이기 때문에 단순히 진행할 프로젝트 내부에 Storybook을 세팅하고 Button 컴포넌트를 하나 생성하여 띄우고 생성한 요소에 대한 시각적 테스트, 상호작용 테스트를 설정하는데만 6시간이 걸렸다. 나중에 또 비슷한 환경을 구축해야할 일이 있을듯하여 큰 틀을 기록해두려고한다.


Storybook 추가하기

나는 아예 새로 생성한 프로젝트였기 때문에 아래 명령어를 사용해 storybook을 추가했다.

pnpm dlx storybook@latest init

 

그런데 남들은 이렇게 하면 바로 storybook이 세팅되고 사이트가 열렸는데 나는 아래와 같은 이상한 오류가 발생했다. 아예 싹 지우고 프로젝트를 새로 생성해보아도 오류에 변화는 없었다.

 

발생한 오류

✘ [ERROR] Could not resolve "@storybook/addon-toolbars/manager"

    node_modules/.pnpm/@storybook+addon-essentials@8.1.11_@types+react-dom@18.3.0_@types+react@18.3.3_prettier@3.3.2_unjzl3a5zay53n36pw25ml45ja/node_modules/@storybook/addon-essentials/dist/toolbars/manager.js:1:14:
      1 │ export * from '@storybook/addon-toolbars/manager';
        ╵               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  The Yarn Plug'n'Play manifest forbids importing
  "@storybook/addon-toolbars" here because it's not listed as a
  dependency of this package:

    ../../../../.pnp.cjs:36:31:
      36 │         "packageDependencies": [\
         ╵                                ~~

  You can mark the path "@storybook/addon-toolbars/manager" as
  external to exclude it from the bundle, which will remove this
  error and leave the unresolved path in the bundle.

✘ [ERROR] Could not resolve "@storybook/addon-backgrounds/manager"

    node_modules/.pnpm/@storybook+addon-essentials@8.1.11_@types+react-dom@18.3.0_@types+react@18.3.3_prettier@3.3.2_unjzl3a5zay53n36pw25ml45ja/node_modules/@storybook/addon-essentials/dist/backgrounds/manager.js:1:14:
      1 │ export * from '@storybook/addon-backgrounds/manager';
        ╵               ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  The Yarn Plug'n'Play manifest forbids importing
  "@storybook/addon-backgrounds" here because it's not listed as a
  dependency of this package:

    ../../../../.pnp.cjs:36:31:
      36 │         "packageDependencies": [\
         ╵                                ~~

  You can mark the path "@storybook/addon-backgrounds/manager" as
  external to exclude it from the bundle, which will remove this
  error and leave the unresolved path in the bundle.

.....(생략)

✘ [ERROR] Could not resolve "filesize"

    node_modules/.pnpm/@chromatic-com+storybook@1.6.0_react@18.3.1/node_modules/@chromatic-com/storybook/dist/manager.mjs:7:25:
      7 │ import { filesize } from 'filesize';
        ╵                          ~~~~~~~~~~

  The Yarn Plug'n'Play manifest forbids importing "filesize" here
  because it's not listed as a dependency of this package:

    ../../../../.pnp.cjs:36:31:
      36 │         "packageDependencies": [\
         ╵                                ~~

  You can mark the path "filesize" as external to exclude it from
  the bundle, which will remove this error and leave the
  unresolved path in the bundle.

✘ [ERROR] Could not resolve "react-confetti"

    node_modules/.pnpm/@chromatic-com+storybook@1.6.0_react@18.3.1/node_modules/@chromatic-com/storybook/dist/manager.mjs:10:15:
      10 │ import s8 from 'react-confetti';
         ╵                ~~~~~~~~~~~~~~~~

  The Yarn Plug'n'Play manifest forbids importing "react-confetti"
  here because it's not listed as a dependency of this package:

    ../../../../.pnp.cjs:36:31:
      36 │         "packageDependencies": [\
         ╵                                ~~

  You can mark the path "react-confetti" as external to exclude it
  from the bundle, which will remove this error and leave the
  unresolved path in the bundle.

✘ [ERROR] Could not resolve "strip-ansi"

    node_modules/.pnpm/@chromatic-com+storybook@1.6.0_react@18.3.1/node_modules/@chromatic-com/storybook/dist/manager.mjs:11:15:
      11 │ import b8 from 'strip-ansi';
         ╵                ~~~~~~~~~~~~

  The Yarn Plug'n'Play manifest forbids importing "strip-ansi"
  here because it's not listed as a dependency of this package:

    ../../../../.pnp.cjs:36:31:
      36 │         "packageDependencies": [\
         ╵                                ~~

  You can mark the path "strip-ansi" as external to exclude it
  from the bundle, which will remove this error and leave the
  unresolved path in the bundle.

✘ [ERROR] Could not resolve "react-confetti"

    node_modules/.pnpm/@storybook+addon-onboarding@8.1.11_react@18.3.1/node_modules/@storybook/addon-onboarding/dist/App-SU7ZWKZE.js:8:26:
      8 │ import ReactConfetti from 'react-confetti';
        ╵                           ~~~~~~~~~~~~~~~~

  The Yarn Plug'n'Play manifest forbids importing "react-confetti"
  here because it's not listed as a dependency of this package:

    ../../../../.pnp.cjs:36:31:
      36 │         "packageDependencies": [\
         ╵                                ~~

  You can mark the path "react-confetti" as external to exclude it
  from the bundle, which will remove this error and leave the
  unresolved path in the bundle.

Error: Build failed with 15 errors:
node_modules/.pnpm/@chromatic-com+storybook@1.6.0_react@18.3.1/node_modules/@chromatic-com/storybook/dist/manager.mjs:7:25: ERROR: Could not resolve "filesize"
node_modules/.pnpm/@chromatic-com+storybook@1.6.0_react@18.3.1/node_modules/@chromatic-com/storybook/dist/manager.mjs:10:15: ERROR: Could not resolve "react-confetti"
node_modules/.pnpm/@chromatic-com+storybook@1.6.0_react@18.3.1/node_modules/@chromatic-com/storybook/dist/manager.mjs:11:15: ERROR: Could not resolve "strip-ansi"
node_modules/.pnpm/@storybook+addon-essentials@8.1.11_@types+react-dom@18.3.0_@types+react@18.3.3_prettier@3.3.2_unjzl3a5zay53n36pw25ml45ja/node_modules/@storybook/addon-essentials/dist/actions/manager.js:1:14: ERROR: Could not resolve "@storybook/addon-actions/manager"
node_modules/.pnpm/@storybook+addon-essentials@8.1.11_@types+react-dom@18.3.0_@types+react@18.3.3_prettier@3.3.2_unjzl3a5zay53n36pw25ml45ja/node_modules/@storybook/addon-essentials/dist/backgrounds/manager.js:1:14: ERROR: Could not resolve "@storybook/addon-backgrounds/manager"
...
    at failureErrorWithLog (./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:1651:15)
    at ./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:1059:25
    at runOnEndCallbacks (./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:1486:45)
    at buildResponseToResult (./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:1057:7)
    at ./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:1086:16
    at responseCallbacks.<computed> (./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:704:9)
    at handleIncomingPacket (./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:764:9)
    at Socket.readFromStdout (./node_modules/.pnpm/esbuild@0.20.2/node_modules/esbuild/lib/main.js:680:7)
    at Socket.emit (node:events:519:28)
    at addChunk (node:internal/streams/readable:559:12)

WARN Broken build, fix the error above.
WARN You may need to refresh the browser.

 ELIFECYCLE  Command failed with exit code 1.

 

잘 읽어보니 무슨 node_modules/.pnpm 내부의 @storybook과 관련된 모든 모듈들과 .pnp.cjs에 무슨 문제가 있는 것 같은데 오류가 발생한 많은 node_modules를 다 까보아도 .pnp.cjs라는 파일은 보이지도 않고 어떻게 해야할지 1시간을 삽질했다.

 

해결

https://github.com/storybookjs/storybook/discussions/28425

 

Error: Could not resolve various Storybook dependencies with Storybook 8 and PNPM · storybookjs storybook · Discussion #28425

When attempting to use Storybook 8 with PNPM in my Next.js project, I encounter errors indicating that various Storybook dependencies cannot be resolved. This issue persists despite multiple troubl...

github.com

이 issue가 나를 구원해줬다. .pnp.cjs는 프로젝트 내부에 있던게 아니라 /Users/사용자이름 하위에 숨김 폴더로 있었다.

이 파일을 지워서 휴지통으로 보내주었다

 

이제 정상적으로 세팅이 완료되고, 사이트가 실행되었다!


Button 컴포넌트 생성하기

프론트엔드 개발자로서 화면을 그리기 전에 그래도 디자인 시안이 있으면 항상 작업이 훨씬 수월했기때문에 개발 환경 세팅 이전에 Figma를 활용해서 전체적인 페이지에 대한 디자인을 대강 그려놨었다. 그 중 프로젝트에서 사용할 버튼들을 아래와 같이 정리해뒀었는데, 이걸 기반으로 실제 코드로 Button을 생성하여 아까 설치한 Storybook과 연동해보기로 했다.

Figma에 그려둔 사용할 버튼 모음

 

일단 이번에 언젠가부터 주변에서 많이 들려오던 shadcn ui를 활용해 디자인 시스템을 구성해보려고 했기에, shadcn ui의 Button 컴포넌트를 가져와서 프로젝트에 추가했다.

pnpm dlx shadcn-ui@latest add button
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils";

const buttonVariants = cva(
  "inline-flex items-center justify-center text-button whitespace-nowrap disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        navy: "bg-navy text-white hover:bg-navy/90 rounded-[8px]",
        gray: "bg-gray_01 text-gray_03 hover:bg-gray_02/60 rounded-[8px]",
        grayLine: "bg-white text-black hover:bg-gray_01/60 border border-solid border-gray_02 rounded-[8px]",
      },
      size: {
        large: "w-full h-[48px] px-[16px] py-[14.5px]",
        medium: "w-full h-[44px] px-[16px] py-[12.5px]",
        small: "w-full h-[36px] px-[16px] py-[8.5px]",
      },
    },
    defaultVariants: {
      variant: "navy",
      size: "medium",
    },
  }
);

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button";
    return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
  }
);
Button.displayName = "Button";

export { Button, buttonVariants };

 

프로젝트에서 사용할 color과 font를 미리 tailwind.config.ts에 지정해두고, 그것을 바탕으로 Button 코드를 조금 수정했기 때문에 바로 받아온 Button 컴포넌트의 코드와는 차이가 있을것이다.

 

Figma에 기획했던대로 맞추어 당장 사용할 navy/gray/grayLine의 variant를 가진 버튼 3개를 추가했고, large/medium/small의 3가지 size를 가질 수 있도록했다. 그리고 default로 navy, medium 속성을 가지고 있도록했다.


Storybook과 Button 연결하기

Storybook과 생성한 Button 컴포넌트를 연결하기위해 Button 컴포넌트와 같은 경로에 button.stories.tsx를 생성하고 아래 코드를 작성 후, 사이트를 확인해보면 생성한 Button이 만들어져있다.

import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import { Button, ButtonProps } from "./button";

const meta: Meta<ButtonProps> = {
  title: "Components/Button",
  component: Button,
  argTypes: {
    variant: {
      control: { type: "select" },
      options: ["navy", "gray", "grayLine"],
    },
    size: {
      control: { type: "select" },
      options: ["large", "medium", "small"],
    },
    asChild: {
      control: { type: "boolean" },
    },
  },
  tags: ["autodocs"],
};

export default meta;

type Story = StoryObj<ButtonProps>;

export const Default: Story = {
  args: {
    variant: "navy",
    size: "medium",
    children: "Default Button",
  },
};

export const Gray: Story = {
  args: {
    variant: "gray",
    size: "medium",
    children: "Gray Button",
  },
};

export const GrayLine: Story = {
  args: {
    variant: "grayLine",
    size: "medium",
    children: "GrayLine Button",
  },
};

export const AsChild: Story = {
  args: {
    asChild: true,
    children: <span>❌ Button as a Child</span>,
    variant: "navy",
    size: "medium",
  },
};

Storybook에 Button 컴포넌트가 연결됨


시각적 테스트 설정하기

Storybook을 보면 하단에 Visual Tests라는 탭이 있는데 이 부분을 설정해볼 것이다. 이것은 commit을 기준으로 새 스냅샷이 캡처되고, 기존 스냅샷과 비교해 변경 사항을 감지한다고한다.

pnpm add -D chromatic
pnpm chromatic --project-token=<project-token>

 

 

저렇게 하고나면 chromatic 사이트가 열리고 아래와 같은 화면을 볼 수 있다.

 

근데 사실 이 시각적 테스트가 어떨때 유용하게 쓰일지 아직 잘 감이 안온다.

지금 프로젝트에 다른 프론트엔드 개발자가 존재하는 것도 아니고 아직까지 단순한 Button밖에 생성하지 않아서 그런것 같다. 만약 나중에 여러 사람들과 협업을 진행하는 경우 이 시각적 테스트 도구를 활용해 UI의 변경사항을 쉽게 확인할 수 있게된다면 좋을 것 같다. 코드리뷰 하듯 다함께 변경사항을 확인하고 Accept한다거나... 여튼 지금 상황에서는 이후에 나올 상호작용 테스트를 더 유용하게 쓸 것 같다.


상호작용 테스트 설정하기

Visual Tests 옆에 Interactions라는 탭이 있다. 이것을 통해 생성한 UI 컴포넌트가 사용자가 무언가를 타이핑하거나 클릭할 때, 해당 이벤트에 대한 응답으로 올바른 상태가 업데이트되고 보여지는지 확인할 수 있다.

 

1. test-runner 설치하기

pnpm add -D @storybook/test-runner
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build",
    "test-storybook": "test-storybook", // 추가
  },
pnpm run test-storybook

 

근데 여기서 아래와 같은 오류가 발생했다.

 

발생한 오류

Error: Executable doesn't exist at /Users/사용자/Library/Caches/ms-playwright/chromium-1129/chrome-mac/Chromium.app/Contents/MacOS/Chromium

 

해결

pnpm add -D @playwright/test
pnpm exec playwright install

 

이후 pnpm run storybook:test를 수행하니 정상적으로 test가 동작하였다.

 

참고1: pnpm run storybook:test --watch를 수행하면 코드가 바뀔때 감지하여 테스트를 자동으로 수행해준다.

참고2: storybook test-runner는 playwright를 이용하고, 이는 모든 상호작용 테스트를 수행하고 실패한 스토리를 잡아내준다.

 

2. play를 활용한 상호작용 테스트코드 작성하기

export const Default: Story = {
  args: {
    variant: "navy",
    size: "medium",
    children: "Default Button",
  },
  render: (args) => {
    const [click, setClick] = useState(false);

    const handleClick = () => {
      setClick(!click);
    };

    return (
      <Button {...args} onClick={handleClick} variant={click ? "gray" : args.variant}>
        {args.children}
      </Button>
    );
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByText("Default Button");

    // 클릭 전에 클래스가 올바른지 확인
    expect(button).toHaveClass("bg-navy");

    // 버튼 클릭
    await userEvent.click(button);

    // 클릭 후 클래스 변경 확인
    expect(button).toHaveClass("bg-gray_01");
  },
};

 

play 기능을 사용해 실제로 어떤 동작이 발생했을때의 상태 변화를 테스트해볼수 있다. 위처럼 기존 색상(navy)를 가진 버튼에 userEvent를 통해 클릭 이벤트가 발생했을때의 시나리오를 작성하여 클릭 이벤트 발생시 버튼이 가지고 있는 class를 통해 gray 색상으로 변경되었는지 확인해보는 테스트코드를 작성해보았다.

 

상호작용 테스트가 잘 통과되는것을 볼 수 있다. 그리고 실제로 버튼을 눌러보면 작동한다.


후기

솔직히 설정하는것도 어려웠지만, 테스트코드를 작성하는것은 더욱 막막했다. 지금 단순한 버튼 하나 그리고 클릭 이벤트 발생시 버튼 하나의 색상을 변경하는것에 대한 테스트 코드를 작성하는것도 매우 어려운데 뒤에 구현해야할 다수의 필터링 옵션, 체크박스 등은 도대체 어떻게 구현해야할지 감이 안온다. 많은 시간이 들 것으로 예상된다. 테스트 코드도 중요하지만 일단 프로젝트의 완성이 1순위이므로 너무 목매이지 말고 연습해본다는 느낌으로 천천히 작성해봐야겠다.


참고자료

https://storybook.js.org/docs

 

Storybook: Frontend workshop for UI development

Storybook is a frontend workshop for building UI components and pages in isolation. Thousands of teams use it for UI development, testing, and documentation. It's open source and free.

storybook.js.org

https://storybook.js.org/tutorials/ui-testing-handbook/react/ko/introduction/

 

Storybook Tutorials

Learn how to develop UIs with components and design systems. Our in-depth frontend guides are created by Storybook maintainers and peer-reviewed by the open source community.

storybook.js.org

 

LIST