Jest再入門 - カスタムマッチャー作成編

| 10 min read
Author: noboru-kudo noboru-kudoの画像

Jest再入門シリーズの最後はカスタムマッチャーの作成にチャレンジします。
JestのExpect APIには、組み込みで多くのマッチャーが提供されていますが、これだけでは不足するケースや複雑なアサートを書かざるを得ないケースが往々にしてあります。

そんなときは、Jestのカスタムマッチャーを作成して、テストをシンプルにしていきましょう。

Information

カスタムマッチャーにはJestコミュニティで開発・公開されているものもあります。
有用なカスタムマッチャーが多数提供されていますので、必要に応じて導入すると良いでしょう。

カスタムマッチャーの基本

#

最初は、シンプルなケースでカスタムマッチャーを作成します。
ここではUUIDのフォーマットが正しいことを検証するマッチャーを作成してみましょう。

まずは、Jestのカスタムマッチャーです。この場合は以下のようなマッチャー実装になります。

expect.extend({
  toBeUUID(received: unknown): jest.CustomMatcherResult {
    if (typeof received !== "string") throw new Error("actual value must be a string");
    const pass = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/.test(received);
    const message = () => {
      return pass
        ? `期待値: Not UUIDフォーマット、テスト結果: ${received}(UUIDフォーマット)`
        : `期待値: UUIDフォーマット、テスト結果: ${received}(Not UUIDフォーマット)`;
    };
    return {
      pass,
      message,
    };
  },
});

これがカスタムマッチャーの基本形です。
expect.extendの引数にカスタムマッチャーとなる関数を定義します。
カスタムマッチャーの定義は以下になります。

type MatcherContext = MatcherUtils & Readonly<MatcherState>;
type CustomMatcher = (
    this: MatcherContext,
    received: any,
    ...actual: any[]
) => CustomMatcherResult | Promise<CustomMatcherResult>;

引数としてテスト対象の値(received)を受け取り、戻り値として、jest.CustomMatcherResultを返します。
jest.CustomMatcherResultの型定義は以下のようになります。

interface CustomMatcherResult {
    pass: boolean;
    message: () => string;
}

passが検査結果、messageがテスト失敗時のメッセージです。
ここでは正規表現による一致を検査し、passにtrueまたはfalseを設定しています。
message指定時は注意が必要です。passがtrueだからと言ってテストが成功する訳ではありません。
これはexpect("foo").not.toBe("bar")のように否定のケースがあるためです。
上記では、その点を考慮してpassがtrueでも、notを指定した場合のmessageも定義しています(3項演算子の部分)。

このカスタムマッチャーを使ったテストは以下のように記述できます。

test("UUIDフォーマットマッチャー", () => {
  expect("00000000-0000-0000-0000-000000000000").toBeUUID();
  expect("foo").not.toBeUUID();
});

使い方は、Jestの組み込みマッチャーと同様です。
TypeScriptの場合は、これだけではコンパイラが追加したカスタムマッチャーを認識できません。
この場合は、d.tsファイルを作成するのが一般的です。
名前は任意ですが、以下のようなd.tsファイルを作成します。

export interface CustomMatchers<R = unknown> {
  toBeUUID(): R;
}

declare global {
  namespace jest {
    interface Expect extends CustomMatchers {}
    interface Matchers<R> extends CustomMatchers<R> {}
    interface InverseAsymmetricMatchers extends CustomMatchers {}
  }
}

CustomMatcherにTypeScriptが認識可能なマッチャーのインターフェースを記述し、その下のdeclare内でjestの既存インターフェースにマージしています。

これで準備は完了です。後は通常のテスト同様に上記テストは成功します。
テストが失敗すると、以下のような出力となります。

  ● カスタムマッチャー › UUIDフォーマットマッチャー

    期待値: UUIDフォーマット、テスト結果: 00000000-0000-0000-0000-00000000000(Not UUIDフォーマット)

      51 | describe("カスタムマッチャー", () => {
      52 |   test("UUIDフォーマットマッチャー", () => {
    > 53 |     expect("00000000-0000-0000-0000-00000000000").toBeUUID();
         |                                                   ^
      54 |     expect("foo").not.toBeUUID();
      55 |   });
      56 |

テスト失敗の出力として、カスタムマッチャーでmessageに設定した内容が表示されていることが分かります。

Information

自作のマッチャーはゼロベースで作成するのではなく、Jest本体のマッチャーの実装を参考に作成するのがお勧めです。

期待値を受け取るパターン

#

次は、比較対象の期待値を受け取るパターンを作成します。さらに今回はJestで用意されているユーティリティで出力結果の見栄えを良くしてみましょう。
ここでは、文字列を大文字小文字を区別せずに比較するtoMatchCaseInsensitiveマッチャーを作成します。
まずは、専用のマッチャーを作成し、先程定義したexpect.extendに追加しましょう。

expect.extend({
  // (省略)
  toMatchCaseInsensitive(received: unknown, expected: string): jest.CustomMatcherResult {
    if (typeof received !== "string") throw new Error("actual value must be a string");
    const pass = Object.is(received.toLowerCase(), expected.toLowerCase());
    const message = () => {
      const hint = this.utils.matcherHint("toMatchCaseInsensitive", received, expected, {
        isNot: this.isNot,
        promise: this.promise,
        comment: "何か違う。。",
      });
      const diff = this.utils.printDiffOrStringify(
        received.toLowerCase(),
        expected.toLowerCase(),
        "期待値",
        "実際値",
        this.expand !== false
      );
      return hint + "\n\n" + diff;
    };
    return {
      pass,
      message,
    };
  },
});

今回は期待値を受け取るので、引数にexpectedを追加しています。事前に期待値(expected)、実際値(received)それぞれを小文字に変換して比較(Object.is)します。
message部分ですが、今回はJestのユーティリティ(jest-matcher-utility)で用意されているものを活用しています。
これは、カスタムマッチャー内のthis.utilsで呼出しできます。今回はmatcherHint/printDiffOrStringifyを利用してmessageを構築しました。

後はd.tsファイルにこのマッチャーのエントリーを追加して、テストを記述するだけです(d.tsファイルは先程のCustomMatcherに追加するだけですので割愛します)。
テストは以下のようになります。

test("大文字小文字区別しないマッチャー", () => {
  expect("foo").toMatchCaseInsensitive("Foo");
  expect("foo").not.toMatchCaseInsensitive("BAR");
});

上記テストは成功します。失敗すると以下のように表示されます。

  ● カスタムマッチャー › 大文字小文字区別しないマッチャー

    expect(foo).toMatchCaseInsensitive(Fooa) // 何か違う。。

    期待値: "foo"
    実際値: "fooa"

      61 |   test("大文字小文字区別しないマッチャー", () => {
      62 |     // expect("a").toBe("b")
    > 63 |     expect("foo").toMatchCaseInsensitive("Fooa");
         |                   ^
      64 |     expect("foo").not.toMatchCaseInsensitive("BAR");
      65 |   });
      66 |

先程よりも出力結果が見やすくなっていることが分かります。

スナップショットテストのカスタムマッチャー

#

カスタムマッチャーはスナップショットテスト[1]にも適用できます。
ここではオブジェクトのpayloadフィールド配下のみをスナップショットテストするカスタムマッチャーを作成してみます。

スナップショットテストのカスタムマッチャーはJestが提供するjest-snapshotを包含する形で作成します。
以下のようになります。

import { SnapshotState, toMatchSnapshot } from "jest-snapshot";

expect.extend({
  // (省略)

  toMatchPayloadSnapshot(received: {
    [key: string]: unknown;
  }): jest.CustomMatcherResult | Promise<jest.CustomMatcherResult> {
    if (typeof received !== "object") throw new Error("actual value must be a object");
    if (!("payload" in received)) throw new Error("payload not found");
    const thisInstance = this as jest.MatcherContext & { snapshotState: SnapshotState };
    return toMatchSnapshot.bind(thisInstance, received["payload"], "toMatchPayloadSnapshot")();
  },
});

ポイントはカスタムマッチャーの最後のtoMatchSnapshot.bind(...)の部分です。
ここで、jest-snapshotが提供しているマッチャーにスナップショットファイルのチェックを移譲しています。
現状はTypeScriptで厳密に型付けしようとすると、thisをjest-snapshotが提供する型(jest.MatcherContext & { snapshotState: SnapshotState })にキャストする必要がありました(他の方法があるのかもしれません)。

作成するテストは以下のようになります(先程同様にd.tsファイルは先程のCustomMatcherに追加するだけですので割愛します)。

test("スナップショットテストのカスタムマッチャー", () => {
  const obj = {
    created: new Date().getTime(), // テスト対象外
    payload: { // テスト対象
      test: "foo-bar-hoge",
      count: 100,
    },
  };
  expect(obj).toMatchPayloadSnapshot();
});

createdフィールドはテストの都度結果が変わるようにしていますが、カスタムマッチャーはpayload配下のみを検証しますので、常にテストは成功します。
スナップショットテストなので、もちろんスナップショットファイルも更新されます。
payload配下を変更してテストを失敗させると、以下のような出力となります。

  ● カスタムマッチャー › スナップショットテストのカスタムマッチャー

    expect(received).toMatchSnapshot(hint)

    Snapshot name: `カスタムマッチャー スナップショットテストのカスタムマッチャー: toMatchPayloadSnapshot 1`

    - Snapshot  - 2
    + Received  + 2

      Object {
    -   "count": 100,
    -   "test": "foo-bar-hoge",
    +   "count": 200,
    +   "test": "foo-bar-hoge-fuga",
      }

      73 |       },
      74 |     };
    > 75 |     expect(obj).toMatchPayloadSnapshot();
         |                 ^
      76 |   });
      77 | });
      78 |

payload配下のみの差分がテスト失敗として検出されていることが分かります。

カスタムマッチャーをテスト全体に適用する

#

これまで作成したカスタムマッチャーは、このままだと各テストファイル内でexpect.extendを使って登録する必要があります。通常はデフォルトで使えるようにしたいと思います。
Jestではテストファイル実行時のフックポイントがあります。

最後の仕上げに、今まで作成したカスタムマッチャーをテスト全体で利用できるようにしてみましょう。
まずはセットアップスクリプトを作成します。ここではspecs/register-matchers.tsというファイルを作成しました。

import { SnapshotState, toMatchSnapshot } from "jest-snapshot";

expect.extend({
  toBeUUID(received: unknown): jest.CustomMatcherResult {
    // (省略)
  },

  toMatchCaseInsensitive(received: unknown, expected: string): jest.CustomMatcherResult {
    // (省略)
  },

  toMatchPayloadSnapshot(received: {
    [key: string]: unknown;
  }): jest.CustomMatcherResult | Promise<jest.CustomMatcherResult> {
    // (省略)
  },
});

今までテストファイル内で作成したカスタムマッチャーをこちらに移動します。
続いてjest.config.tsの設定を以下のように変更します。

export default {
  // 追加(それ以外は変更不要)
  setupFilesAfterEnv: ["<rootDir>/specs/register-matchers.ts"],
};

<rootDir>はデフォルトではプロジェクトルートを指します。これで各テストファイル実行前にこのセットアップスクリプトが実行されるようになります。
各テストファイルでexpect.extendの定義は不要になり、これまで作成したカスタムマッチャーがデフォルトで利用できるようになります。

終わりに

#

今回でJest再入門シリーズは最終回になります。
Jestは予め用意されたものを何となく使うことが多いですが、ゼロベースから導入するのも簡単で、これだけで単体テストのユースケースのほとんどを賄えることが理解いただけたでしょうか?
と偉そうなことを言いましたが、筆者も執筆していて気づいたことが結構多く、改めてJestの使い方を再認識しました。

Jest+TypeScriptはフロントエンドに限らず、バックエンドでも広く使われるようになってきたと思います。
本シリーズが皆さんの参考になれば幸いです。


関連記事


参照資料


  1. スナップショットテストは Jest再入門 - スナップショットテスト編を参照してください。 ↩︎

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。