Jest + TypeScript でテストを書くことが増えてきたので、この辺でTipsを記載しておこうと思います。今後も何かあれば追記します。
インストール
こちら を参考にしました。
まず、必要なライブラリを追加します。
yarn init
yarn add jest @types/jest ts-jest --dev
jest.config.js
をプロジェクト直下に作成します。
module.exports = {
"testMatch": [
"**/__tests__/**/*.+(ts|tsx|js)",
"**/?(*.)+(spec|test).+(ts|tsx|js)"
],
"transform": {
"^.+\\.(ts|tsx)$": "ts-jest"
},
}
package.json
にスクリプトを追加します。
"scripts": {
"test": "jest"
},
あとは、以下で実行できます。
yarn test
フォルダ構成は以下のような感じです。
.
├── __tests__
├── dist
├── src
├── jest.config.js
├── package.json
├── tsconfig.json
└── yarn.lock
モジュール直下の関数のテスト
テスト対象の関数が、外部からインポートしたモジュール直下の関数を利用しているケースのテストの書き方を紹介します。
テスト対象のコード
今回、テスト対象のコードとして以下のような二つのコードを用意しました。
-
テスト対象のコード(
useModuleMethod.ts
)
与えられた文字列を2回繰り返すtwice
関数と、外部のモジュール関数を呼び出して計5回繰り返すtwiceAndThrice
関数があるだけです(特に処理内容には意味はありません)
インポートして利用してるthrice
関数をモックしてテストする想定です。import { thrice } from './otherModule'; /** 文字列を2回繰り返す */ export const twice = (str: string): string => { return str + str; }; /** 文字列を2回+3回で合計5回繰り返す */ export const twiceAndThrice = (str: string): string => { return twice(str) + thrice(str); // thriceがモジュール関数 };
- 外部モジュール(
otherModule.ts
)/** 文字列を3回繰り返す */ export const thrice = (str: string): string => { return str + str + str; };
このコードを使って spyOn
と mock
を利用する基本的なテストコードを実装してみます。
spyOnを使ってテスト
spyOn
を使う場合のポイントは以下の通りです。
- モジュール関数を
spyOn
する場合は、モジュール全体を*
でimportで別名をつける -
jest.spyOn(otherModule, 'thrice')
で、otherModule
直下のthrice
関数をスパイする- この時点では、
thrice
関数を監視下に置いただけで、振る舞いは実関数のまま
- この時点では、
-
spy.mockReturnValueOnce('mock')
で、関数の戻り値をモック
実際のソースコード(targetSpy.test.ts
)は以下の通りです。
import { twice, twiceAndThrice } from '../../src/jest/useModuleMethod';
import * as otherModule from '../../src/jest/otherModule'; // `*` importして別名をつける
describe('with spy', () => {
test('twiceAndThrice', () => {
const spy = jest.spyOn(otherModule, 'thrice'); // 内部で呼び出しているmodule関数をspy
spy.mockReturnValueOnce('mock'); // thrice関数の戻り値をモック
const actual = twiceAndThrice('input');
expect(actual).toBe('inputinputmock'); // inputが2回とmock値が連結された結果が返っている
});
});
describe('real', () => {
test('twice', () => {
const actual = twice('input');
expect(actual).toBe('inputinput'); // inputが2回繰り返された結果が返っている
});
test('twiceAndThrice', () => {
const actual = twiceAndThrice('input');
expect(actual).toBe('inputinputinputinputinput'); // inputが5回繰り返された結果が返っている
});
});
自分の所感ですが、後述のmock
を使うよりも、spyOn
を使う方が以下の点で優れているので、基本はspyOn
を使う方がオススメです。
-
spyOn
は関数スコープに閉じてるので、モッククリアなどをやらなくても他のテストには影響を与えない-
mock
を使ったケースで、モッククリアを忘れて他のテストがエラーになってしまうケースは調査が大変
-
- クラスやモジュールの一部の関数だけモックすることができる
- 同じ関数をモックしたりしなかったり、テスト毎に切り替えられる
mockを使ってテスト
mock
を使う場合のポイントは以下の通りです。
-
jest.mock()
でモジュール、クラス、関数をモックする- この時点で、全ての関数は何もせず
undefinedを
返すようになる
- この時点で、全ての関数は何もせず
- 記述する場所は、関数の中ではなくトップレベルにする必要がある
- TypeScript の場合は、
jest.Mock
型にキャストすることで、モックオブジェクトにアクセスできる - 他の関数に影響を与えるので、
jest.clearAllMocks()
等でモック定義を一旦クリアする必要がある
実際のソースコード(useModuleMethodMock.test.ts
)は以下の通りです。
import { twice, twiceAndThrice } from '../../src/jest/useModuleMethod';
import { thrice } from '../../src/jest/otherModule';
jest.mock('../../src/jest/otherModule'); // 対象のモジュールをモックする。関数の中ではなくトップレベルに記載必要
/** 各テスト実行前に行われる共通処理 */
beforeEach(() => {
jest.clearAllMocks(); // モックをクリアする
});
describe('with mock', () => {
test('twiceAndThrice', () => {
const mockThrice = thrice as jest.Mock; // typescriptの場合はキャストが必要
mockThrice.mockReturnValueOnce('mock'); // モック関数の戻り値を指定
const actual = twiceAndThrice('input');
expect(actual).toBe('inputinputmock'); // 本来はinputが5回繰り返されるが、内部で呼び出しているthrice関数の戻り値部分がmockになっている
expect(mockThrice.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
expect(mockThrice.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第1引数チェック
});
});
describe('real', () => {
test('twice', () => {
const actual = twice('input');
expect(actual).toBe('inputinput'); // inputが2回繰り返された結果が返っている
});
});
記述方法に差異はありますが、基本的にはspyOn
と同等のことができます。
とはいえ、mock
の方が色々と気をつけるべきことが多いので、特に理由がなければ何も考えずにspyOn
を利用する方がいいと思います。
クラス直下の関数のテスト
テスト対象の関数が、外部からインポートしたクラス直下の関数を利用しているケースのテストの書き方を紹介します。
テスト対象のコード
今回、テスト対象のコードとして以下のような二つのコードを用意しました。
-
テスト対象のコード(
useClassMethod.ts
)
与えられた文字列に対して、static関数を呼び出して固定文字列を追加する関数と、instance関数を呼び出して固定文字列を追加する関数を提供しています。(特に処理内容には意味はありません)
インポートして利用しているstatic関数とinstance関数をモックしててテストする想定です。import OtherClass from './otherClass'; /** 固定文字列を追加する(classのstatic関数を利用) */ export const plusFixedStrWithStaticMethod = (str: string): string => { return OtherClass.plusFixedStrByStaticMethod(str); }; /** 固定文字列を追加する(classのinstance関数を利用) */ export const plusFixedStrWithInstanceMethod = (str: string): string => { return new OtherClass().plusFixedStrByInstanceMethod(str); };
-
外部モジュール(
otherClass.ts
)export default class OtherClass { static fixedStr = 'fixed'; // static関数 static plusFixedStrByStaticMethod(str: string): string { return str + this.fixedStr; } // instance関数 plusFixedStrByInstanceMethod(str: string): string { return str + OtherClass.fixedStr; } }
このコードを使って spyOn
と mock
を利用する基本的なテストコードを実装してみます。
spyOnを使ってテスト
クラス直下の関数をspyOn
す流場合は、以下の点だけ気を付けておく必要があります。
- static関数の場合
- モックしたい関数が所属するクラスをインポートして
jest.spyOn(OtherClass, 'plusFixedStrByStaticMethod')
のように、第一引数にクラスを、第二引数に関数名を指定する
- モックしたい関数が所属するクラスをインポートして
- instance関数の場合
- モックしたい関数が所属するクラスをインポートして
jest.spyOn(OtherClass.prototype, 'plusFixedStrByInstanceMethod')
のように、第一引数にクラスのprototype
を、第二引数に関数名を指定する
- モックしたい関数が所属するクラスをインポートして
後は、モジュール関数のケースと同じです。
実際のテストコード(useClassMethodSpy.test.ts
)は以下の通りです。
import { plusFixedStrWithStaticMethod, plusFixedStrWithInstanceMethod } from '../../src/jest/useClassMethod';
import OtherClass from '../../src/jest/otherClass';
describe('static method', () => {
test('real', () => {
const actual = plusFixedStrWithStaticMethod('input');
expect(actual).toBe('inputfixed'); // input+固定値が返っている
});
test('with spy', () => {
const spy = jest.spyOn(OtherClass, 'plusFixedStrByStaticMethod'); // 内部で呼び出してるstatic関数をspy
spy.mockReturnValueOnce('mock');
const actual = plusFixedStrWithStaticMethod('input');
expect(actual).toBe('mock'); // モック値が返っている
expect(spy.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
expect(spy.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第一引数チェック
});
});
describe('instance method', () => {
test('real', () => {
const actual = plusFixedStrWithInstanceMethod('input');
expect(actual).toBe('inputfixed'); // input+固定値が返っている
});
test('with spy', () => {
const spy = jest.spyOn(OtherClass.prototype, 'plusFixedStrByInstanceMethod'); // 内部で呼び出してるinstance関数をspy
spy.mockReturnValueOnce('mock');
const actual = plusFixedStrWithInstanceMethod('input');
expect(actual).toBe('mock'); // モック値が返っている
expect(spy.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
expect(spy.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第1引数チェック
});
});
mockを使ってテスト
クラス直下の関数をmock
する方法は、モジュール関数のケースとほぼ同じです。
一点だけ、クラス直下のinstance関数をモックしたい場合だけ、OtherClass.prototype.plusFixedStrByInstanceMethod as jest.Mock
のように、prototype
を利用する必要があります。
実際のテストコード(useClassMethodMock.test.ts
)は以下の通りです。
import { plusFixedStrWithStaticMethod, plusFixedStrWithInstanceMethod } from '../../src/jest/useClassMethod';
import OtherClass from '../../src/jest/otherClass';
// 対象のモジュールをモックする。関数の中ではなくトップレベルに記載必要
jest.mock('../../src/jest/otherClass');
beforeEach(() => {
jest.clearAllMocks(); // モックをクリアする
});
test('mock static method', () => {
const mockPlusFixedStrByStaticMethod = OtherClass.plusFixedStrByStaticMethod as jest.Mock;
mockPlusFixedStrByStaticMethod.mockReturnValueOnce('mock'); // 内部で呼び出しているstatic関数をmock
const actual = plusFixedStrWithStaticMethod('input');
expect(actual).toBe('mock'); // モック値が返っている
expect(mockPlusFixedStrByStaticMethod.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
expect(mockPlusFixedStrByStaticMethod.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第一引数チェック
});
test('mock instance method', () => {
const mockPlusFixedStrByInstanceMethod = OtherClass.prototype.plusFixedStrByInstanceMethod as jest.Mock;
mockPlusFixedStrByInstanceMethod.mockReturnValueOnce('mock'); // 内部で呼び出しているinstance関数をmock
const actual = plusFixedStrWithInstanceMethod('input');
expect(actual).toBe('mock'); // モック値が返っている
expect(mockPlusFixedStrByInstanceMethod.mock.calls.length).toBe(1); // mock関数の呼び出し回数チェック
expect(mockPlusFixedStrByInstanceMethod.mock.calls[0][0]).toBe('input'); // mock関数の1回目の呼び出しの第一引数チェック
});
こちらも、モジュール関数の時と同じように気にすべき点がいくつかあるので、こちらも利用せず、spyOn
を利用した方が良いと思います。
Tips
async 関数のモックの戻り値指定
Promise.resolve()
する。
jest.spyOn(other, 'plusStatic').mockReturnValue(Promise.resolve('hoge'));
クラス直下のinstance関数のspyOnが上手くいかない
自分が経験したケースとして、クラス直下のinstance関数fuga
をspyOnしようとして、
jest.spyOn(Hoge, 'fuga')
のように実装したところ、以下のようなエラーに直面したことがありました。
Cannot spy the fuga property because it is not a function; undefined given instead
直接の原因は、prototype
下に指定した名前(fuga
)の関数が存在しないためでした。
で、prototype
下に対象関数が存在しなかった原因ですが、これは以下のようにプロパティとして関数を定義していたためでした。
class Hoge {
fuga = () => {
// do something
}
}
この実装では、prototype
下にfuga
が含まれません。
対応方法ですが、Hoge
クラスを自由に変更できるのであれば、プロパティとして関数定義するのではなく、普通に関数定義すればOKです。
class Hoge {
fuga() {
// do something
};
}
Hoge
クラスを変更できない事情がある場合は、spyOn
を諦めて、mock
を使ってHoge
クラス全体をモックするしかない気がします。
デバッグ
VS Code の Jest プラグイン を使っています。
インストールするだけで、特に何かを設定する必要もなく利用できます。
ブレークポイントを貼って、debugボタンをクリックするだけで普通にデバッグできます。
CI on Linux だけエラー
LinuxのDockerイメージを使って、CIでテストを実行した時だけエラーになるケースがあります。
エラーの内容は、例えば、以下のような感じです。
FAIL __tests__/hoge/fuga.spec.ts
● Test suite failed to run
Cannot find module '../../../src/hoge/fuga/Piyo' from 'fuga.spec.ts'
> 10 | jest.mock("../../../src/hoge/fuga/Piyo");
このケースは、 OS X では、パスの大文字小文字を区別しないけどLinuxでは区別することに起因する、ファイル名の大文字小文字問題 なことが多いです。
例は、src/hoge/fuga/piyo.ts
モジュールをsrc/hoge/fuga/Piyo
というパスで探して見つからない状況です。もちろん、OS X 上で実行する分には問題なく動きます。
いつも jest の設定やバージョンを調べて何かミスってないかを確認して時間を潰してしまうので、自戒を込めてメモっておきます。
最後に
最初の頃は、Typescript + Jest でテストを書く際、mock
を使うことが多かったですが、後にspyOn
の方が多くのケースに対応できるし、他のテストに影響を与えないことを知って、最近はspyOn
ばかり使うようになりました。
今のところ、ここで記載している範囲の知識でも、Jest で何かをテストしようとして実現できなかったことは無いので、Jest は結構使いやすい気がします。