Jest+TypeScriptで単体テスト

# Jest+TypeScriptで単体テスト
## 背景
ずいぶん前に[JUnit5とMockitoで単体テストする記事](https://qiita.com/gitcho/items/5ce478ec8e29bbddc152)を書いたのだが、TypeScriptとJestで同じことをしたいと思うことがあるので、備忘録として記事に残しておくことにした。

## バージョンなど
`package.json`から抜粋。プロジェクト雛形は[たとえばここ](https://typescriptbook.jp/tutorials/jest)などを参考に作成しているものとして、ここでは割愛。

```json:package.json
  "devDependencies": {
    "@types/jest": "^29.2.5",
    "jest": "^29.3.1",
    "ts-jest": "^29.0.5",
    "typescript": "^4.9.4"
  }
```

## プロダクトコード
テスト対象となる、プロダクトコードを作成。

```ts:MainService.ts
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;
    }
}
```

```ts:SubService.ts
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`なメンバ変数をモックに置き換えたい場合、[リフレクションを使用する](https://docs.oracle.com/javase/jp/11/docs/api/java.base/java/lang/reflect/package-summary.html)と思う。著名なテストフレームワークである[Mockito](https://site.mockito.org/)であれば、`@InjectMocks`で`private`な変数にモックしたインスタンスを注入できるが、これも結局は内部でリフレクションに置き換えているのだと思われる。

では、TypeScriptの場合はどうやって`private`な変数にモックを注入するのか、というと`as any`で型制約をなくしてしまうのが一番簡単だ。

```ts
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`など[モック専用の機能](https://jestjs.io/ja/docs/mock-functions)が用意されており、これを利用することでモックの呼び出し回数なども検証できるようになる。

```js
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`で型変換しないと型チェック不正でモックを実装できないので注意。

```js
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`でクラスごとモックにしても、以下のようにすることで特定のメソッドだけをモックにすることができる。

```js
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の使い方を知らなくてもなんとかなってしまう。だがそれ故にモック化する方法が統一されにくい側面もあり、このあたりはプロジェクト内で共通認識を持っておいたほうがいいかもしれない。