リーダブルなテストのための、jest モックファクトリー関数
単体テストを書く時、モジュール間の関連を検証するため、一部のモジュールをモックする必要が出てくることがあります。モックは様々な手法がありますが、書き方によって、メンテナンス性やテストの可読性が変わります。一般的に行われるモック手法を確認しつつ、よりリーダブルなテストを書く方法を紹介します。
ログイン API を呼び出す Web API クライアント
今回紹介する、モック対象の Web API クライアントです。Native Fetch API を関数でラップした、自作の Web API クライアント(ログインするためのlogin
関数)です。
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
関数は非同期関数ですので、mockResolvedValueOnce
やmockRejectedValueOnce
を定めることで、モックを施すことができます。
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
関数の振る舞いを模すことができます。
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