bandal.dev

Storybook으로 Next.js RSC 테스트하기

Storybook과 MSW로 React Server Components의 인터랙션을 테스트하는 방법에 대해 알아봅니다.

14
Storybook
Next.js
RSC

Storybook 8 부터 React Server Component (RSC)를 지원하게 되었습니다.
이번 글에서는 Next.js 14 기반의 RSC를 Storybook을 통해 테스트하는 방법에 대해 알아보겠습니다.

들어가기전에

RSC가 뭔가요?

RSC는 서버에서 렌더링되는 컴포넌트로, 클라이언트에서 렌더링되는 컴포넌트와는 다르게 서버에서 렌더링되어 클라이언트로 전달됩니다. 이렇게 들으면 SSR과 차이가 없어보입니다.

SSR은 초기 페이지 로드에 중점을 두고 있습니다. 사용자는 HTML을 빨리 얻을 수 있지만, 앱과 바로 상호작용할 수는 없습니다.

  • 컴포넌트가 표시되기 전에, 서버에서 페이지 전체의 데이터를 가져오기까지 기다려야합니다. 이를 방지하기 위해 일부 데이터를 client-side에서 useEffect() Hooks를 통해 가져오는 것인데, 이는 Round-Trip이 길며, 컴포넌트가 렌더링되고 Hydrate 이후에 일어납니다. 이는 긴 Waterfall을 유발합니다.
  • 브라우저에서 완성된 HTML과 JS를 받게되면 React.createElement를 통해 DOM을 다시 렌더링하고, 이벤트 리스너를 연결하는 Hydration 과정이 필요합니다.

240302-001709

SSR은 SEO 최적화, FCP(Fist Contentful Paint)를 개선하는데 도움이 되었지만, 결국 클라이언트 환경에서 많은 작업의 수행을 필요로 했습니다.

반면, RSC는 Hydration 과정이 없어집니다. 서버에서 완전히 렌더링한 후 React Server Component Payload (RSC Payload)라는 특수 데이터 형식으로 클라이언트에 전달됩니다.

  • Server Component의 코드는 클라이언트에 전달되지 않습니다. 반면 SSR은 JS 번들을 통해 클라이언트에 전달됩니다.
  • Server Component는 서버에 직접 접근(Database, FileSystem, 내부 서비스)할 수 있습니다.
  • 결과, 다운로드할 JS Bundle도 없고, 클라이언트측 리렌더링을 요구하지 않으므로 성능이 향상됩니다.
  • 단, 서버 컴포넌트는 정적인 처리만 가능합니다. 즉 useState, useEffect와 같은 상태, DOM Web API를 사용할 수 없습니다.

240302-002501

RSC Payload가 뭔가요?
React Server Component Tree의 압축된 바이너리 표현입니다. 이는 클라이언트의 React에서 DOM을 업데이트하는데 사용됩니다. RSC Payload는 다음이 포함되어 있습니다.

  • Server Compoennt의 렌더 결과
  • Client Component를 렌더링해야하는 위치, JS 파일 참조를 위한 Placeholder
  • Server Component에서 Client Component로 전달되는 Props

240312-002929

Next.js 에서는 app router로 변경되며 RSC의 사용성이 개선되었습니다.
기존엔 페이지의 최상단에 getServerSideProps()를 선언하여 props로 전달해야만 했지만, 지금은 컴포넌트를 비동기 async로 선언하면 됩니다.

export default async function Component() {
  const dbRes = db.query("SELECT * FROM example");
  const apiRes = await fetch("/api/example");
 
  return (
    ...
  )
}

자세한 사용 방법은 공식 문서를 참고하세요.

왜 Storybook으로 인터랙션을 테스트하나요?

Jest나 Playwright 등을 통해 컴포넌트가 출력된 값이 예상대로 나오는지 테스트할 수 있습니다. 하지만 위 방식만으론 UI 버그를 예방하긴 충분하지 않습니다.
사용자의 인터랙션은 테스트할 수 있지만, Node.js 환경에서만 동작하기에 시각적 테스트를 할 수는 없습니다.

반면 스토리북은 컴포넌트 단위로 UI를 테스트할 수 있으며, 브라우저 환경에서 동작하기에 시각적 테스트도 가능합니다. 또한, 사용자의 인터랙션을 자동으로 실행하는 테스트 시나리오를 작성할 수 있습니다.

이 글을 통해 스토리북의 Next.js 14 내의 환경 구성 및 RSC를 테스트하는 방법에 대해 알아보겠습니다.

Storybook 환경 구성

먼저, Next.js 프로젝트에 Storybook을 추가합니다.

pnpm dlx storybook@next init

이 후 Storybook의 main.ts에 새로운 RSC 기능을 사용하기 위한 설정을 추가합니다.
또한, tsconfig의 paths alias을 Storybook에서도 사용할 수 있도록 설정합니다.

.storybook/main.ts
const config: StorybookConfig = {
  // stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  stories: ['../app/**/*.stories.tsx'],
  // ... 기존 설정들
  features: { experimentalRSC: true }
  staticDirs: [
    "../public",
    // 이 블로그에선 next.js localfont를 사용하여서 따로 추가해줬습니다.
    {
      from: "../public/fonts",
      to: "/public/fonts",
    }
  ],
  webpackFinal: async (config) => {
    if(!config.resolve) return config;
 
    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname, "../src"),
      "contentlayer/generated": path.resolve(__dirname, "../.contentlayer/generated"),
    }
    
    return config;
  },
}

이제 Stories를 작성하여 RSC를 테스트할 수 있습니다. 이 블로그의 페이지는 RSC로 구성되어 있습니다. 그러므로 PostPage 컴포넌트를 예시로 들겠습니다.

app/components/pages/PostPage.stories.tsx
import { Meta, StoryObj } from "@storybook/react";
import { allPosts } from "contentlayer/generated";
 
import Layout from "../Layout";
import PostPage from "./PostPage";
 
const meta = {
  title: "pages/PostPage",
  component: PostPage,
} satisfies Meta<typeof PostPage>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
const post = allPosts.find((post) => post.slug === "/__test/mdx-example");
 
if(!post) {
  throw new Error("No post found");
}
 
export const Default: Story = {
  args: post,
};

240302-025743

RSC가 작동하는 것은 확인했지만, 스타일과 필요한 Provider가 누락되어있음을 확인할 수 있습니다.
./storybook/preview.tsxdecorators를 통해 필요한 Provider를 추가할 수 있습니다.

.storybook/preview.tsx
import type { Preview } from "@storybook/react";
import Providers from "../src/components/Provider";
import * as React from "react";
 
import "@/app/globals.css";
import "./font-face.css";
 
const preview: Preview = {
  parameters: {
    nextjs: {
      appDirectory: true,
    },
  },
  decorators: [
    (Story) => (
      <Providers>
        <Story />
      </Providers>
    ),
  ]
};
 
export default preview;

tailwind.config.ts에서 content 경로를 추가합니다.

tailwind.config.ts
const config = {
  darkMode: ["class"],
  content: [
    "./src/**/*.{ts,tsx}",
    "./storybook/**/*.{ts,tsx}",
  ],
  // ... 기타 설정
}

240302-031144

이제 원하던 화면이 출력되는 것을 확인할 수 있습니다.

MSW를 통한 API Mocking

MSW를 활용하여 데이터를 제어하며 다양한 상태를 테스트해볼 수 있습니다.

먼저, MSW와 msw-storybook-addon을 설치합니다. msw-storybook-addon이 MSW의 2.0.0 까지만 지원하기에, 그 이상은 설치할 경우 에러가 발생합니다.
Issue#121이 해결되기 전까진 아래 버전을 지켜주세요.

pnpm add -D msw@2.0.0
pnpm dlx storybook add msw-storybook-addon@2.0.0--canary.122.b3ed3b1.0
pnpm dlx msw init <PUBLIC_DIR>

설치가 무사히 진행되었다면, public/mockServiceWorker.js 파일이 생성됨과, .storybook/main.ts의 addons에 msw-storybook-addon가 추가되어있음을 확인할 수 있습니다.

.storybook/main.ts
const config: StorybookConfig = {
  stories: ['../src/**/*.stories.tsx'],
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@chromatic-com/storybook",
    "@storybook/addon-interactions",
    "msw-storybook-addon"
  ],
  // ... 기타 설정
}

다음, .storybook/preview.tsx에 MSW 초기화 및 onUnhandledRequest옵션을 추가합니다.

.storybook/preview.tsx
import { initialize, mswLoader } from "msw-storybook-addon";
 
initialize({onUnhandledRequest: "bypass"}); // handle 등록되지 않은 요청을 통과시킵니다.
 
const preview: Preview = {
  loaders: [mswLoader],
  // ... 기타 설정
};

이제 MSW를 통해 API 요청을 제어할 수 있습니다. 이 블로그의 링크 카드는 OpenGraph API를 통해 데이터를 가져오고 있어서, 이를 테스트해보겠습니다.
먼저, API Mocking을 위한 src/mocks/handlers.ts를 작성합니다.

src/mocks/handlers.ts
import { http, HttpResponse } from "msw";
import { OgObject } from "open-graph-scraper/dist/lib/types";
 
const MOCK_OG_OBJECT: OgObject = {
  // MOCK DATA
};
 
// Next.js API 를 사용하고 있기에, Next.js DEV 서버 API URL을 사용합니다.
function nextApi(url: string) {
  return `http://localhost:3000/api${url}`;
}
 
function searchParam(url: string, param: string) {
  const urlObj = new URL(url);
  return urlObj.searchParams.get(param);
}
 
 
export const handlers = [
  http.get<OpenGraphReq, any, OgObject>(
    nextApi("/open-graph"),
    async ({ request}) => {
 
      const url = searchParam(request.url, "url");
      
      if( url === "https://example.com/error")
        return HttpResponse.json({}, { status: 503 });
      else if( url === "pending")
        return new Promise(() => {});
 
 
      return HttpResponse.json(MOCK_OG_OBJECT);
    }
  ),
];

이제, stories 파일에 handler를 등록하여 API를 인터셉트할 수 있습니다.

LinkedCard.stories.tsx
import { Meta, StoryObj } from "@storybook/react";
 
import { handlers } from "@/mocks/handlers";
 
import LinkedCard from "./linked-card";
 
const meta = {
  title: "ui/LinkedCard",
  component: LinkedCard,
  parameters: {
    layout: "centered",
    msw: {
      handlers,
    },
  },
  decorators: [
    (Story) => (
      <div className="w-[600px]">
        <Story />
      </div>
    ),
  ],
} satisfies Meta<typeof LinkedCard>;
export default meta;
 
type Story = StoryObj<typeof meta>;
 
export const Default: Story = {
  args: {
    title: "Title Example",
    href: "https://www.example.com",
  },
};

이 상태에서 결과를 확인하면 Mocking된 데이터로 응답받은 것을 확인할 수 있습니다.

API-Mocking

컴포넌트 테스트

테스트를 할 수 있는 환경이 다 갖춰졌습니다. 이제 Storybook의 play 함수를 사용하여 컴포넌트의 상태를 변경하며 테스트를 진행할 수 있습니다.
사용자의 상호작용이나, 스토리가 렌더링된 후 expect를 통해 렌더링된 결과를 확인할 수 있습니다.

LinkedCard.stories.tsx
export const Default: Story = {
  args: {
    value: "Copy String",
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // Click Event
    const button = canvas.getByRole("button");
    userEvent.click(button);
    await canvas.findByText("복사되었습니다.");
 
    // Clipboard
    expect(await navigator.clipboard.readText()).toBe("Copy String");
  },
};

마치며

곧 다가올 React v19와 RSC의 대중화로, RSC를 테스트하는 방법에 대한 관심이 높아질 것으로 예상됩니다.
그에 대비하여 RSC를 Storybook을 통해 테스트하는 방법에 대해 알아보았습니다.

Storybook은 CypressplaywrightNode.js에서 동작하는 것과 달리, 브라우저 환경에서 동작하기에 시각적으로 테스트를 할 수 있었습니다.
이는 요즘 복잡해지는 UI를 테스트하는데도 유용하고, 결과를 디자이너나 퍼블리셔에게 보여주기에도 좋은 도구입니다.

또한 사용자의 인터렉션을 테스트 할 때 개발자나 디자이너가 웹에 접속하여 하나씩 클릭해볼 필요가 없어지고,
play를 작성하여 자동화된 테스트를 진행한다면 더 효과적인 테스트를 할 수 있을 것입니다.

참조