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

# はじめに

こんにちは！

みなさん、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
```

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

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

## tsconfig.jsonの作成

```bash
npx tsc -init
```

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

```json
{
  "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`を作成し、以下のように設定してください。

```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

```tsx
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

```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)
      })
    })
  })
})
```

## 実行

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

```tsx
npx jest
```

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

```tsx
- 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)を受け取って、`pass`と`message`を返します。

```tsx
yourMatcher(x, y, z) {

		なんらかの処理

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

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

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

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

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

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

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

		なんらかの処理

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

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

- カスタムマッチャーを利用するファイル自体に記述する
    
    これは単純にテストファイルの先頭に記述する方法です。例えば、作成するカスタムマッチャーがそのテストファイル内でしか使わないようなものの場合はこちらで良いと思います。
    
    ```tsx
    expect.extend({
      yourMatcher(){
        ...
      }
    })
    
    describe('Test',()=>{
      it('Check hoge',()=>{
         expect(fuga).yourMatcher();
      })
    })
    ```
    
- セットアップファイルに記述する
    
    この方法では、１つのファイルにカスタムマッチャーを切り出し、それをjestの`setupFilesAfterEnv`という設定を使い、各テストの前にそのファイルを読み込ませるようにします。様々なテストで繰り返し使うカスタムマッチャーの場合はこの方法で登録するのが良いかと思います。
    
    具体的には、以下のように1つのファイルを作り、カスタムマッチャーの定義と登録をします。

    jest.setup.ts
    
    ```tsx
    
    expect.extend({
      yourMatcher(){
        ...
      }
    })
    ```
    
    その後、上記のファイルをテストの前に読み込みされるように`jest.config.ts`内で`setupFilesAfterEnv`に指定します。

    jest.config.ts
    
    ```tsx
    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`を使用し、そのフィールドが値はなんでもいいけど型が期待値を満たしているかどうかを確認する際に用います。

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

### マッチャーの実装

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

```tsx
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,
        };
    }
  },
});
```

上記マッチャーは[こちら]([https://stackoverflow.com/questions/51133367/jest-testing-for-type-or-null](https://stackoverflow.com/questions/51133367/jest-testing-for-type-or-null))を参考にしています

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

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

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

```tsx
} 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

```tsx
// 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

```tsx
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に認識させる方法を説明します。

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/782ac421-80b0-fb82-0d9a-dbea6d714b27.png)


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

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

```tsx
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

```tsx
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が型の補完をしてくれるはずです

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/366323/b7ad3696-0084-72b3-7efd-c3becab01d78.png)


# 終わりに

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

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

# 参考

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