Jest+TypeScriptで単体テスト
背景
ずいぶん前にJUnit5とMockitoで単体テストする記事を書いたのだが、TypeScriptとJestで同じことをしたいと思うことがあるので、備忘録として記事に残しておくことにした。
バージョンなど
package.json
から抜粋。プロジェクト雛形はたとえばここなどを参考に作成しているものとして、ここでは割愛。
"devDependencies": {
"@types/jest": "^29.2.5",
"jest": "^29.3.1",
"ts-jest": "^29.0.5",
"typescript": "^4.9.4"
}
プロダクトコード
テスト対象となる、プロダクトコードを作成。
import { SubService } from "./SubService";
export class MainService {
private subService = new SubService();
public getSum() {
const sum = this.subService.getNumbers().reduce((p, c) => {
return p + c;
}, 0);
return sum;
}
}
export class SubService {
public getNumbers() {
return [8, 7, 2, 3, 6, 4, 5, 8, 4, 0];
}
}
テストコード
MainService
のgetSum()
を単体テストするコードを作成する。単体テストではthis.subService.getNumbers()
の戻り値はモック化されるべきなので、TypeScriptを採用している場合のモック化方法をまとめる。
Javaではリフレクション、TypeScriptではas any
でモック注入
例えば、JavaのJUnitでプロダクトコードのprivate
なメンバ変数をモックに置き換えたい場合、リフレクションを使用すると思う。著名なテストフレームワークであるMockitoであれば、@InjectMocks
でprivate
な変数にモックしたインスタンスを注入できるが、これも結局は内部でリフレクションに置き換えているのだと思われる。
では、TypeScriptの場合はどうやってprivate
な変数にモックを注入するのか、というとas any
で型制約をなくしてしまうのが一番簡単だ。
import { MainService } from "path/to/MainService";
test('jestのmock機能を使わない方法', () => {
const service = new MainService();
(service as any).subService = { // as anyでprivateを無視して代入する
getNumbers: () => [10, 20, 30, 40] // モック関数を定義
};
expect(service.getSum()).toEqual(100);
});
Jestで関数を部分的にモック化
上記の方法で、subService
をモックオブジェクトに置き換えて単体テストすることができるが、Jestにはjest.fn
などモック専用の機能が用意されており、これを利用することでモックの呼び出し回数なども検証できるようになる。
import { MainService } from "path/to/MainService";
import { SubService } from "path/to/SubService";
test('jest.fnで作成したモック関数を使う方法', () => {
const service = new MainService();
(service as any).subService = {
getNumbers: jest.fn(() => [10, 20, 30, 40]) // モック関数を定義
};
expect(service.getSum()).toEqual(100);
expect((service as any).subService.getNumbers).toHaveBeenCalledTimes(1);//読み出し回数なども検証できる
});
test('jest.spyOnでインスタンスの特定の関数だけモック関数にする方法', () => {
const service = new MainService();
const subService = new SubService(); // 一度インスタンスを生成してから
jest.spyOn(subService, 'getNumbers').mockImplementation(() => [10, 20, 30, 40]); // 差し替えて
(service as any).subService = subService; // injectionする
expect(service.getSum()).toEqual(100);
expect(subService.getNumbers).toHaveBeenCalledTimes(1);
})
Jestでクラスを全体的にモック化
jest.mock
でSubService
クラス自体をモックにすることができる。ただし、TypeScriptの場合はas jest.Mock
で型変換しないと型チェック不正でモックを実装できないので注意。
import { MainService } from "../src/sample/MainService";
import { SubService } from "../src/sample/SubService";
// jest.mock でクラスをモックにする方法
jest.mock("../src/sample/SubService")
// 以降、SubService はモック化されている
// 必要なら SubService.mockClear() でモックの呼出記録をクリアする
test("インスタンス全体をモックにする方法", () => {
(SubService as jest.Mock).mockImplementation(() => {
// SubServiceをnewしたときに生成されるインスタンスを返す。つまりコンストラクタのモック化
return {
getNumbers: () => [10, 20, 30, 40]
}
})
const service = new MainService();
expect(service.getSum()).toEqual(100);
expect(SubService).toHaveBeenCalledTimes(1);
});
なお、jest.mock
でクラスごとモックにしても、以下のようにすることで特定のメソッドだけをモックにすることができる。
import { MainService } from "../src/sample/MainService";
import { SubService } from "../src/sample/SubService";
// jest.mock でクラスをモックにする方法
jest.mock("../src/sample/SubService")
// 以降、SubService はモック化されている
test("必要なメソッドだけをモックする方法", () => {
const mockMethod = SubService.prototype.getNumbers as jest.MockedFunction<typeof SubService.prototype.getNumbers>;
mockMethod.mockImplementation(() => [10, 20, 30, 40]);
const service = new MainService();
expect(service.getSum()).toEqual(100);
expect(mockMethod).toHaveBeenCalledTimes(1);
});
所感
Javaと違って、TypeScriptはas any
してしまえば型制約を無視して簡単にモックにしたり注入したりできるので、実はそこまでJestの使い方を知らなくてもなんとかなってしまう。だがそれ故にモック化する方法が統一されにくい側面もあり、このあたりはプロジェクト内で共通認識を持っておいたほうがいいかもしれない。