📸

MSW を活用した WebAPI リグレッションテスト

2023/04/24に公開
2

MSW はネットワークレベルでリクエストをインターセプトする、自動テストで便利なモックサーバーです。過去記事でも紹介したとおり、スパイ(モック関数)をネットワークレベルに忍ばせることが可能です。実際に WebAPI が呼ばれた時の Payload の検証は、Jest 組み込みのモック機能では実現できない領域です。本稿は「WebAPI リグレッションテスト」を実施するための、MSW 活用方法を解説します。記事で使用しているサンプルコードはこちら

課題の概要

Web アプリケーションページのほとんどは「1.UI を表示し/2.入力操作し/3.WebAPI 通信し/4.通信後処理を行う」という一連処理が責務です。このようなページに書かれるテストは、WebAPI 通信前後に集中しがちです。MSW を使用すると、以下の図の様に「送信後処理」まで到達可能なため、例えば「WebAPI レスポンスが返ってきたら、特定画面へ遷移すること」というアサーションが書けるようになります。(※以降、図中の緑枠はアサーション箇所を示す)

MSW のインターセプト

しかし、このテストでは「送信値に期待しない不具合が生じていないか?」を検証するには不十分です。最終的に値が送信されるまでの間に「差分」が入り込む余地はたくさんありますが、送信後処理のテストに影響しないケースはよくあります。なぜなら MSW が、リクエスト Payload を検証するために用意されたのではなく、送信後処理のために用意されているからです。

Payload リグレッション

もしそれぞれの処理が綺麗にモジュール分割されており、単体テストが書かれていたのなら、そのままリグレッションテストとして活きるでしょう。しかし、単体テストが書かれておらず「これからリファクタリングをしたいから、リグレッションテストが必要」という状況下においては、別のアプローチを検討しなければなりません。

Payload のリグレッションテスト

「リファクタリング前と変わらない Payload が送信されることを担保したい」というニーズには 「WebAPI リグレッションテスト」 が便利です。

テスト対象の確認

テスト対象として簡単なサンプルを用意しました。value.trimで入力値前後の余白が取り除かれますが、この処理が欠落した場合、Payload は期待値とは異なるものとなってしまいます。

string 型からは変わっていないため、TypeScript でコンパイルエラーにならない抜け穴です。「optional 値が送信されなくなった」という変化に気付けないケースも想定できます。

import { createPost } from "@/services/clients/posts";
import { useState } from "react";

export const ExamplePage = () => {
  const [value, setValue] = useState<string>("");
  return (
    <div>
      <h1>Example Page</h1>
      <form
        onSubmit={async (event) => {
          event.preventDefault();
          // 入力値前後の余白は取り除く
          const input = value.trim();
          // 値を送信するまでの間に多数の処理がある場合、さらに懸念が増す
          const data = await createPost({ payload: { value: input } });
          router.push(`/path/to/page/${data.id}`);
        }}
      >
        <input
          type="text"
          value={value}
          onChange={(event) => {
            setValue(event.target.value);
          }}
        />
        <button>submit</button>
      </form>
    </div>
  );
};

.snap を活用したリグレッションテスト

Jest には標準機能として「スナップショットテスト」が備わっていて、一般的に UI コンポーネントのレンダリング結果に対するリグレッションテストとして活用されています。このスナップショットは、テキストファイルとして「.snap」ファイルに残るため、対象は UI である必要はなく、オブジェクトやプリミティブも保存できます。これを 「WebAPI Payload のスナップショットとして活用する」 のが、WebAPI リグレッションテストです。

import { composeStories } from "@storybook/react";
import * as stories from "./index.stories";
import { waitFor } from "@testing-library/react";
import { mockCreatePost } from "@/services/clients/posts/index.mock";
import { setupMockServer } from "@/tests/jest/msw";
import { renderThenPlay } from "@/tests/jest/storybook";

const server = setupMockServer();
const { NormalInput, WithWhiteSpaceInput } = composeStories(stories);

test("送信値に空白が含まれるパターン", async () => {
  // WebAPI の payload を記録するスパイを用意
  const mockFn = jest.fn();
  // MSW のハンドラーを設定
  server.use(mockCreatePost({ mockFn }));
  // Story をレンダリングして PlayFunction を再生する
  await renderThenPlay(WithWhiteSpaceInput);
  await waitFor(() => expect(mockFn).toHaveBeecCalledTimes(1));
  // スナップショットテストをアサーションとして使用
  expect(mockFn.mock.lastCall[0]).toMatchSnapshot();
});

payload はこの様な内訳で保存されます。スナップショットテストは細かな差分も検知するため、「リファクタリング前と変わらない Payload が送信されることを担保したい」 という目的に適しています。

exports[`送信値に空白が含まれるパターン 1`] = `
{
  "payload": {
    "value": "abcd",
  },
}
`;

今回の例は最小構成のため「Payload のリグレッションテスト」としていますが、他にも MSW ハンドラーを工夫すれば、リクエストヘッダーも同様にスナップショットに残せます。モックデータを対象にした不毛なアサーション記述も不要ですし「どこまで詳細にアサーションを書くか?」という指針も不要なので、リグレッションテストが目的ならどんどんスナップショットは活用すべきだと筆者は考えています。

ただし、スナップショットテスト対象の変更頻度が高い場合「そこの差分は検知しなくても良い」と感じることもあります。自動テストに対してこういった印象が重なると、誰も結果を確認せず更新するだけというように、形骸化するケースもよくあります。なんとなくコミットするのではなく、ドキュメントにあるようにテスト対象外はexpect.anyを活用するなどして、テストの目的をきちんと持つことが大切です。

Storybook 運用と併せて

今回のサンプルでは、Story をテストケースとして成立するように組んでいます。.snapファイルを使用した UI リグレッションテストは便利で、 Play Function 再生後の UI も、.snapファイルに残すことができます。

  • WebAPI リグレッションテスト
  • Play Function 再生前の、UI スナップショットテスト
  • Play Function 再生後の、UI スナップショットテスト
  • Play Function 再生前の、ビジュアルリグレッションテスト
  • Play Function 再生後の、ビジュアルリグレッションテスト

Storybook・MSW があれば、目的に応じた多様なテスト手法が展開可能なため、おすすめです。今回のサンプルは以下に置いていますので、このあたりのコメントアウトを外して、テストが失敗する様子を確認してみてください。

https://github.com/takefumi-yoshii/frontend-webapi-snapshot-testing

もちろん全てのテスト手法を採用する必要はなく「必要に応じて」随時判断すると良い様に思います。

  • WebAPI 送信値 Payload を作成する際、様々な処理を挟んでいる
    • リファクタリングの際に、壊れてしまうかもしれない
      • WebAPI リグレッションテストを書く
  • WebAPI 受信値 Paylaod を受け取り UI 表示までに、様々な処理を挟んでいる
    • 最終的に出力される UI は、PC/SP の見た目が異なる
      • ビジュアルリグレッションテストを書く
      • ビジュアルリグレッションテストがあるから、UI リグレッションテスト(.snap)は書かない
  • WebAPI 受信値 はなく、TypeScript で故障が検知できる
    • リグレッションテストは不要

追記

Story ファイルを jsdom で実行するために提供されていた @storybook/testing-react ですが、Storybook7.0 でファーストクラスの Storybook 機能に昇格したそうです。今後は @storybook/react から import してね、とのことです。

https://github.com/storybookjs/testing-react/issues/143

Discussion

masashimasashi

いつも楽しく、記事を読ませていただいております!
今回の記事も実際に試して、うまくリファクタリングができました
本当にありがとうございます

実際に試した上で1点だけ気になったことがございます
それは下記のアサーションでESLintエラーが発生します

  // スナップショットテストをアサーションとして使用
  await waitFor(() => expect(mockFn.mock.lastCall[0]).toMatchSnapshot());

これはどうやらtesting-library/no-wait-for-snapshotが原因のようです
したがってwaitFor内部でスナップショットを呼ぶことはtesting-libraryでは非推奨のようです
このESLintエラーを自然に回避できないかと考えてみました

  await waitFor(() => expect(mockFn).toHaveBeecCalledTimes(1));

  // スナップショットテストをアサーションとして使用
  expect(mockFn.mock.lastCall[0]).toMatchSnapshot()

mockFnの呼び出しの確認後にスナップショット比較を行えば、testing-library推奨の方法に則れます
ご検討いただけると幸いです

TakepepeTakepepe

ご指摘ありがとうございます!わたしもご提案いただいた内容が良いと思います。記事・サンプルコードともに修正いたしました。ありがとうございます。