🃏

リーダブルなテストのための、jest モックファクトリー関数

2022/09/01に公開

単体テストを書く時、モジュール間の関連を検証するため、一部のモジュールをモックする必要が出てくることがあります。モックは様々な手法がありますが、書き方によって、メンテナンス性やテストの可読性が変わります。一般的に行われるモック手法を確認しつつ、よりリーダブルなテストを書く方法を紹介します。

ログイン API を呼び出す Web API クライアント

今回紹介する、モック対象の Web API クライアントです。Native Fetch API を関数でラップした、自作の Web API クライアント(ログインするためのlogin関数)です。

src/services/Login/index.ts
export type Data = {
  redirectUrl: string;
};
export type Input = {
  email: string;
  password: string;
};

export async function login(input: Input): Promise<Data> {
  const body = JSON.stringify(input);
  return fetch(path(), {
    method: "POST",
    body,
    headers: defaultHeaders,
  }).then(handleResolve);
}

このような Web API クライアントを関数として分割・モジュール化しておくと、この関数が返す値をテスト単位で変更することができます。

ログイン画面のテスト

login関数を使用している画面のテストを見ていきましょう。login関数の戻り値次第で、ログイン画面の振る舞いは変わります。つまり、この関数が Resolve/Reject することによって、前後でどういった処理が行われるかが検証できます。

test.todo("ログインできなかった時、エラーメッセージが表示される");
test.todo("メンテナンス中の時、メンテナンス画面にリダイレクトする");
test.todo("ログインできた時、トップ画面にリダイレクトする");

これら条件を検証すため、どのようにテストを書くのか見ていきましょう。

一般的なモック方法

つぎのようにjest.spyOnを使うことで、@/services/Loginから export されているlogin関数をモックすることができます。login関数は非同期関数ですので、mockResolvedValueOncemockRejectedValueOnceを定めることで、モックを施すことができます。

import * as Login from "@/services/Login";
// モックできるようにファイル冒頭で宣言する
jest.mock("@/services/Login");

test("ログインできたとき、トップにリダイレクトする", async () => {
  jest.spyOn(Login, "login").mockResolvedValueOnce({
    redirectUrl: "/",
  });
  // ...省略
  expect(singletonRouter).toMatchObject({ asPath: "/" });
});
test("メンテナンス中の時、メンテナンス画面にリダイレクトする", async () => {
  jest.spyOn(Login, "login").mockResolvedValueOnce({
    redirectUrl: "/maintenance",
  });
  // ...省略
  expect(singletonRouter).toMatchObject({ asPath: "/maintenance" });
});
test("ログインできなかった時、エラーメッセージが表示される", async () => {
  jest.spyOn(Login, "login").mockRejectedValueOnce({
    message: "failed",
  });
  // ...省略
  expect(screen.getByText("ログインに失敗しました")).toBeInTheDocument();
});

モックファクトリー関数を使ったモック方法

このテストを読みやすくするため、mockLoginという「モックファクトリー関数」を用意します。この関数を使用すると、先ほどのテストと同等の内容が次の様に書けます。

import { mockLogin } from "@/services/Login/index.mock";

test("ログインできたとき、トップにリダイレクトする", async () => {
  mockLogin({ email: "taro@valid.com" });
  // ...省略
  expect(singletonRouter).toMatchObject({ asPath: "/" });
});
test("メンテナンス中の時、メンテナンス画面にリダイレクトする", async () => {
  mockLogin({ email: "taro@maintenance.com" });
  // ...省略
  expect(singletonRouter).toMatchObject({ asPath: "/maintenance" });
});
test("ログインできなかった時、エラーメッセージが表示される", async () => {
  mockLogin({ email: "taro@invalid.com" });
  // ...省略
  expect(screen.getByText("ログインに失敗しました")).toBeInTheDocument();
});

冒頭で宣言していたjest.mock("@/services/Login");が消えています。そして、テスト毎にどういった状況再現のためモックを設定しているのか、一目瞭然です。

モックファクトリー関数内訳

モックファクトリー関数がどのように定義されていたのか見ていきましょう。"@/services/Login"は、次の様なファイル構成になっていました。

├── index.mock.ts
└── index.ts

index.mock.tsの実装内訳は次のとおりです。このファイル冒頭で、jest.mock(".");を先に宣言してしまうのがポイントです。引数emailを条件分岐の判断材料とし、モックインスタンスをいくつかのバリエーションで return するようにします。これで、login関数の振る舞いを模すことができます。

src/services/Login/index.mock.ts
import * as Login from ".";
// ここでモックできるように宣言してしまう
jest.mock(".");

const defaultInput: Login.Input = {
  email: "taro@valid.com",
  password: "abcd1234",
};

export function mockLogin(mockInput?: Partial<Login.Input>) {
  // 入力内容をテスト毎に上書きできるようにしておく
  const input = { ...defaultInput, ...mockInput };
  // 対象関数のモックインスタンスを生成
  const spy = jest.spyOn(Login, "login");
  // 引数 email によってレスポンスを変更する
  switch (input.email) {
    case "taro@invalid.com":
      return spy.mockRejectedValueOnce({
        message: "failed",
      });
    case "taro@maintenance.com":
      return spy.mockResolvedValueOnce({
        redirectUrl: "/maintenance",
      });
    default:
      return spy.mockResolvedValueOnce({
        redirectUrl: "/",
      });
  }
}

email入力値によって分岐するので、switch 文にあわせ、String Literal の Union 型を施してみます。テストコードで入力値を誤るということもなくなりますし、エディターで文字列がサジェストされるため、より便利になります。

type MockInput = Partial<
  Omit<Login.Input, "email"> & {
    email: "taro@valid.com" | "taro@invalid.com" | "taro@maintenance.com";
  }
>;
export function mockLogin(mockInput?: MockInput) {
  const input = { ...defaultInput, ...mockInput } as MockInput;
  // ...省略
}

まとめ

今回は Web API クライアントのモックを紹介しましたが、MSW を使用したネットワークレイヤーのモック手法もあります。しかし、今回紹介した手法は、どんなモジュールにも適用できる方法です。テスト向けのモジュールを整備して、よりよいテストを書いていきましょう。

Discussion