テストコード側での MSW による API モック定義を抹殺し Storybook 側に寄せる方法

この記事は [READYFOR Advent Calendar 2022](https://qiita.com/advent-calendar/2022/readyfor) の20日目の記事です。

![テストコード側での MSW による API モック定義を抹殺し Storybook 側に寄せる方法](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/83038/1dcfe27e-a7bc-2b2a-129a-7a5ea28b3dea.png)

## はじめに

こんにちは。READYFOR でフロントエンドエンジニアとして働いている菅原（[@kotarella1110](https://twitter.com/kotarella1110)）です！
READYFOR では Storybook 駆動な開発を行なっており、[Mock Service Worker (以降 MSW)](https://mswjs.io/) で API をモックし [Storybook](https://storybook.js.org) のストーリー単位で [Jest](https://jestjs.io) と [Testing Library](https://testing-library.com) を使用してテストを実施しています。
本日はタイトルにもある通り、より Storybook 駆動な開発にするための「テストコード側での MSW によるモック定義を抹殺し Storybook 側に寄せる方法」を紹介したいと思います！

説明用に Example を用意しておきました👍

https://github.com/kotarella1110-sandbox/msw-storybook-testing-example

## 対象読者

- Storybook と MSW を用いたテストの経験がある方
- Storybook のストーリー側とテストコード側の MSW によるモック定義が重複しているのを一つにしたい方
- Storybook 駆動な開発をしていきたい方

## READYFOR でのストーリー単位のテスト方法

※ [こちらのブランチ](https://github.com/kotarella1110-sandbox/msw-storybook-testing-example/tree/template)にチェックアウトして実際に手を動かすと理解しやすいかと思います！

`.storybook/preview.js` で [`msw-storybook-addon`](https://storybook.js.org/addons/msw-storybook-addon) の `initialize` を実行して MSW の初期化を行い、`mswDecorator` をグローバルなデコレーターとして提供することにより、Storybook で MSW を有効にすることが可能です。

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

initialize();

export const decorators = [mswDecorator];
```

ストーリー側では、ストーリー毎に MSW のハンドラーを渡して API モック定義するだけです。

```tsx:src/App/index.stories.tsx
import type { ComponentMeta, ComponentStoryObj } from "@storybook/react";
import { rest } from "msw";
import { App } from ".";
import { swrDecorator } from "../lib/storybook";
import { handlers } from "../mocks/handlers";

export default {
  component: App,
  decorators: [swrDecorator],
} as ComponentMeta<typeof App>;

export const Story1: ComponentStoryObj<typeof App> = {
  parameters: {
    msw: {
      handlers,
    },
  },
};

export const Story2: ComponentStoryObj<typeof App> = {
  parameters: {
    msw: {
      handlers: [
        rest.get("/profile", (_, res, ctx) =>
          res(
            ctx.json({
              firstName: "Taro",
              lastName: "Nihon",
            })
          )
        ),
        ...handlers,
      ],
    },
  },
};

export const Story3: ComponentStoryObj<typeof App> = {
  parameters: {
    msw: {
      handlers: [
        rest.get("/profile", (_, res, ctx) => res(ctx.status(500))),
        ...handlers,
      ],
    },
  },
};
```

この状態で以下のようなテストを実行するとどうなるでしょうか？
当たり前ですが、ストーリー毎に定義した API モックはテスト側では反映されないため、テストに失敗してしまいます。

```tsx:src/App/index.test.tsx
import { composeStories } from "@storybook/testing-react";
import { render, screen } from "@testing-library/react";
import { rest } from "msw";
import { handlers } from "../mocks/handlers";
import { server } from "../mocks/server";
import * as stories from "./index.stories";

const { Story1, Story2, Story3 } = composeStories(stories);

test("ストーリー1", async () => {
  render(<Story1 />);
  expect(await screen.findByText("Kotaro")).toBeInTheDocument();
  expect(await screen.findByText("Sugawara")).toBeInTheDocument();
});

test("ストーリー2", async () => {
  render(<Story2 />);
  expect(await screen.findByText("Taro")).toBeInTheDocument();
  expect(await screen.findByText("Nihon")).toBeInTheDocument();
});

test("ストーリー3", async () => {
  render(<Story3 />);
  expect(await screen.findByText("failed to load")).toBeInTheDocument();
});
```

そのため、以下のように `jest.setup.ts` で MSW を有効にし、

```diff_typescript:jest.setup.ts
import { server } from "./src/mocks/server";

+ beforeAll(() => server.listen());
+ afterEach(() => server.resetHandlers());
+ afterAll(() => server.close());
```

テストケース毎にストーリーで渡したハンドラーと全く同じハンドラーをテスト側でも MSW の [use](https://mswjs.io/docs/api/setup-server/use) API に渡して API モック定義を上書きする必要があります。

```diff_tsx:src/App/index.test.tsx
import { composeStories } from "@storybook/testing-react";
import { render, screen } from "@testing-library/react";
import { rest } from "msw";
import { handlers } from "../mocks/handlers";
import { server } from "../mocks/server";
import * as stories from "./index.stories";

const { Story1, Story2, Story3 } = composeStories(stories);

test("ストーリー1", async () => {
+  server.use(...handlers);
  render(<Story1 />);
  expect(await screen.findByText("Kotaro")).toBeInTheDocument();
  expect(await screen.findByText("Sugawara")).toBeInTheDocument();
});

test("ストーリー2", async () => {
+  server.use(
+    rest.get("/profile", (_, res, ctx) =>
+      res(
+        ctx.json({
+          firstName: "Taro",
+          lastName: "Nihon",
+        })
+      )
+    )
+  );
  render(<Story2 />);
  expect(await screen.findByText("Taro")).toBeInTheDocument();
  expect(await screen.findByText("Nihon")).toBeInTheDocument();
});

test("ストーリー3", async () => {
+  server.use(rest.get("/profile", (_, res, ctx) => res(ctx.status(500))));
  render(<Story3 />);
  expect(await screen.findByText("failed to load")).toBeInTheDocument();
});
```

ただ、この状態だと Story 側で渡しているハンドラーに修正が入った場合に、テスト側で渡しているハンドラーも修正する必要があるため、かなり冗長です。
そのため、READYFOR では、以下のようにストーリー毎のパラメーターから handler を取得して渡すようにしてテストをしていました。

```diff_tsx:src/App/index.test.tsx
import { composeStories } from "@storybook/testing-react";
import { render, screen } from "@testing-library/react";
import { server } from "../mocks/server";
import { handlers } from "../mocks/handlers";
import * as stories from "./index.stories";

const { Story1, Story2, Story3 } = composeStories(stories);

test("ストーリー1", async () => {
-  server.use(...handlers);
+  server.use(...Story1.parameters?.msw?.handlers);
  render(<Story1 />);
  expect(await screen.findByText("Kotaro")).toBeInTheDocument();
  expect(await screen.findByText("Sugawara")).toBeInTheDocument();
});

test("ストーリー2", async () => {
-  server.use(
-    rest.get("/profile", (_, res, ctx) =>
-      res(
-        ctx.json({
-          firstName: "Taro",
-          lastName: "Nihon",
-        })
-      )
-    )
-  );
+  server.use(...Story2.parameters?.msw?.handlers);
  render(<Story2 />);
  expect(await screen.findByText("Taro")).toBeInTheDocument();
  expect(await screen.findByText("Nihon")).toBeInTheDocument();
});

test("ストーリー3", async () => {
-  server.use(rest.get("/profile", (_, res, ctx) => res(ctx.status(500))));
+  server.use(...Story3.parameters?.msw?.handlers);
  render(<Story3 />);
  expect(await screen.findByText("failed to load")).toBeInTheDocument();
});
```

ここまでのコードは[こちら](https://github.com/kotarella1110-sandbox/msw-storybook-testing-example/tree/bad)です。

だいぶスッキリしましたが、ストーリー側とテストコード側両方で MSW による API モック定義をする必要があるのは手間ですし、テストコード側でハンドラーの指定ミス（例えば、Story3 のテストで Story2 のハンドラーを渡してしまうなど）が発生する可能性はまだあります。

## テストコード側での MSW による API モック定義を抹殺し、Storybook 側に寄せる方法

`msw-storybook-addon` のソースを読んだタイミングで気付いたのですが、テストのセットアップ段階で [`msw-storybook-addon`](https://storybook.js.org/addons/msw-storybook-addon) の `initialize` が実行され、`mswDecorator` が適用されてさえいれば、テスト側での MSW の有効化・ハンドラー指定が不要になります。
これらの設定は `.storybook/preview.js` で実施しているため、これがテストのセットアップ段階で読み込まれていれば良いわけです。それを行うには以下のように修正します。
`.storybook/preview.js` を import することで `initialize` が実行され `@storybook/testing-react` の `setGlobalConfig` を使用することで `.storybook/preview.js` でグローバルデコレーターとして定義した `mswDecorator` が適用されます。

```diff_typescript:jest.config.ts
- import { server } from "./src/mocks/server";
+ import { setGlobalConfig } from "@storybook/testing-react";
+ import * as globalStorybookConfig from "./.storybook/preview";

- beforeAll(() => server.listen());
- afterEach(() => server.resetHandlers());
- afterAll(() => server.close());
+ setGlobalConfig(globalStorybookConfig as any);
```

そして、テスト側のハンドラーの指定を削除します。

```diff_tsx:src/App/index.test.tsx
import { composeStories } from "@storybook/testing-react";
import { render, screen } from "@testing-library/react";
- import { server } from "../mocks/server";
import * as stories from "./index.stories";

const { Story1, Story2, Story3 } = composeStories(stories);

test("ストーリー1", async () => {
-  server.use(...Story1.parameters?.msw?.handlers);
  render(<Story1 />);
  expect(await screen.findByText("Kotaro")).toBeInTheDocument();
  expect(await screen.findByText("Sugawara")).toBeInTheDocument();
});

test("ストーリー2", async () => {
-  server.use(...Story2.parameters?.msw?.handlers);
  render(<Story2 />);
  expect(await screen.findByText("Taro")).toBeInTheDocument();
  expect(await screen.findByText("Nihon")).toBeInTheDocument();
});

test("ストーリー3", async () => {
-  server.use(...Story3.parameters?.msw?.handlers);
  render(<Story3 />);
  expect(await screen.findByText("failed to load")).toBeInTheDocument();
});
```

実際の差分は[こちら](https://github.com/kotarella1110-sandbox/msw-storybook-testing-example/pull/1)です。

この状態でテストを実行するとパスします！
無事、テストコード側での MSW による API モック定義を抹殺し Storybook 側に寄せることができました 🎉

## 最後に

いかがだったでしょうか？
本記事の内容に関して、`msw-storybook-addon` の README に記載されているわけでもなく、あまり出回ってなさそうな情報だったので紹介してみました！（`msw-storybook-addon` のソースをもっと早く読んでおけば良かったと後悔しています・・・普段から自分の使用しているライブラリのコードリーディングをすることは大切だなと改めて感じました！笑）
Storybook 側に API のモック定義を寄せることで、ストーリーを用意しなければテストができない状況を作り出すことができ、より Storybook 駆動な開発が促進されると思います！
もし自分と同じよう悩みを持っていた方がいれば是非参考にしてもらえればと思います。