【備忘録】JestのspyOn()とmock()の使い方について

<!-- # JestのspyOn()とmock()の使い方 -->

# はじめに
jestは、javascriptやtypescriptのテストツールです。
`jest.spyOn()`と`jest.mock()`は、どちらもメソッドをmockするためのもので、テストコードの中でモック関数を定義する際に使用します。
どちらも同じようなことが出来るのですが、いつもいざ使おうとしたときに混同してしまいがちなので、備忘録としてまとめてみました。

# 環境
テストを作成した環境は、以下の通りです。

  - node: 12.19.0
  - @types/jest: 26.0.19
  - jest: 26.6.3
  - ts-jest: 26.4.4
  - ts-node: 9.0.0
  - typescript: 4.1.2

# 基本的な使い方

`jest.spyOn()`は、オブジェクトを引数に指定するのに対し、`jest.mock()`は、モジュールを引数に指定します。
つまり、mockの対象が引数に指定したオブジェクトだけなのか、モジュールそのものなのかという違いがあります。
また、`jest.mock()`でmockされモジュールは、デフォルトでは何も返さない状態になってしまうのに対し、`jest.spyOn()`の場合、デフォルトで本来のメソッドが呼ばれます。([JEST API Reference#jest.spyOn](https://jestjs.io/docs/ja/jest-object#jestspyonobject-methodname)より)

## テスト対象サンプルコード
以下のサンプルコードで、定義された`TestClass`クラスの`getText()`メソッドをテストする場合を例に記載します。

- moduleA.ts:mock対象。`CalcModuleA`クラスと`TextModuleA`クラスが定義されたファイル
- test.ts：テスト対象。`TesTClass`クラスが定義されたファイル

### moduleA.ts

```typescript
export class CalcModuleA {
    constructor(readonly x: number){}
    exec(a: number, b: number) {
        return a + b - this.x;
    }
}
export class TextModuleA {
    exec(a: string, b: string) {
        return a + b;
    }
}
```
### test.ts

```typescript
import { CalcModuleA, TextModuleA } from './moduleA';

export class TestClass {
    calc: CalcModuleA;
    constructor(readonly text: TextModuleA) {
        this.calc = new CalcModuleA(1);
     }
    
    public getText(textA: string, textB: string) {
        const s1 = textA.split('s');
        const s2 = textB.substr(-2);
        const c = this.calc.exec(textA.length, textB.length);
        const t = this.text.exec(s1[0], s2);
        return t + s1[s1.length -1] + c;
    }
}
```
## `jest.spyOn()`でmockする場合
`TextModuleA.exec mock`テスト内で、`TextModuleA`クラスのインスタンスを作成後、`jest.spyOn()`でmock化し処理を定義します。

```typescript
import { CalcModuleA, TextModuleA } from '../moduleA';
import { TestClass } from '../test';

describe('spyOn', () => { 
    it(`TextModuleA.exec mock`, () => {
        　//TestClassクラスに渡すTextModuleAクラスのインスタンスを作成
        const t = new TextModuleA();
        //execメソッドのmock化
        const mockTextExec = jest.spyOn(t, 'exec');
        //mock処理を仕込む
        mockTextExec.mockImplementation((a: string, b: string) => {
            return `spy`;
        });
        
        const test = new TestClass(t);
        //testインスタンス内のCalcModuleAクラスのexecメソッドをmock化
        //インスタンスが取得さえ出来れば、mock化することができる。
        const mockCalcExec = jest.spyOn(test['calc'], 'exec');

        //テスト対象のメソッドを実行
        const result = test.getText('testA', 'planB');
        console.log(`spyOn result: ${result}`);

        //mock化したメソッドの引数を検証
        expect(mockTextExec).toBeCalledTimes(1);
        expect(mockTextExec).toBeCalledWith('te', 'nB');
        expect(mockCalcExec).toBeCalledTimes(1);
        expect(mockCalcExec).toBeCalledWith(5, 5);
        //テスト対象メソッドの結果を検証
        expect(result).toBe('spytA9');
    })
});
```
## `jest.mock()`でmockする場合
テストファイルの最初で、mock化を行います。
`TestModuleA`クラスをmock化する際に、`CalcModuleA`クラスも一緒にmock化されてしまうため、一緒に処理を定義しておく必要があります。

```typescript
jest.mock('../moduleA', () => {
    //moduleAモジュールのimport結果をmock
    return {
        //TextModuleAクラスのコンストラクタのmock定義
        TextModuleA: jest.fn().mockImplementation(() => {
            return {
                exec: jest.fn(),
            }
        }),
        //CalcModuleAクラスのコンストラクタのmock定義
        CalcModuleA: jest.fn().mockImplementation(() => {
            return {
                //execメソッドをmock化し、常に1を返すように設定
                exec: jest.fn().mockReturnValue(1),
            }
        })
    }
});
import { TextModuleA } from '../moduleA';
import { TestClass } from '../test';

describe('jest.mock', () => {
    it(`TextModuleA.exec mock`, () => {
        //TestClassクラスに渡すTextModuleAクラスのインスタンスを作成
        const t = new TextModuleA();
        //execメソッドのmock定義を取得
        const mockTextExec = t.exec as jest.Mock;
        //mock処理を仕込む
        mockTextExec.mockImplementation((a: string, b: string) => {
            return `mock`;
        });
        const test = new TestClass(t);
        //testインスタンス内のCalcModuleAクラスのexecメソッドのmock定義を取得
        const mockCalcExec = test['calc'].exec;

        //テスト対象のメソッドを実行
        const result = test.getText('testA', 'planB');
        console.log(`mock result: ${result}`);

        //mock化したメソッドの引数を検証
        expect(mockTextExec).toBeCalledWith('te', 'nB');
        expect(mockCalcExec).toBeCalledWith(5, 5);
        //テスト対象メソッドの結果を検証
        expect(result).toBe('mocktA1');
    });
})
``` 
この例では、特定のインスタンスの1メソッドをmockして1件だけのテストをしているのみであるため、`jest.spyOn()`の方が`jest.mock()`に比べ、シンプルに記述することができます。

## mockの結果を呼び出し毎に設定する場合
複数回呼ばれるメソッドで、都度異なる結果を返すようにしたい場合のmock処理の定義方法です。
`xxxOnce()`でmock定義することで1度だけ呼ばれるようになります。複数個の`xxxOnce()`を連続で定義すると、定義した順番にその値が返されるようになります。
`xxxOnce()`が全て返された後は、`xxxOnce()`でないmock処理が定義されていた場合は、その値を返しますが、何も定義されていない場合、デフォルトの処理が呼ばれます。

```typescript
    it('mock returnValues', () => {
    //TestClassクラスに渡すTextModuleAクラスのインスタンスを作成
    const t = new TextModuleA();
    //execメソッドのmock化
    const mockTextExec = jest.spyOn(t, 'exec');
    //mock処理を仕込む
    mockTextExec.mockImplementationOnce((a: string, b: string) => {
        return `spy1`;
    }).mockImplementationOnce(() => 'spy2')
        .mockReturnValueOnce('spy3');

    //先に定義した値から順番に返される
    expect(t.exec('a', 'b')).toBe('spy1');
    expect(t.exec('a', 'b')).toBe('spy2');
    expect(t.exec('a', 'b')).toBe('spy3');
    //定義したものを全て呼んだ後は、デフォルトの処理が呼ばれる
    expect(t.exec('a', 'b')).toBe('ab');
})
```
    
## mockの初期化処理
mockした処理が呼ばれた後、そのまま別のテストを行うと、定義済みのmockが影響して上手くテストが出来ないことがあり得ます。そこで、テストの前後どちらかでmockの初期化処理を行うようにすべきです。
初期化に関するメソッドは、以下の3つがあります。（[JEST API Reference](https://jestjs.io/docs/ja/mock-function-api)より）

- **`mockClear()`**：mockの実行履歴（呼び出し回数など）をクリアします。
- **`mockReset()`**：`mockClear()`の内容に加えて、mockの定義(戻り値や、関数)も完全に初期化する。`jest.spyOn()`のものに使用した場合でも、戻り値のない関数となるので注意が必要です。
- **`mockRestore()`**：`jest.spyOn()`のものにのみ有効です。`mockReset()`の内容に加えて、対象をmock前の処理に復元します。

最低でも`mockClear()`は行うようにした方が良いです。



# 少しひねった使い方

## `jest.spyOn()`で、インスタンスを取得せずにmockする方法
基本的に、`jest.mock()`でmockすれば良いですが、`jest.spyOn()`でも出来ます。
mock対象のメソッドが一部であったり、mock処理を実装せず、引数の検証だけしたい場合などは、`jest.spyOn()`を使用した方が容易となります。
`jest.spyOn()`で、引数に指定するオブジェクトとして`xxx.prototype`(`xxx`は、クラス名)を使用することでmockすることができます。
ただし、mock対象がインスタンスではないため、テストの前後のどちらかで、`mockRestore()`などをしないと、ほかのテストに影響が出てしまうので、注意が必要です。

```typescript
import { CalcModuleA, TextModuleA } from '../moduleA';
import { TestClass } from '../test';

describe('spyOn prototype', () => {
    beforeEach(() => {
        //CalcModuleAクラスのexecメソッドをmock化
        jest.spyOn(CalcModuleA.prototype, 'exec');
        //TextModuleAクラスのexecメソッドのmock化
        jest.spyOn(TextModuleA.prototype, 'exec');
    });
    afterEach(() => {
        //test外で、mock定義を行うため、`afterEach()`で、mockの初期化やクリア処理を行う。
        //CalcModuleAクラスのexecメソッドのmock履歴をクリア
        (CalcModuleA.prototype.exec as jest.Mock).mockClear();
        //TextModuleAクラスのexecメソッドのmock定義を本来のものに戻す
        (TextModuleA.prototype.exec as jest.Mock).mockRestore();
    })
    it(`TextModuleA.exec mock`, () => {
        //TextModuleAクラスのexecメソッドのmock処理を仕込む
        (TextModuleA.prototype.exec as jest.Mock).mockImplementation((a: string, b: string) => {
            return `spy`;
        });
        
        //TestClassクラスに渡すTextModuleAクラスのインスタンスを作成
        const t = new TextModuleA();
        const test = new TestClass(t);
        
        //テスト対象のメソッドを実行
        const result = test.getText('testA', 'planB');
        console.log(`spyOn result: ${result}`);

        //mock化したメソッドの引数を検証
        expect(TextModuleA.prototype.exec).toBeCalledTimes(1);
        expect(TextModuleA.prototype.exec).toBeCalledWith('te', 'nB');
        expect(CalcModuleA.prototype.exec).toBeCalledTimes(1);
        expect(CalcModuleA.prototype.exec).toBeCalledWith(5, 5);
        //テスト対象メソッドの結果を検証
        expect(result).toBe('spytA9');
    });
});
```

## `jest.spyOn()`でコンストラクタをmockする方法
こちらも、`jest.mock()`でmockすれば良いですが、`jest.spyOn()`でも出来ます。
`jest.spyOn()`で、引数に指定するオブジェクトとしてモジュール自体を指定することで、コンストラクタのmockをすることができます。
ただし、他の`jest.spyOn()`と異なりデフォルトでも本来のメソッドが呼び出されません。
そのため、テストの際にmock処理を定義する必要があります。

### CalcModuleAクラスのコンストラクタをmockする例
moduleAのCalcModuleAクラスのコンストラクタをmockするサンプルです。
本来のコンストラクタが呼び出せるように、`jest.spyOn()`を行う前に`original`変数に格納し、`spyOn()`時の初期mock処理として呼び出すようにmock処理を定義しています。

```typescript
//moduleAモジュール全体を読み込む
import * as moduleA from '../moduleA';
import { TextModuleA, CalcModuleA } from '../moduleA';
import { TestClass } from '../test';

describe('spyOn constructor', () => {
    //mock前の本来の処理を格納する。
    const original = moduleA.CalcModuleA;
    
    beforeEach(() => {
        //mockしたいコンストラクタのクラスを指定することで、コンストラクタをmockできる。
        //デフォルトの処理として、本来のコンストラクタを呼び出すようにする。
        jest.spyOn(moduleA, 'CalcModuleA').mockImplementation((x)=> {return new original(x)});
    })
    afterEach(() => {
        //CalcModuleAクラスのコンストラクタのmock定義を本来のものに戻す
        (CalcModuleA as jest.Mock).mockRestore();
    })
    it(`CalcModuleA.exec mock`, () => {
        //CalcModuleAクラスのコンストラクタをmockし、execメソッドが常に1000を返すように設定
        const mockCalcExec = jest.fn((a, b) => { return 1000 });
        (CalcModuleA as jest.Mock).mockImplementation((x: number) => {
            return {
                x,
                exec: mockCalcExec
            }
        });
        const t = new TextModuleA();
        const test = new TestClass(t);
        const result = test.getText('testA', 'planB');
        console.log(`spyOn result: ${result}`);

        //mock化したコンストラクタの呼び出し回数と引数を検証
        expect(CalcModuleA).toBeCalledTimes(1);
        expect(CalcModuleA).toBeCalledWith(1);
        //mock化したexecメソッドの引数を検証
        expect(mockCalcExec).toBeCalledWith(5, 5);
        expect(result).toBe('tenBtA1000');
    });
})
```
### Dateをmockする例
globalのDateクラスのコンストラクタをmockするサンプルです。
`new Date()`の結果を、固定するためのmockです。コンストラクタの引数が無い場合のみmockした値を返すようにmock定義を行っています。
`jest.spyOn()`で定義しているため、`mockRestore()`するだけで、本来の処理に復元が可能です。

```typescript
    //mockする前の本来の処理を格納
    const OriginalDate = Date;
    //mockで返す固定のDateを定義
    const now = new OriginalDate('2020/04/01 11:23:34.567');
    const spiedDate = jest.spyOn<any, any>(global, 'Date').mockImplementation((...arg: any[]) => {
        const p = Array(7).fill(0);
        arg.forEach((v, i) => p[i] = v);
        if (arg.length > 0) {
            return arg.length === 1 ? new OriginalDate(arg[0]) : new OriginalDate(p[0], p[1], p[2], p[3], p[4], p[5], p[6]);
        }
        return now;
    });
    ・・・
    //mockを解除して本来の処理に戻す際に呼ぶ
    spiedDate.mockRestore();
```

## `jest.mock()`で、本来のメソッドを残しつつmockする方法
`jest.mock()`と`jest.requireActual()`を使用することで、`jest.spyOn()`と同様に、コンストラクタやメソッドをspyしつつ本来の処理を呼び出すことができます。([JEST API Reference#jest.requireActual](https://jestjs.io/docs/ja/jest-object#jestrequireactualmodulename)より)

### CalcModuleAクラスのコンストラクタをmockする例

```typescript
import { CalcModuleA, TextModuleA } from '../moduleA';
import { TestClass } from '../test';

//moduleAモジュールの本来のモジュール
let original: any;

jest.mock('../moduleA', () => {
    //moduleAモジュールの本来のモジュールを読み込む
    original = jest.requireActual('../moduleA');
    return {
        //moduleAモジュールの本来の処理を展開
        ...original,
        //CalcModuleAクラスのコンストラクタ定義を上書き。
        CalcModuleA: jest.fn().mockImplementation((x: number) => {
            //初期処理として本来のコンストラクタを呼び出し
            return new original.CalcModuleA(x);
        }),
    }
});

describe('jest.mock constructor', () => {
    afterEach(() => {
        //CalcModuleAクラスのコンストラクタのmock履歴をクリア
        (CalcModuleA as jest.Mock).mockClear();
        //コンストラクタのmock定義がテストごとに変わる場合は、再定義する。
        (CalcModuleA as jest.Mock).mockImplementation((x: number) => {
            //初期処理として本来のコンストラクタを呼び出し
            return new original.CalcModuleA(x);
        });
    })

    it(`arguments check`, () => {
        //TestClassクラスに渡すTextModuleAクラスのインスタンスを作成
        const t = new TextModuleA();
        const test = new TestClass(t);

        //テスト対象のメソッドを実行
        const result = test.getText('testA', 'planB');
        console.log(`mock result: ${result}`);

        //mock化したコンストラクタの引数を検証
        expect(CalcModuleA).toBeCalledTimes(1);
        expect(CalcModuleA).toBeCalledWith(1);
        //テスト対象メソッドの結果を検証
        expect(result).toBe('tenBtA9');
    });
    it(`CalcModuleA.exec mock`, () => {
        //CalcModuleAクラスのコンストラクタをmockし、execメソッドが常に1000を返すように設定
        const mockCalcExec = jest.fn((a, b) => { return 1000 });
        (CalcModuleA as jest.Mock).mockImplementation((x: number) => { 
            return {
                exec: mockCalcExec
            }
        });
        const t = new TextModuleA();
        const test = new TestClass(t);
        const result = test.getText('testA', 'planB');
        console.log(`mock2 result: ${result}`)

        expect(CalcModuleA).toBeCalledTimes(1);
        expect(CalcModuleA).toBeCalledWith(1);
        //mock化したexecメソッドの引数を検証
        expect(mockCalcExec).toBeCalledWith(5, 5);
        expect(result).toBe('tenBtA1000');
    })
})
```
### pathモジュールの`isAbsolute()`をmockする例
自作のモジュールでなくても、同様にmockすることが可能です。

```typescript
import path from 'path';
jest.mock('path', () => {
    //pathモジュールの本来の処理を格納
    const original = jest.requireActual('path');
    return {
        ...original,
        //isAbsolute()の処理をmockに差し替え
        isAbsolute: jest.fn((filePath: string) => {
            return original.isAbsolute(filePath);
        }),
    }
})
describe('path', () => {
    it('isAbsolute', () => {
        //1回目は、必ずtrueを返すようにmock定義を追加
        (path.isAbsolute as jest.Mock).mockReturnValueOnce(true);

        //1回目は、trueが返される
        expect(path.isAbsolute('./test.ts')).toBe(true);
        //joinメソッドは、本来の処理が呼ばれる
        expect(path.join('z', 'a')).toBe('z\\a');
        //2回目は、本来の処理が呼ばれ、falseが返される
        expect(path.isAbsolute('./test.ts')).toBe(false);
    });
});
```
### fsモジュールの`existsSync()`をmockする例
本来の処理を、`originalExistsSyncFunction`としてmock処理の外部に格納することで、テストの際に必要に応じて本来の処理を呼ぶことが出来ます。（例：テストの結果としてファイルの存在確認をしたい。など）

```typescript
//existsSync()の本来の処理を格納する変数
//あとから、本来の処理を呼べるようにするために定義しておく。
let originalExistsSyncFunction;
jest.mock('fs', () => {
    const original = jest.requireActual('fs');
    //existsSync()の本来の処理を格納
    originalExistsSyncFunction = original.existsSync;
    return {
        ...original,
        existsSync: jest.fn((filePath: string) => {
            //デフォルトで、本来の処理を行うようにmockを定義
            return originalExistsSyncFunction(filePath);
        }),
    };
});

describe('fs', () => {
    it('existsSync', () => {
        //常にfalseを返すように定義
        (fs.existsSync as jest.Mock).mockReturnValue(false);

        expect(originalExistsSyncFunction('./write.txt')).toBe(false);
        expect(fs.existsSync('./write.txt')).toBe(false);
        fs.writeFileSync('./write.txt', 'write');
        expect(originalExistsSyncFunction('./write.txt')).toBe(true);
        expect(fs.existsSync('./write.txt')).toBe(false);

        expect(fs.existsSync).toBeCalledTimes(2);
    })
});
```

# おわりに
今回、`jest.spyOn()`と`jest.mock()`を使ったmockの方法についてまとめてみました。
両方とも出来ることはほぼ同じですが、mockを行う際の指定方法やmockの初期化時の挙動に違いがある点は、気を付ける必要があります。
個人的には、`jest.spyOn()`の方が手軽に使えるのかなという印象です。

記載の会社名、製品名、サービス名等はそれぞれの会社の商標または登録商標です。