📡

Repository層のテストから始める辛くないMSW

2023/08/06に公開

はじめに

MSWが登場して以来、フロントエンド開発で随分一般的になったと思います。テストで利用するのはもちろん、データフェッチを伴うコンポーネントのstorybookを作る際に、簡単な設定で済む点が非常に便利です。

https://mswjs.io/

一方でいざ導入したいとなった際に、「どこから始めるか?」をチームで合意し、日々の開発サイクルに取り入れるのは難しいのではないでしょうか?今回は現職で行なっているMSWの導入についてご紹介したいと思います。Next.jsを前提として解説しますがVueやAngularなど他のフレームワークでも参考にしていただける内容です。

対象読者

  • プロジェクトにMSWを導入したい方
  • jestのMock → MSWのMockに移行したい方

データ通信する層から移行していく

まず現在開発しているアプリケーションのデータフローについて簡単に説明します。データ通信を担当する階層をAPIAccess層(所謂Repository層)と呼んでおり、useAPIAccessXXXというカスタムフックを起点に以下の責務を担います。

  1. 受け取った引数をリクエストボディへ変換してサーバーサイドへリクエスト
  2. 受け取ったレスポンスのバリデーション
  3. レスポンスボディをフロントで用いる型に変換

例えばuseAPIAccessUserはこのような定義になっています。

export const useAPIAccessUser = () => {
  const [loading, setLoading] = useState(false);

  const getUser = useCallback(
    async (): Promise<APIResult<User[]>> => {
      setLoading(true);
      try {
        const response = await apiAccessUser.getUser();
        return { data: response.decodedData, status: response.status };
      } catch (e) {
        const error = e as ApiClientError<User>;
        return {
          errors: {
            clientError: error,
          },
          status: error.response?.status,
        };
      } finally {
        setLoading(false);
      }
    },
    []
  );

  return {
    loading,
    getUser,
  };
};

なぜデータ通信する層から移行するのか

まず前提として、既存のテストではjest-mock-axiosを使ってAPI通信をモックしている箇所が大量にあり、MSWはjest-mock-axiosと併用はできません。Componentやhooksで複数のデータ通信を行なっている場合、全てのAPIのモック定義を揃える必要があります。

一方でuseAPIAccessは自身が通信するAPIのみを関心としており、使い始めるまでのコストが低いのです。開発チームではまずuseAPIAccessのテストをMSWで書き切ることを合意し、日々の開発の中でボーイスカウト的に移行しています。

どのようにテストを書くか

テストで使う際はhandlerの定義 → ユニットテスト作成するだけで本当に簡単です!

handlerの定義

一つのAPIに対し、3つのケースを用意します。今後Componentやhooksでも利用する際には、引数で受け取ったレスポンスを返せるようにしておいても良いかもしれません。

msw/handlers/user.ts
// エンドポイントのpathは定数管理しているので流用します
const endPoint = `${
  process.env.NEXT_PUBLIC_API_ENDPOINT_BASE
}${apiEndpoints.user()}`;

export const userGetHandler = {
  // 200系が返り、レスポンスが正常な時
  successDefault: rest.get(endPoint, (req, res, ctx) => {
    return res(ctx.json(successUserResponse));
  }),

  // 200系が返り、バリデーションエラーになった時
  failedValidationError: rest.get(endPoint, (req, res, ctx) => {
    return res(ctx.json(invalidUserResponse));
  }),

  // 200系以外のステータスの時
  failedServerError: rest.get(endPoint, (req, res, ctx) => {
    return res(
      ctx.status(500),
      ctx.json({
        detail: "Internal Server Error",
      })
    );
  }),
};

ユニットテストを実装

テストを書く際は冒頭でモックサーバーをセットアップ -> 各テストケースで必要なレスポンスを設定するだけです。従来のjestでmockやspyonを使ってテストしていたケースに比べ、可読性の高いテストを書けるようになりました!

useAPIAccessUser.test.ts
// テスト冒頭でモックサーバーをセットアップ
const server = setupMockServer();

describe("#useAPIAccessUser", () => {
  describe("getUser", () => {
    test("success", async () => {
      // テストケースごとに必要なレスポンスが返るようにする
      server.use(userGetHandler.successDefault);
      const { result } = renderHook(() => useAPIAccessUser());
      await act(async () => {
        const response = await result.current.getUser();
        expect(response.data).toMatchObject(successUser);
        expect(response.status).toBe(200);
        expect(response.errors).toBeUndefined();
      });
    });

    test("failed: validation error", async () => {
      server.use(userGetHandler.failedInvalid);
      const { result } = renderHook(() => useAPIAccessUser());
      await act(async () => {
        const response = await result.current.getUser();
        expect(response.data).toBeUndefined();
        expect(response.status).toBe(400);
        expect(response.errors.clientError.message).toBe(
          "UserAPIValidation: validateUserAPIResponse"
        );
      });
    });

    test("failed: server error", async () => {
      server.use(userGetHandler.failedServerError);
      const { result } = renderHook(() => useAPIAccessUser());
      await act(async () => {
        const response = await result.current.getUser();
        expect(response.data).toBeUndefined();
        expect(response.status).toBe(500);
        expect(response.errors.clientError.message).toBe(
          "Request failed with status code 500"
        );
      });
    });
  });
});

setupMockServerという関数を定義し、テストで必要な定義をラップしています。Takepepeさんが「フロントエンド開発のためのテスト入門」の中で紹介されていた実装を参考に、デフォルト設定のjest-mock-axiosを無効にする設定を追加しています。

setupMockServer.ts
// mswを利用したテスト時はaxiosをモックしない
jest.unmock("axios");

export const setupMockServer = (
  ...handlers: RequestHandler[]
): SetupServerApi => {
  const server = setupServer(...handlers);
  beforeAll(() => server.listen());
  afterEach(() => server.resetHandlers());
  afterAll(() => server.close());
  return server;
};

おわりに

紹介した移行方針で1ヶ月ほど開発し、主要なAPIを中心にjestによるmockから切り替えることができました!今後はComponentやhooksのインテグレーションテストにも利用していく予定です。また、Aspidaを利用してより型安全にhandlerを実装する方法も検討しています。

最後に、現在は株式会社ACESにて商談解析AIツール「ACES Meet」の開発を行っています。フロントエンドエンジニアも積極採用中ですのでご応募お待ちしてます!

Discussion