154
85

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

株式会社日立システムズAdvent Calendar 2020

Day 23

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

Last updated at Posted at 2020-12-22

はじめに

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

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

154
85
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
154
85

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?