JestのカスタムマッチャをTypeScriptで書く

意外と手こずったので、備忘も兼ねて共有しとく。
#公式のドキュメント
[JEST > API Reference > Expect](https://jestjs.io/docs/ja/expect)
#カスタムマッチャの定義
どう定義して、どう利用するか、公式ドキュメントを見ても[expect.extend()](https://jestjs.io/docs/ja/expect#expectextendmatchers)にいきなりマッチャ関数持ちのオブジェクト喰わせるコードしか載ってなくて、のっけから困る。

できれば、1つのカスタムマッチャに対して1ファイルで定義したいし、モジュール化して必要なときに必要なモノだけインポートしたいけど、モジュールに何をエクスポートさせるかも、やりようが色々あって迷うところ。

マッチャ関数なのか

```typescript
export default toBeWithinRange;
```
オブジェクトなのか

```typescript
export default { toBeWithinRange };
```
はたまた`expect.extend()`にオブジェクトを喰わせる関数なのか

```typescript
export default () => expect.extend({ toBeWithinRange });
```

別にどうやっても動くけど、色々試した結果、定義側ではマッチャ関数を書いた上でそれを持たせたオブジェクトをエクスポートさせ、利用側ではインポートしたオブジェクトを`expect.extend()`に喰わせる形式が、自分には一番分かり易かった。

#サンプルコード
##Custom Matcher
###定義例(toBeWithinRange.ts)
```typescript
// カスタムマッチャ定義
function toBeWithinRange(
  received: number, // 第1引数は expect() で指定した検証対象
  floor: number, // 第2引数以降は検証時に与える引数
  ceiling: number,
): jest.CustomMatcherResult {
  // 検証結果の真偽値
  const pass: boolean = received >= floor && received <= ceiling;
  // メッセージ文字列を返す関数
  const message: () => string = pass ?
    () => `expected ${received} not to be within range ${floor} - ${ceiling}` :
    () => `expected ${received} to be within range ${floor} - ${ceiling}`;

  // jest.CustomMatcherResult の型に合わせて返す
  return {
    pass,
    message,
  };
}
// expect.extend() に与えるオブジェクトとしてエクスポート
export default { toBeWithinRange };
```
###使用法
```typescript
// マッチャを使用するUT内でインポート(ファイルパスは仮)
import toBeWithinRange from '@/../tests/unit/customMatchers/toBeWithinRange';

// 使用箇所に合わせてマッチャを登録
beforeEach(() => {
  expect.extend(toBeWithinRange);
});

// 検証
it('numeric ranges', () => {
  expect(100).toBeWithinRange(90, 110);          // VSCodeでTS[2339] エラー
  expect(101).not.toBeWithinRange(0, 100);       // VSCodeでTS[2339] エラー
  expect({apples: 6, bananas: 3}).toEqual({
    apples: expect.toBeWithinRange(1, 10),       // VSCodeでTS[2339] エラー
    bananas: expect.not.toBeWithinRange(11, 20), // VSCodeでTS[2339] エラー
  });
});
```
##Custom Async Matcher
###定義例(toBeDivisibleByExternalValue.ts)
```typescript
// カスタムマッチャ(非同期)定義
async function toBeDivisibleByExternalValue(
  received: number, // 第1引数は expect() で指定した検証対象
): Promise<jest.CustomMatcherResult> {
  // 検証に用いる値を外部の非同期処理から取得(する体で)
  const externalValue: number = await getExternalValueFromRemoteSource();
  // 検証結果の真偽値
  const pass: boolean = received % externalValue === 0;
  // メッセージ文字列を返す関数
  const message = pass ?
    () => `expected ${received} not to be divisible by ${externalValue}` :
    () => `expected ${received} to be divisible by ${externalValue}`;

  // jest.CustomMatcherResult の型に合わせて返す(async なので暗黙的に Promise を返す)
  return {
    pass,
    message,
  };
}
// expect.extend() に与えるオブジェクトとしてエクスポート
export default { toBeDivisibleByExternalValue };

/* 便宜上、本来なら外部から数値を取得する非同期処理をここに記述 */
function getExternalValueFromRemoteSource() {
  const EXTERNAL_VALUE: number = 10;
  const TIME_OUT: number = 2000;

  return new Promise<number>((resolve) => {
    setTimeout(() => {
      resolve(EXTERNAL_VALUE);
    }, TIME_OUT);
  });
}
```
###使用法
```typescript
// マッチャを使用するUT内でインポート(ファイルパスは仮)
import toBeDivisibleByExternalValue from '@/../tests/unit/customMatchers/toBeDivisibleByExternalValue';

// 使用箇所に合わせてマッチャを登録
beforeEach(() => {
  expect.extend(toBeDivisibleByExternalValue);
});

// 検証
it('is divisible by external value', async () => {
  await expect(100).toBeDivisibleByExternalValue();     // VSCodeでTS[2339] エラー
  await expect(101).not.toBeDivisibleByExternalValue(); // VSCodeでTS[2339] エラー
});
```

##Custom Snapshot Matcher
###定義例(toMatchTrimmedSnapshot .ts)
```typescript
// 標準のマッチャ(snapshot) をインポート
import { toMatchSnapshot } from 'jest-snapshot'; // VSCodeでTS[7016] エラー
// カスタムマッチャ(snapshot)定義
function toMatchTrimmedSnapshot(
  this: any, // this の型を規定するために用意された typescript 向けの特別な記法。引数として指定はできない。
  received: string, // 第1引数は expect() で指定した検証対象
  length: number, // 第2引数以降は検証時に与える引数
): jest.CustomMatcherResult {
  return toMatchSnapshot.call(
    this,
    received.substring(0, length),
    'toMatchTrimmedSnapshot',
  );
}
// expect.extend() に与えるオブジェクトとしてエクスポート
export default { toMatchTrimmedSnapshot };
```
###使用法
```typescript
// マッチャを使用するUT内でインポート(ファイルパスは仮)
import toMatchTrimmedSnapshot from '@/../tests/unit/customMatchers/toMatchTrimmedSnapshot';

// 使用箇所に合わせてマッチャを登録
beforeEach(() => {
  expect.extend(toMatchTrimmedSnapshot);
});

// 検証
it('stores only 10 characters', () => {
  expect('extra long string oh my gerd').toMatchTrimmedSnapshot(10); // VSCodeでTS[2339] エラー
});
```
##型定義ファイル(jest.d.ts)
ユニットテスト自体は以上で動作するようになるが、サンプルコード中にコメントした通りこのままだとVSCode上ではエラーが出てしまう。
解消しないと自動補完等に支障が出るので、適切な型定義ファイルを追加しておく。
ファイル構成についても特に決まりは無いが、共通のnamespaceから`jest.d.ts`として1つにまとめ、マッチャ定義ファイルと一緒に置いておくのが一番しっくり来た。

```typescript
declare module 'jest-snapshot';
declare namespace jest {
  interface Matchers<R> {
    toBeWithinRange(floor: number, ceiling: number): R;
    toBeDivisibleByExternalValue(): R;
    toMatchTrimmedSnapshot(length: number): R;
  }

  interface Expect {
    toBeWithinRange: (floor: number, ceiling: number) => any;
  }

  interface InverseAsymmetricMatchers {
    toBeWithinRange: (floor: number, ceiling: number) => any;
  }
}
```

## 標準のマッチャとして登録する場合
使用頻度の高いカスタムマッチャを都度インポート＆登録し直すのが負担になった場合など、標準のマッチャとして予め登録しておける。

1. Jest 23.x 以前
    1. jest.config.js に以下の設定を追記する(ファイルパスは仮)
        * `setupTestFrameworkScriptFile: '<rootDir>/config/jest/setup-script-file.ts',`
    1. インポート＆登録を実行するスクリプトファイル(setup-script-file.ts)を配置
1. Jest 24.0 以降
    1. jest.config.js に以下の設定を追記する(配列中のファイルパスは仮)
        * `setupFilesAfterEnv: ['<rootDir>/config/jest/add-custom-matchers.ts'],`
    1. インポート＆登録を実行するスクリプトファイル(add-custom-matchers.ts)を配置

### スクリプトファイルの例
```typescript
// 標準として登録するカスタムマッチャの定義をインポート
import toBeWithinRange from '@/../tests/unit/customMatchers/toBeWithinRange';
import toBeDivisibleByExternalValue from '@/../tests/unit/customMatchers/toBeDivisibleByExternalValue';
import toMatchTrimmedSnapshot from '@/../tests/unit/customMatchers/toMatchTrimmedSnapshot';

// 予め登録
expect.extend(toBeWithinRange);
expect.extend(toBeDivisibleByExternalValue);
expect.extend(toMatchTrimmedSnapshot);
```