MSWを用いたStorybook・テスト上で使うGraphQLモックサーバーの運用

Storybook上でコンポーネントの挙動をみたいけど、データ取得に失敗した際の挙動は確認できない。。。みたいな悩みはありませんか？私はあります。

今回はGraphQLを用いたデータ取得を行うコンポーネントに対して、mswを用いることによってデータ取得の結果に応じたコンポーネントの振る舞いをStorybook上やテスト上で確認出来るようにあれこれする内容になっています。

~~GraphQLのアドカレというよりStorybookのアドカレかもしれない~~

## 概要
本記事では以下の内容を取り扱います。

* MSWを用いたGraphQLのモックサーバーの運用
* StorybookとMSWを組み合わせたコンポーネントのカタログ化
* テスト上でのモックサーバーの運用

Query・Mutationのモックを出来るようにし、実際にStorybookやテスト上でモックすることでどういったことが出来るようになるのかという内容をメインに書いています。

## サンプルコードの説明

今回紹介するサンプルコードは公開しています。筆者はまだまだ初心者のため、掲載しているソースコードの改良点などあれば是非コメントしていただけると助かります！

https://github.com/ruritoBlogger/apollo-client-msw-storybook-sample

### コンポーネントの説明

```tsx
const GET_TODOS: DocumentNode = gql`
  query todos {
    todos {
      id
      name
      title
    }
  }
`;

export const TodoList = (): JSX.Element => {
  const { data, loading, error } = useTodosQuery();

  if (loading) {
    return <p>loading...</p>;
  }

  if (error || data === undefined) {
    return <p>error!!!</p>;
  }

  return (
    <>
      {data.todos.map(todo => {
        return (<div key={todo.id}>
         <p>{todo.name}</p>
        </div>)
      })}
    </>
  );
};

```

`Todo`一覧を取得するQueryを実行して、取得したデータの内容を表示する単純なコンポーネントです。
`graphql-codegen` を用いてQueryを実行するカスタムフックを生成しそれを用いています。

### mswのハンドラーの説明

```tsx
import { graphql } from "msw";
import { GraphQLError } from "graphql/error";
// NOTE: graphql-codegenを用いて生成された型情報
import {TodosQuery, TodosQueryVariables} from "../../generated/graphql";

interface TodosQueryHandlerInput {
  loading?: boolean;
  isError?: boolean;
}

export const todosQueryHandler = ({
  loading,
  isError,
}: TodosQueryHandlerInput) => {
  return graphql.query<TodosQuery, TodosQueryVariables>("todos", (req, res, ctx) => {
    if (loading) {
      return res(
        ctx.delay(1000 * 60 * 60 * 60),
        ctx.data({ todos: [{ id: "TODO_1", name: "delay name", title: "delay title" }] })
      );
    }

    if (isError) {
      return res(ctx.errors([new GraphQLError("error!!!")]));
    }

    return res(
      ctx.data({
        todos: [{
          id: "TODO_1",
          name: "TODO1",
          title: "test title",
        }],
      })
    );
  });
};
```
このハンドラーではTODO一覧を取得するQueryをどうやってモックするのかという設定を実装しています。引数に応じてQueryの実行結果を制御することができ、今回のコードだと以下の3通りの結果をモックサーバーから返却出来ます。

* Queryの実行に成功したケース
    * `[{id: "TODO_1", name: "TODO1", title: "test title"}]` が返却されます
* Queryが実行中のケース
    * `delay` を用いて1時間程度結果を返さないようになっています
    * 結果としてQueryが実行中という内容を表現できるようになってます
* Queryの実行に失敗したケース
    * `GraphQLError` が返却されます

今回は省略していますが、Queryの実行に成功した際に返却する内容を指定したりと幅広い表現ができます。mswすげえ。

### Storybookの説明

```tsx
export default {
  component: TodoList,
} as ComponentMeta<typeof TodoList>;

export const Default: ComponentStoryObj<typeof TodoList> = {
  name: "通常時",
  parameters: {
    msw: {
      handlers: [todosQueryHandler({})]
    },
  },
};

export const OnError: ComponentStoryObj<typeof TodoList> = {
  name: "データ取得に失敗時",
  parameters: {
    msw: {
      handlers: [todosQueryHandler({isError: true})]
    },
  },
  decorators: [disableCacheDecorator]
};

export const Loading: ComponentStoryObj<typeof TodoList> = {
  name: "データ取得中時",
  parameters: {
    msw: {
      handlers: [todosQueryHandler({loading: true})]
    },
  },
  decorators: [disableCacheDecorator]
};
```

Storybookでは、TODO一覧の取得に成功 or 失敗時と取得中の3つのパターンを用意します。
`msw-storybook-addon`を用いることによって各Storyを表示したときに指定したmswのハンドラーを実行することができます。
今回のコードでは各Storyに対して適切なmswのハンドラーを設定しています。

Storybookの画面は以下のようになります。
|Default|OnError|Loading|
|--|--|--|
|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273667/2b83248e-41ef-2629-1f31-6b6e92a20608.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273667/db5d9bbf-7fd5-bb6b-4dca-f8d73cb39158.png)|![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273667/8897137d-e5de-603f-624b-308a484d5bb6.png)|

### テストの説明
```tsx
import * as stories from "./TodoList.stories"

test("レンダリングした時, 正常に表示されること", async () => {
  const { Default } = composeStories(stories);
  const { findByText } = customRender(<Default />);

  expect(
    await findByText("TODO1" )
  ).toBeInTheDocument();
});

test("データの取得に失敗した際にエラーを表示すること", async () => {
  server.use(todosQueryHandler({isError: true}))
  const { OnError } = composeStories(stories);
  const { findByText } = customRender(<OnError />);

  expect(
    await findByText("error!!!" )
  ).toBeInTheDocument();
});

test("データの読み込み中は読み込み中であることを表示すること", async () => {
  server.use(todosQueryHandler({loading: true}))
  const { Loading } = composeStories(stories);
  const { findByText } = customRender(<Loading />);

  expect(
    await findByText("loading..." )
  ).toBeInTheDocument();
});
```
テストでは各Storyに対して意図した内容が表示されているかどうかを確認しています。
Storybookと同じく各テストに対して適切なmswのハンドラーを設定しています。
このような形でmswを用いたGraphQLのモックサーバーをStorybook・テストで使いまわすようにできます。

## メリット・デメリット

今回紹介した方法では、Storybook上でコンポーネントの振る舞いを確認出来るようにするという目的のために一部強引な実装をしている個所があり、メリット・デメリットがそれぞれ存在します。

### メリット

- Storybook上でコンポーネントの振る舞いをより正確に確認出来るようになった
  - QueryやMutationの実行に失敗した際の振る舞いまで確認出来るようになった
- `apollo/client`のキャッシュを加味した複雑なテストの実装が出来る
  - モックサーバーから返却する値を調整することで、あたかも値が追加・更新・削除出来たかのような振る舞いが出来る

### デメリット

- Storybook上で色々操作をすると、以下のgifのように意図しない挙動になってしまう場合がある
  - 各Storyで同じ`ApolloClient`を用いていることが原因だと思ってます
- Storybook上でのインタラクションテストとの相性が悪い
  - 上の意図しない挙動が影響して、テストに成功する場合とそうでない場合が発生する
- テストで一つ一つmswのハンドラーを設定する必要がある
  - Storyを用いたテストの実装をしているが、Storybookの方に実装したmswのハンドラーまではテストでは使いまわせない
  - よってテスト側でもStorybookとは別にmswのハンドラーを呼び出す必要があり、コード量が増えてしまう


意図しないStorybookの挙動
![Storybook.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/273667/eff8b0e7-b4c6-30d5-2b9d-6d4726610d69.gif)

## まとめ

今回はmswを用いたGraphQLのQueryやMutationの結果をモックすることによって、Storybookやテスト上でのコンポーネントの振る舞いの幅を広げられるようになりました。`apollo/client`のキャッシュ周りなど課題点があり、実務で使うにはまだまだ粗いですがこの記事を通して「便利そうだな～」と感じていただければ幸いです。

### 参考記事

https://storybook.js.org/addons/msw-storybook-addon

https://zenn.dev/takepepe/articles/nextjs-msw-hof