🪤

MSW で意図しないリクエストまでハンドリングされてハマった話

2024/11/22に公開

こんにちは、steshima です。

ソーシャル PLUS ではフロントエンドの開発に MSW を用いて、ローカルサーバーを使った開発や spec、Storybook などに役立てています。

今回、spec を実装していて MSW の仕様を知らずハマったポイントがあったので、この記事で紹介します。

ソーシャル PLUS の MSW を使った開発環境

ソーシャル PLUS のフロントエンドでは、MSW の request handler を一つのファイルにまとめて定義しています。

export const handlers: readonly RequestHandler[] = [
  typedRest.foo.$get(async (_, res, ctx) =>
    res(ctx.status(200), ctx.json({ ... })),
  ),
  typedRest.foo.$post(async (_, res, ctx) =>
    res(ctx.status(201), ctx.json({ ... })),
  ),
  ...
];

typedRest... の部分は mswpida というライブラリを使ったもので、内部的には MSW の request handler を返しています。

mswpida はフロントエンドのチームリーダーの方が作成したもので非常に便利なので、詳しくはこちらを是非読んでみてください。

https://zenn.dev/socialplus/articles/type-safe-msw-with-mswpida

そして、個別の spec や Storybook で上記に定義したものと違うレスポンスを返したい場合は、そのファイル内で上書きするようにしています。

ハマった箇所

今回ハマったのは、spec で request handler を上書きしていた場合でした。
テスト内容は下記です。

  • テスト対象
    • 商品情報の詳細画面コンポーネント
  • 登場するエンドポイント
    • foo/bar/products/:id 商品詳細情報取得
    • foo/bar/products/archived アーカイブされた商品の一覧取得
      • render 時に1度だけリクエストされる
  • テストで確認したいこと
    • 更新ボタンを押すと商品詳細情報取得リクエスト(foo/bar/products/:id)が再度実行される
      • テストコード上ではリクエストが render 時と再リクエストで計2回実行されることを確認する

コードにすると下記のようなものです。

describe('Page', () => {
  // MSW の `setupServer` を呼び出し、全エンドポイントの request handler を設定したもの
  const server = setupTestMockServer();

  it('商品名を編集して更新ボタンをクリックすると、商品情報が更新される', async () => {
  const fetchDetailCallCountMock = jest.fn();
    server.use(
      // 詳細情報取得リクエスト
      // foo/bar/products/:id
      messageTypedRest.foo.bar.products._identifier.$get(
        async (_, res, ctx) => {
          fetchDetailCallCountMock();
          return res(ctx.status(200), ctx.json({ ... }));
        },
      ),
    );

    render(<Page id="dummy-id" />, {
      wrapper: CustomWrapper,
    });

    ...
    // 詳細情報取得リクエストが render 時と再取得処理で2回実行されることを確認
    expect(fetchDetailCallCountMock).toHaveBeenCalledTimes(2);
    ...
  })
})

コードの通り、fetchDetailCallCountMock が render 時に1回、再リクエストで1回の計2回リクエストされてくれれば期待通りの動きになります。

しかしテストを実行してみると、なぜか 3 回リクエストされていました🤔

軽くログを仕込んでリクエストの内容を見てみると、意図しないリクエストが先ほどの request handler でハンドリングされていることがわかりました。

foo/bar/products/dummy-id
foo/bar/products/archived // 意図しないエンドポイントへのリクエスト
foo/bar/products/dummy-id

foo/bar/products/archived はたしかにコンポーネントからリクエストを飛ばしてはいるものの、先ほどの request handler でハンドリングされるのは意図しない挙動です🤔

原因と MSW の仕様

原因は、MSW の Execution order によるものでした。

ドキュメントにある通り、request handler は定義された順に実行されます。

そのため今回でいうと foo/bar/products/:id を spec 側で上書きすることで最初に実行されるようになり、foo/bar/products/:id:id は動的なパスなので、全く別のリクエストである foo/bar/products/archived もマッチ対象になり、foo/bar/products/:id の request handler でハンドリングされていました。

ひとまずの対応として、ここでは foo/bar/products/archived の request handler も上書きし、具体的なパスが先にマッチされるように順番を調整しました。

describe('Page', () => {
  // MSW の `setupServer` を呼び出し、全エンドポイントの request handler を設定したもの
  const server = setupTestMockServer();

  it('商品名を編集して更新ボタンをクリックすると、商品情報が更新される', async () => {
  const fetchDetailCallCountMock = jest.fn();
    server.use(
      // foo/bar/products/archived
      // 具体的なパスが先に実行されるように上書き
      messageTypedRest.foo.bar.products.archived.$get(
        async (_, res, ctx) => {
          return res(ctx.status(200), ctx.json({ ... }));
        },
      ),
      // 詳細情報取得リクエスト
      // foo/bar/products/:id
      messageTypedRest.foo.bar.products._identifier.$get(
        async (_, res, ctx) => {
          fetchDetailCallCountMock();
          return res(ctx.status(200), ctx.json({ ... }));
        },
      ),
    );

    render(<Page id="dummy-id" />, {
      wrapper: CustomWrapper,
    });

    ...
    // 詳細情報取得リクエストが render 時と再取得処理で2回実行されることを確認
    expect(fetchDetailCallCountMock).toHaveBeenCalledTimes(2);
    ...
  })
})

今回は spec で上書きしていた特殊なケースですが、よくあるような全てのリクエストをモックするために request handler をまとめて定義する場合でも、知らないとハマることがあるかもしれません。

まとめ

MSW の Execution order の仕様通り、具体的なパスを先に定義するようにしましょう。

また MSW はドキュメントを読むと実装の詳細に依存するなどの理由からリクエストのテストは避けるべきで、例外的に確認したい場合は Life-cycle events API を使ってテストするようにとあるので、こちらを使えば今回のようなケースの場合は未然にハマらないように防げそうです。

SocialPLUS Tech Blog

Discussion