25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

READYFORAdvent Calendar 2022

Day 20

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

Last updated at Posted at 2022-12-20

この記事は READYFOR Advent Calendar 2022 の20日目の記事です。

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

はじめに

こんにちは。READYFOR でフロントエンドエンジニアとして働いている菅原(@kotarella1110)です!
READYFOR では Storybook 駆動な開発を行なっており、Mock Service Worker (以降 MSW) で API をモックし Storybook のストーリー単位で JestTesting Library を使用してテストを実施しています。
本日はタイトルにもある通り、より Storybook 駆動な開発にするための「テストコード側での MSW によるモック定義を抹殺し Storybook 側に寄せる方法」を紹介したいと思います!

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

対象読者

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

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

こちらのブランチにチェックアウトして実際に手を動かすと理解しやすいかと思います!

.storybook/preview.jsmsw-storybook-addoninitialize を実行して MSW の初期化を行い、mswDecorator をグローバルなデコレーターとして提供することにより、Storybook で MSW を有効にすることが可能です。

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

initialize();

export const decorators = [mswDecorator];

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

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 モックはテスト側では反映されないため、テストに失敗してしまいます。

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 を有効にし、

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

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

テストケース毎にストーリーで渡したハンドラーと全く同じハンドラーをテスト側でも MSW の use API に渡して API モック定義を上書きする必要があります。

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 を取得して渡すようにしてテストをしていました。

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();
});

ここまでのコードはこちらです。

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

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

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

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);

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

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();
});

実際の差分はこちらです。

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

最後に

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

25
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?