📘

[React] msw でリクエスト情報を検証したり、レスポンスをカスタマイズしたい

2024/05/20に公開

msw がいいと聞いて

最近のフロントエンド開発では、モック化に msw を使うことが多いということを聞いて、早速導入してみました。
msw とはなんぞや? みたいな部分は下記の記事がわかりやすくまとめられていて、勉強させていただきました。
https://zenn.dev/azukiazusa/articles/using-msw-to-mock-frontend-tests

これまではモック化に jest を使ってやっていましたが、今回 msw を使っての雑な感想としては、、

  • 一回モック化したらいろんなテストで使いまわせて便利
  • jest と storybook 両方で使えるの嬉しい

こんな感じです。
ただ使っている中で、

  • モック API に送られたリクエスト情報を検証したい
  • jest みたいにテスト側でレスポンスをカスタマイズしたい

⬆️ こんなことができないかなーと思っていたら、プルリクエストをレビューしてくれた方からアドバイスをもらって実現できたので、紹介させていただきます。

シンプルなモック化

公式ドキュメントを見ながら実装すると下記のような形になると思います。
シンプルな記事取得・作成 API をモック化します。

article-handlers.ts
import { http, HttpResponse } from "msw";
export const mockArticles =
  [
    {
      id: 1,
      title: "Article 1",
      content: "Content 1",
    },
    {
      id: 2,
      title: "Article 2",
      content: "Content 2",
    },
  ]
export const articleHandlers = [
  // 記事作成用モック API
  http.post(`${process.env.API_URL}/api/articles`, async ({ request }) => {
    const { title, content } = (await request.json()) as {
      title: string;
      content: string;
    };
    if (!title || !content) {
      return HttpResponse.json({ message: "Invalid request" }, { status: 400 });
    }

    // この辺りでリクエスト内容を検証したい

    return HttpResponse.json(
      {
        article: {
          id: new Date().getTime(),
          title,
          content,
        },
      },
      { status: 200 }
    );
  }),

  // 記事一覧取得用モック API
  http.get(`${process.env.API_URL}/api/articles`, async () => {
    return HttpResponse.json(
      { articles: mockArticles },    // ここの戻り値をカスタマイズできるようにしたい
      { status: 200 }
    );
  })
];

モックハンドラーをカスタマイズする

ざっくり何をどうしたか

リクエスト情報の検証・レスポンスのカスタマイズをするために下記のように改修しました。

1. モックハンドラを作成し返却する関数を作成した
2. 1に対して、リクエストの情報を引数に呼び出すためのモック関数を引数で渡すようにした
3. 1カスタムレスポンスを引数で渡すようにした

1をやると、2と3ができるようになるという感じですね。
実際にどのようにしたか書いていきます。

モックハンドラを作成し返却する関数を作成する

article-handlers-factory.ts
import { http, HttpHandler, HttpResponse } from "msw";
import { Mock } from "@storybook/test";

type HandlerFactoryParams = {
  mockFn?: jest.Mock | Mock;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  resBody?: any;
};

// ハンドラファクトリーの型定義
type HandlerFactory = (params: HandlerFactoryParams) => HttpHandler;

// 記事作成用ハンドラファクトリー
export const createPostArticleHandler: HandlerFactory = (params) => {
  const { mockFn } = params;
  return http.post(`${process.env.API_URL}/api/articles`, async ({ request }) => {
    const { title, content, imageUrl } = (await request.json()) as {
      title: string;
      content: string;
      imageUrl: string;
    };

    if (!title || !content) {
      return HttpResponse.json({ message: "Invalid request" }, { status: 400 });
    }

    // リクエストを検証するために mockFn を呼び出す
>   mockFn?.(title, content, imageUrl);

    return HttpResponse.json(
      {
        article: {
          id: new Date().getTime(),
          title,
          content,
          imageUrl,
        },
      },
      { status: 200 }
    );
  });
}

// 記事取得用ハンドラファクトリー
export const createGetArticlesHandler: HandlerFactory = (params) => {
  const { resBody } = params;
  return http.get(`${process.env.API_URL}/api/articles`, async () => {
    return HttpResponse.json(
      // resBody が指定されている場合はそれを返す
>     { articles: resBody ?? mockArticles },
      { status: 200 }
    );
  });
}

モックハンドラに対して、テスト側から値を渡せるように関数化する感じですね。
引数でリクエスト検証用のモック関数と、任意のレスポンスを渡せるようにしました。

実際にテストで使う

すでにイメージできている方も多いかと思いますが、最後に先ほど作成した関数を実際にテストで使ってみます。

jest で記事取得をテストする

jest では、server.use() 関数を使って動的にハンドラを登録するような実装になります。

describe('Article', () => {
  beforeAll(() => {
    server.listen();
  });
  afterEach(() => {
    jest.resetAllMocks();
  });
  afterAll(() => {
    server.close();
  });

  it('should get articles', async () => {
>   server.use(
>     createGetArticlesHandler({
>       // ここでレスポンスを上書き
>       resBody: [
>         ...mockArticles,
>         { id: 3, title: 'Title 3', content: 'Content 3' }
>       ],
>     }),
    );

    // useArticle は内部で記事取得 API をコールし結果を返します。
    const { result } = renderHook(() => useArticle());

    await waitFor(() => {
      expect(result.current.articles).toHaveLength(3);
      expect(result.current.articles[2].id).toBe(3);
    });
  });
});

storybook で記事作成をテストする

storybook では、parameters にハンドラを登録します。

const mockFn = fn();
export const PostArticle: Story = {
> parameters: {
>   msw: {
>     handlers: [createPostArticleHandler({ mockFn })],
>   },
  },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);

    // enter title
    const titleInput = canvas.getByPlaceholderText('Title');
    const title = 'Hello world!';
    await userEvent.type(titleInput, title);

    // enter content
    const contentInput = canvas.getByPlaceholderText('Content');
    const content = 'This is a test article.';
    await userEvent.type(contentInput, content);

    // submit form
    const submitButton = canvas.getByText('Create Article');
    await userEvent.click(submitButton);

    await waitFor(() => {
        // リクエストが正しく送信されているかをチェックする
      expect(mockFn).toHaveBeenCalledWith(title, content);
    });
  },
};

おまけ: initial handlers と runtime handlers

公式ドキュメントを見てみると、大きく分けて2種類のハンドラがあるようでした。
それぞれを簡潔に説明すると

  • initial handlers
    msw が初期化される際に登録されるリクエストハンドラ。setupWorker() setupServer()関数で登録できる。

  • runtime handlers
    アプリケーション(やテスト)の実行中に動的に追加できるリクエストハンドラ。.use()関数で登録できる。

とのことです。
イメージがしやすいように、公式のサンプルコードを載せておきます。

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
 
const server = setupServer(
  // These are the initial request handlers.
  http.get('/resource', () => {
    return HttpResponse.text('Fallback')
  })
)
 
server.use(
  // These are the runtime request handlers.
  http.post('/login', () => {
    return new HttpResponse()
  })
)

今回のように、テストごとにハンドラの内容を変えたい場合には、runtime handlers を使うのがいいということですね。

でも、毎回ハンドラ設定するのめんどくない?

テストによっては、リクエストの検証やレスポンスのカスタマイズなどが不要で、固定値を返すモックさえあればいい、なんてケースもあると思います。

そんな時は、 initial handlers をあらかじめ設定しておき、必要なテストの時だけ、 runtime handlers を使って上書きするのが良さそうです。

すでに setupServer() などでハンドラが設定されているパスに対しても、 .use() 関数を呼び出すことでハンドラを上書きすることができます。

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
 
const server = setupServer(
  http.get('/resource', () => {
    return HttpResponse.text('Fallback')
  })
)
 
server.use(
  http.get('/resource', () => {
    return HttpResponse.text('Override')
  })
)

こうしておけば、テスト側で毎回動的にハンドラを登録しなくてもいいので楽ですね。

終わりに

テスト用に API をモックする場合、今回のようにレスポンスをテスト側で指定したい需要が一定あるんじゃないかと思っています。

今回紹介したやり方でも、割と柔軟にモック API を扱えるようになったなーと思っていますが、もっとベターなアプローチがないかは模索していきたいです。

この記事がどなたかの参考になれば幸いです 🙏

Discussion