6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

TypeScriptAdvent Calendar 2022

Day 24

[TypeScript,Jest] JestでオリジナルのMatcherを作ってテストをしよう

Last updated at Posted at 2022-12-22

はじめに

こんにちは!

みなさん、jestでテストを書いていて 「xxxっていう値が返ってくるのをテストしたいけど組み込みのメソッドだとテストできない。。どうしよう」 となったことはありませんか?

実は、jestには expect.extend という関数が用意されていて自分オリジナルの※Matcherを作ることができるんです。(※ toBe,toHaveBeenCalled などテスト書くときに書くあれです。)

本記事では、自分がこの記事を書くきっかけになった ”ある値の型が 特定の型 | null となるかどうか” をチェックするMatcherの作成を通して、CustomMatcherの作成方法を解説します。

本記事のゴール

自分だけのCustomMatcherを作成できるようになる

準備

まずはTypeScript x Jestを実行できる環境を作成します。もしそれが面倒でちゃっちゃと試したい場合は、筆者のレポジトリ(https://github.com/ironkicka/jest-custom-matcher-sample) をcloneしてご利用ください

最終的なディレクトリ構成

以下が最終的なディレクトリ構成です。ファイルの配置に困ったらこちらを参照してください

.
├── jest.config.ts
├── jest.d.ts
├── jest.setup.ts
├── package-lock.json
├── package.json
├── src
│   ├── sample.test.ts
│   └── sample.ts
└── tsconfig.json

必要なパッケージのインストール

npm i -D typescript jest @types/jest ts-jest ts-node

tsconfig.jsonの作成

npx tsc -init

特に凝った設定はせずデフォルトのままです(以下は自動生成されたもののうちコメント部分を取り除いてます)

{
  "compilerOptions": {
    "target": "es2016",                                  /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
    "module": "commonjs",                                /* Specify what module code is generated. */
    "esModuleInterop": true,                             /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
    "forceConsistentCasingInFileNames": true,            /* Ensure that casing is correct in imports. */
    "strict": true,                                      /* Enable all strict type-checking options. */
    "skipLibCheck": true ,                                /* Skip type checking all .d.ts files. */
  }
}

jest.config.tsの作成

ルートディレクトリにjest.config.tsを作成し、以下のように設定してください。

export default {
  // A list of paths to directories that Jest should use to search for files in
  roots: [
    "<rootDir>/src"
  ],
  // A map from regular expressions to paths to transformers
  transform: {
    "^.+\\.(ts|tsx)$": "ts-jest"
  },
};

サンプルファイルを配置する

src配下に以下のファイルを配置してください

sample.ts

export const getMembers = ()=>{
  return [
    {
      id:1,
      name:'Nick',
      department:null
    },
    {
      id:2,
      name:'Howie',
      department:'A'
    },
    {
      id:3,
      name:'Kevin',
      department:null
    },
  ]
}

sample.test.ts

import {getMembers} from './sample'

describe(getMembers.name,()=>{
  it('Check if each member has values with expected types',()=>{
    const members = getMembers()
    members.forEach((member)=>{
      expect(member).toMatchObject({
        id:expect.any(Number),
        name:expect.any(String),
        department:expect.any(String)
      })
    })
  })
})

実行

以下のコマンドでテストを実行してみてください

npx jest

おそらく、この段階では次のようなエラーが出ると思います。当然ですね。sample.tsを見て貰えば分かるようにdepartmentは要素によってstringが入ることもあればnullも入ることがあります。これは、組み込みのexpect.any(xxx)では表現できません。

- Expected  - 1
    + Received  + 1

      Object {
    -   "department": Any<String>,
    +   "department": null,
        "id": Any<Number>,
        "name": Any<String>,
      }

       5 |     const members = getMembers()
       6 |     members.forEach((member)=>{
    >  7 |       expect(member).toMatchObject({
         |                      ^
       8 |         id:expect.any(Number),
       9 |         name:expect.any(String),
      10 |         department:expect.any(String)

      at src/sample.test.ts:7:22
          at Array.forEach (<anonymous>)
      at Object.<anonymous> (src/sample.test.ts:6:13)

カスタムマッチャーの作成

それでは早速カスタムマッチャーを作成していきます。まずはそもそもどんな構造の何を作ればいいのかを説明していきます。

カスタムマッチャーの構造

ほとんど公式ドキュメントを翻訳する形になりますが、カスタムマッチャーは次のような構造をした関数になります。なんらかの値(x,y,z)を受け取って、passmessageを返します。

yourMatcher(x, y, z) {

		なんらかの処理

    return {
      pass: true,
      message: () => 'エラーだよ',
    };
},

passは入力(x,y,z)がマッチャーの期待する条件を満たすかどうかをtruefalseで表現します。テストが通ればtrueです。簡単ですね。

messageは入力値がテストを通らなかった場合に返すエラーメッセージです.

参考:[Custom Matchers API](https://jestjs.io/docs/expect#custom-matchers-api)

カスタムマッチャーの登録方法

作成したカスタムマッチャーを呼び出せるようにするには以下のようにexpect.extendに渡してあげればOKです。

expect.extend({
	yourMatcher(x, y, z) {

		なんらかの処理

    return {
      pass: true,
      message: () => 'エラーだよ',
    };
	}
})

気になるのはこれをどこで記述するかだと思いますが、主に以下の2通りを使い分けるのが良いと思います。

  • カスタムマッチャーを利用するファイル自体に記述する

    これは単純にテストファイルの先頭に記述する方法です。例えば、作成するカスタムマッチャーがそのテストファイル内でしか使わないようなものの場合はこちらで良いと思います。

    expect.extend({
      yourMatcher(){
        ...
      }
    })
    
    describe('Test',()=>{
      it('Check hoge',()=>{
         expect(fuga).yourMatcher();
      })
    })
    
  • セットアップファイルに記述する

    この方法では、1つのファイルにカスタムマッチャーを切り出し、それをjestのsetupFilesAfterEnvという設定を使い、各テストの前にそのファイルを読み込ませるようにします。様々なテストで繰り返し使うカスタムマッチャーの場合はこの方法で登録するのが良いかと思います。

    具体的には、以下のように1つのファイルを作り、カスタムマッチャーの定義と登録をします。

    jest.setup.ts

    
    expect.extend({
      yourMatcher(){
        ...
      }
    })
    

    その後、上記のファイルをテストの前に読み込みされるようにjest.config.ts内でsetupFilesAfterEnvに指定します。

    jest.config.ts

    export default {
      // A list of paths to directories that Jest should use to search for files in
      roots: [
        "<rootDir>/src"
      ],
      // A list of paths to modules that run some code to configure or set up the testing framework before each test
      setupFilesAfterEnv: [
        '<rootDir>/jest.setup.ts'
      ],
    
      // A map from regular expressions to paths to transformers
      transform: {
        "^.+\\.(ts|tsx)$": "ts-jest"
      },
    };
    

以上がカスタムマッチャーの構造と登録方法です。これから実際に”ある値の型が 特定の型 | null となるかどうか”をチェックするMatcherを作成してみましょう

実装

想定する使い方

今回作成するtoBeTypeOrNullは以下のような使い方を想定しています.

なんらかのオブジェクトに対してtoMatchObjectを使用し、そのフィールドが値はなんでもいいけど型が期待値を満たしているかどうかを確認する際に用います。

expect(なんらかのオブジェクト).toMatchObject({
  xxx:expect.any(Number),//=> なんらかの数値 ならOK
  yyy:expect.any(String),//=> なんらかの文字列 ならOK
  zzz:expect.toBeTypeOrNull(String) //=>文字列 or NULL ならOK
})

マッチャーの実装

上記を実現するためには以下のようなマッチャーを定義します。

expect.extend({
  toBeTypeOrNull(received, classTypeOrNull): jest.CustomMatcherResult {
    try {
      expect(received).toEqual(expect.any(classTypeOrNull));
      return {
        message: () => 'Ok',
        pass: true,
      };
    } catch (error) {
      return received === null
        ? {
          message: () => 'Ok',
          pass: true,
        }
        : {
          message: () => `expected ${received} to be ${classTypeOrNull} type or null`,
          pass: false,
        };
    }
  },
});

上記マッチャーはこちらを参考にしています

まず、最初のtry句で受け取った値が指定した型(classTypeOrNull)を満たすかどうか確認します
問題なければpass:trueを返します。

try {
      expect(received).toEqual(expect.any(classTypeOrNull));
      return {
        message: () => 'Ok',
        pass: true,
      };
}

もしここで、toEqualがエラーを返した場合はcatch句に移り、まずnullかどうかを確認し、nullならpass:trueを返し、nullでなければpass:falseでエラー文を返却します。

} catch (error) {
      return received === null
        ? {
          message: () => 'Ok',
          pass: true,
        }
        : {
          message: () => `expected ${received} to be ${classTypeOrNull} type or null`,
          pass: false,
        };
    }

これをjest.setup.ts,sample.test.tsに反映すると以下のようになります

jest.setup.ts

// CustomMatcherの定義
expect.extend({
  toBeTypeOrNull(received, classTypeOrNull): jest.CustomMatcherResult {
    try {
      expect(received).toEqual(expect.any(classTypeOrNull));
      return {
        message: () => 'Ok',
        pass: true,
      };
    } catch (error) {
      return received === null
        ? {
          message: () => 'Ok',
          pass: true,
        }
        : {
          message: () => `expected ${received} to be ${classTypeOrNull} type or null`,
          pass: false,
        };
    }
  },
});

sample.test.ts

import {getMembers} from './sample'

describe(getMembers.name,()=>{
  it('Check if each member has values with expected types',()=>{
    const members = getMembers()
    members.forEach((member)=>{
      expect(member).toMatchObject({
        id:expect.any(Number),
        name:expect.any(String),
        department:expect.toBeTypeOrNull(String)
      })
    })
  })
})

この状態でnpx jestを実行すればテストが通るはずです!

いかがでしたでしょうか。思ったより簡単だと思われたんではないでしょうか。

ですが、お気付きの方もいるかと思いますが、実はこの状態では実行はできるもののTypeScriptがカスタムマッチャーを認識してくれず下記のような警告が出てしまいます。次のセクションでは自作したマッチャーをTypeScriptに認識させる方法を説明します。

TS2339: Property 'toBeTypeOrNull' does not exist on type 'Expect'.

image.png

カスタムマッチャーの型付け

実はこの作業も非常に簡単です。公式ドキュメントでどの型を拡張すればいいかは既に紹介されていて、それが以下です(一部改変)。これを今回作成したものに合わせて定義するだけです。

interface CustomMatchers<R = unknown> {
  yourMatcher(x: number, y: number): R;
}
declare global {
  namespace jest {
    interface Expect extends CustomMatchers {}
    interface Matchers<R> extends CustomMatchers<R> {}
    interface InverseAsymmetricMatchers extends CustomMatchers {}
  }
}
export {};

具体的には、以下のようなjest.d.tsを作成し、ルートディレクトリに配置するだけです。

jest.d.ts

interface CustomMatchers<R = unknown> {
  toBeTypeOrNull(classType: any): R;
}

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

export {}

すると、以下のようにIDEが型の補完をしてくれるはずです

CustomMatchers<unknown>.toBeTypeOrNull(     classType: any): unknown

image.png

終わりに

いかがでしたでしょうか。このカスタムマッチャーがあればどんなテストが来てももう怖くないんではないでしょうか。個人的には、mockの方が何度も泣かされているので次はmockをまとめた記事をかけたら良いかなと思っています。

この記事が誰かの役に立てば幸いです。

参考

以下2つの記事がとてもわかりやすかったので自分の記事でわかりづらいところあれば是非読んでみてください.
https://qiita.com/Nyagoking/items/b80c5f691d0aa2764f88
https://developer.mamezou-tech.com/testing/jest/jest-custom-matchers/

6
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?