はじめに
こんにちは!
みなさん、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)を受け取って、pass
とmessage
を返します。
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)
カスタムマッチャーの登録方法
作成したカスタムマッチャーを呼び出せるようにするには以下のように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'.
カスタムマッチャーの型付け
実はこの作業も非常に簡単です。公式ドキュメントでどの型を拡張すればいいかは既に紹介されていて、それが以下です(一部改変)。これを今回作成したものに合わせて定義するだけです。
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
終わりに
いかがでしたでしょうか。このカスタムマッチャーがあればどんなテストが来てももう怖くないんではないでしょうか。個人的には、mockの方が何度も泣かされているので次はmockをまとめた記事をかけたら良いかなと思っています。
この記事が誰かの役に立てば幸いです。
参考
以下2つの記事がとてもわかりやすかったので自分の記事でわかりづらいところあれば是非読んでみてください.
https://qiita.com/Nyagoking/items/b80c5f691d0aa2764f88
https://developer.mamezou-tech.com/testing/jest/jest-custom-matchers/