はじめに
かれこれ2年ほど、TypeScriptのプログラムのテストコードをJestで書いています。その中で暫定的なベストプラクティスが出来上がってきたので記事にしました。
Jestでテストを書くときに起こる困りごと
これまでJestを書いていて、「test()
を単独で実行するとテストが正しく動作するのに、describe()
やファイル単位でまとめて実行すると、テストが意図しない動作になる」という事例に数多く遭遇しました。
複数のテスト間で干渉しあっているような状態です。それらは概ねこれらのパターンにまとめられました。
- 指定の関数の呼び出し回数が、他テストケースで呼ばれた回数も含まれており、期待値からずれる
- モック・spy関数に返却値を設定したが、別テストケースで設定した返却値が来てしまい、意図したテストにならない
- テスト対象が意図した動作になるようにテストデータを作成したつもりが、そうならない。テストデータを何度見返しても誤りが見つからず、困る
こんなことにできる限り困らないようなルールとテンプレートを考え使用しています。
注意
一つ二つくらいの単体テストを書くのであれば、以下のテンプレートはむしろ非効率です。
有効に活用できるのは次のような場合と思っています。
- 1つのテストファイル内で複数のテスト対象に対するテストを書く必要があり、テストケースも10や20以上ある
- テストデータが複数のプロパティやネストされた構造を持つ複雑なオブジェクトである
- あるテストのファイルに対し、継続的にテストコードの追加が見込まれる(つまり、作成したテストコードの"いろは"を忘れたころに、そのファイルをまた触る必要がある)
ルールとテンプレート
ルール
- 基本的に
jest.mock
よりもjest.spyOn
を用いる-
jest.mock
はテストする上で動作を固定しておきたいものに使用する- 例
- HTTPリクエストが常に成功するように
jest.mock
- DB接続するメソッドを、必ず接続成功として扱うようにするため
jest.mock
- テスト実行中に動作させる必要のないロギングライブラリは
jest.mock
で何もしないようにする
- HTTPリクエストが常に成功するように
- 例
-
- テストデータは「ひながた」を作っておき、テストではディープコピーして使う
- モック化した関数の返却値を設定する場合、
mockReturnValueOnce
のような1回きりの設定を行う- 呼び出し回数が事前にわからない場合はその限りではない
テンプレート
このテンプレを埋めていく形で書いています。
// beforeAllのコールバックでspyOnする都合上
// 変数を外に置いてtest()でアクセスできるようにする
// 例)let spyFunc: jest.SpyInstance;
describe('describeの文', () => {
beforeAll(() => {
// describeの最初にspy化を行う
});
afterEach(() => {
// 各test終了後にspy・mockの呼び出し回数をリセットする
jest.clearAllMocks();
});
afterAll(() => {
// describe終了時にすべてのspyを解除する
jest.restoreAllMocks();
})
// 以下、テストケース
test('テストケース1', () => { /*テストの中身*/ });
test('テストケース2', () => { /*テストの中身*/ });
test('テストケース3', () => { /*テストの中身*/ });
})
説明
テンプレートの意味やとルールの設定理由を説明します。
ルールの説明
- jest.spyOnを優先的に用いるのは、spyの解除が簡単なためです。そしてspyであっても、
mockReturnValueOnce
、mockImplementationOnce
などで動作を設定することができます - テストデータはひな形ひとつを用意し、test()ごとにディープコピーした後で、そのケースにあった値を設定していきます。こうすることで、そのテストケースで使用するデータが他から干渉されなくなります。ディープコピーはJSON.parse(JSON.stringfy())の裏技チックなものでも良いですし、ライブラリを使う方法や、structuredCloneを使う方法でもOKです
type Template = { field1: string, field2: number, field3: { field3a: string, field3b: string[] } } const template: Template = { field1: 'test', field2: 0, field3: { field3a: 'test', field3b: ['test'] }, } // 中略 test('テストケース', () => { // テストデータをディープコピー const testData: Template = JSON.parse(JSON.stringify(template)) testData.field1 = 'testCase1'; testData.field2 = 200; // このケース用にカスタムしたデータを用いる spyFunc.mockResolvedValue(testData); // 後略 })
- もし、テストデータのディープコピーを行わない場合にはテスト間の干渉が起きやすくなります
- 複数テストケースで同じテストデータを参照している状態で、テスト対象の関数内にテストデータを渡しているとします。テスト対象の関数内では受けたデータから特定のフィールドを削除している場合、1つ目のテストでテストデータから特定フィールドが削除され、その状態のものが意図せず2つ目のテストで使用されてしまいます
- ディープコピー以外の解決方法として「テストケースと同じ数だけ、テストデータを用意する」もあります。しかしテストデータの作成時間が増え、共通化できそうなデータもコピペ以外では流用できず、テストデータだけで行数がどんどん増えていくというデメリットが多いのでおすすめしません
- 「
mockReturnValueOnce
のような1回きりの設定を行う」は、そうしない場合に問題が起こる実例を説明するのが難しいです。そういうケースはだいたいテスト対象のコードが複雑であるために、テスト実装者がテスト間で干渉する可能性を見落としています- 「あるテストケースにのみ必要な設定は、そのテストケースの中でのみ有効であるべき」と考えています。
mockResolved
はモック・spyの返却値を恒常的に変化させますが、mockResolvedOnce
系は1回のみの設定です。もし、3回分の返却値を設定したい場合はチェーンして書けますspyFunc.mockResolvedValue(`a`) .mockResolvedValue(`b`) .mockResolvedValue(`c`);
- 「あるテストケースにのみ必要な設定は、そのテストケースの中でのみ有効であるべき」と考えています。
テンプレートの説明
重要なのはbeforeAll, afterEach, afterAllです。
- beforeAllは受け取ったものをdescribeの一番最初に、afterAllはdescribeの最後に実施します。describeの最初にspyOnし、最後にrestoreAllMocksでspyを解除することで、describe外に余計なspyの設定が漏れ出さないようにしています
- ※restoreAllMocksで解除できるのはspyのみです
- afterEachは、各test終了後に毎回行われます。ここではspyが呼ばれた回数のカウントをclearAllMocksできれいにしています
おわりに
インターネット上にあるJestの情報には、たくさんのテストを一か所に書いていく際の方法論が少ないと感じていました。実際これまでJestを書いていて解決が難しかった問題は、テスト間の干渉やテスト対象側の実装も絡んだ問題でした。
そういったケースの原因は個別のコードに根ざしている部分が多く、サンプルコードを用意するのも難しいと思います。守秘義務的な側面もあります。そういったことから、実用的なJestのプラクティスは記事にしにくい部分があるのかなと思いました。
そこで今回は、これまでの経験したような問題を起こしにくいルールとテンプレートを出したつもりです。半年ほどこのルールとテンプレートで書いており、以前よりもやりやすくなったと私個人は感じています。
個別の問題の原因は置いておき、汎用性のあるものを提案できていれば幸いです。
またこの暫定ベストプラクティスに対する改善点も考えています。
- 記述量が多い。全部のdescribeにいちいちbeforeAll, afterEach, afterAllを書かなくてはいけないのか? テンプレ部分をもっとスリムにできないか?
- jest.mockしておいたものを、後々のテスト追加時に返却値操作をする必要が出てきたとしたら、jest.spyOnに書き換えないといけないのか? だったら最初からすべてをjest.spyOnでやるように統一するのが良いのでは?
今後なにか改善ができたときは、改めて記事にしようとおもいm