【備忘録】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より)

テスト対象サンプルコード

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

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

moduleA.ts

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

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化し処理を定義します。

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化されてしまうため、一緒に処理を定義しておく必要があります。

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処理が定義されていた場合は、その値を返しますが、何も定義されていない場合、デフォルトの処理が呼ばれます。

    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より)

  • 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()などをしないと、ほかのテストに影響が出てしまうので、注意が必要です。

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処理を定義しています。

//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()するだけで、本来の処理に復元が可能です。

    //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より)

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

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することが可能です。

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処理の外部に格納することで、テストの際に必要に応じて本来の処理を呼ぶことが出来ます。(例:テストの結果としてファイルの存在確認をしたい。など)

//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()の方が手軽に使えるのかなという印象です。

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