💯

Jestのモックを駆使してテストをよりよくする

2020/12/03に公開

JavaScriptで単体テストする際は、Jestを使うのがデファクトになってきています。
単体テストでは、関連するモジュールをモックにしてテストすることが多いですね。
ここでは、Jestのモックの機能と使い方をユースケース別に説明します。

クラスのstaticなメソッドをモックしたい

例えば、自分の関数内でDate.now()を使って時間を取得していると、テストを実行するたびに値が異なるため、テストがうまくいかないことがあります。
そのような場合、jest.spyOnを使います。
spyOnを使うことである特定の時間を返すことができるようになります。

class Test {
  func() {
    return Date.now();
  }
}

describe("Date#now", () => {
  it("spyOnを使うと好きな時間に固定することができる", () => {
    const spy = jest.spyOn(Date, "now");
    spy.mockReturnValue(1577804400000); // 2020/01/01
    expect(new Test().func()).toBe(1577804400000);
    spy.mockRestore();
  });
});

インスタンスメソッドをモックしたい

インスタンスAの中でインスタンスBのメソッドを呼び出して、その結果を使って何かする、みたいな処理ってよくあるじゃないですか。

code.ts
class ClassA {
  constructor(readonly b: ClassB) {}
  func() {
    return this.b.someFunc().toUpperCase();
  }
}

class ClassB {
  someFunc() {
    return "hello world.";
  }
} 

ClassAのテストとしては、ClassBの関数を呼んだ結果をtoUpperCase()している、ということだけテストできればよいわけです。ClassBの実体を使っていると、ClassBの仕様が変わったときにClassAのテストも変えないといけなくなります。

そこで、ClassAのテストではClassBのモックを使います。
まずは、ただのオブジェクトにsomeFuncという関数を持たせる方法でやってみます。
ただのオブジェクトをClassBのインスタンスだと偽っており、立派なモックの役割を果たしています。

code.spec.ts
describe("ClassA#func", () => {
  it("ClassB#someFuncの結果をtoUpperCaseしていることをテストする", () => {
    const bMock = { someFunc: () => "foo" };
    const a = new ClassA(bMock);
    expect(a.func()).toBe("FOO");
  });
});

ClassBの関数が呼ばれたことも確認する

ところで、上記例では、a.func()の内部実装で"foo".toUpperCase()してても通っちゃうんですよね。ClassBのメソッドが呼ばれたかどうかは検証できていないわけです。呼ばれたことをあとで確認できるしくみはないでしょうか。

jest.fn()を使います。
これはモック関数と呼ばれる関数を返します。モック関数は呼ばれてもなにもしませんが、どのような引数で何回呼ばれたかを記録しています。また、必要であれば、戻り値を指定したり、内部実装を書いたりもできます。
これを使ってよりよくしてみましょう。

code.spec.ts
describe("ClassA#func", () => {
  it("ClassB#someFuncの結果をtoUpperCaseしていることをテストする", () => {
    const bMock = { someFunc: jest.fn() };
    bMock.someFunc.mockReturnValueOnce("foo");
    const a = new ClassA(bMock);
    expect(a.func()).toBe("FOO");
    expect(b.someFunc).toBeCalledTimes(1);
  });
});

いかがでしょうか。jest.fn()が返す関数オブジェクトは特殊で、いくつかのメソッドが生えており、ここではmockReturnValueOnceを使って、呼ばれたら一度だけ決まった値を返すように設定しています。
また、Jestが提供するexpectで関数が1度だけ呼ばれたことを確認しています。
これなら、ClassBのメソッドが呼ばれていそうなことも確認できましたね。

jest-whenでより読みやすい書き方にする

jest-whenというライブラリを追加してより読みやすい書き方にできます。

code.spec.ts
describe("ClassA#func", () => {
  it("ClassB#someFuncの結果をtoUpperCaseしていることをテストする", () => {
    const bMock = { someFunc: jest.fn() };
    // bMock.someFunc.mockReturnValueOnce("foo");
    when(bMock.someFunc).expectCalledWith().mockReturnValueOnce("foo")
    const a = new ClassA(bMock);
    expect(a.func()).toBe("FOO");
    expect(b.someFunc).toBeCalledTimes(1);
  });
});

expectCalledWithを使うことで、それ以外の引数で関数が呼ばれたらエラーになるようになっています。
これで、引数なしで1度だけ呼ばれたことまで確認できるようになりました。

外部ライブラリをまるっとモック化したい

テスト時に外部ライブラリの挙動を制御したいことってありますよね。例えば、ランダムな数値を得るためにrandomというライブラリを入れている場合など、テストが運ゲーになってしまいます。

myRandom.ts
import { float } from "random";

export function randomFloat() {
  return float(0, 100);
}
myRandom.spec.ts
import {randomFloat} from "./random";
describe("#randomFloat", () => {
  it("通ってくれたのむ", () => {
    expect(randomFloat()).toBe(50.0);
  });
});
結果
Expected: 50
Received: 39.86290552096512

  17 | describe("#randomFloat", () => {
  18 |   it("通ってくれたのむ", () => {
  19 |     expect(randomFloat()).toBe(50.0);
     |                           ^
  20 |   });
  21 | });

このような問題を避けるため、外部のライブラリをモックする方法があります。

外部のライブラリをモックする

jest.mock("random")を使います。
これを書いておくと、このテストファイルを実行している間、randomライブラリはjest.fn()でモック関数化された状態になります。
floatという関数もモック関数になっていますから、インポートした関数にmockReturnValueOnceメソッドが生えています。これで、祈らなくても通ってくれるようになります。

myRandom.spec.ts
import {float} from "random";
import {randomFloat} from "./random";
jest.mock("random");

describe("#randomFloat", () => {
  it("通ってくれたのむ", () => {
    (float as jest.Mock).mockReturnValueOnce(50.0);
    expect(randomFloat()).toBe(50.0);
  });
});

問題は、上記テストファイル内ではfloatは実体を使うことができなくなることです。(モックされた状態から戻せない)

外部のライブラリをモックしたりモックしなかったりする

randomFloatのテストを例に説明します。以下に3件のテストケースを挙げています。
説明が必要そうなところに、直接コメントで記載しています。

random.spec.ts
describe("#randomFloat", () => {
  it("jest.doMockしたとき、requireで読み込んだモジュールがモック関数になっていること", () => {
    // requireやimportで使われるキャッシュを破棄する。詳細は後述。 ・・・ 1.
    jest.resetModules();
    // doMockを呼んだあとにモジュールをrequireすると、モックされたものが得られる ・・・ 2
    // この効果はこのファイル内で継続するので、beforeEachや実体が必要なテストでdontMockすること
    jest.doMock("random");
    const {randomFloat} = require("../myRandom");
    const {float} = require("random");
    (float as jest.Mock).mockReturnValueOnce(50)
    expect(randomFloat()).toBe(50.0);
  });

  it("jest.dontMockしたとき、requireで読み込んだモジュールがモック関数になっていないこと", () => {
    jest.resetModules();
    // 前のテストでdoMockしているので、それを打ち消している。 ・・・ 3
    jest.dontMock("random");
    const {randomFloat} = require("../myRandom");
    const actual = randomFloat()
    expect(actual).toBeGreaterThanOrEqual(0);
    expect(actual).toBeLessThanOrEqual(100);
  });

  it("importを使ってもモックできること", async () => {
    jest.resetModules();
    // 前のテストでdontMockしているので、それをさらに打ち消している。
    jest.doMock("random");
    const {randomFloat} = await import("../myRandom"); // ・・・ 4
    const {float} = await import("random");
    (float as jest.Mock).mockReturnValueOnce(80.3)
    expect(randomFloat()).toBe(80.3);
  });
});

1. jest.resetModules()

そもそも、テスト実行しているNode.jsは、require("moduleName")を初めて実行したときにそのモジュールファイルを読み込みにいきます。このとき、モジュールはキャッシュされ、次回以降のrequireではそのキャッシュが読み込まれることになります。
doMockするよりも前に、requireした場合、モックされていないモジュールがキャッシュされているため、doMockしても意味がないのです。
そこで、以下のような流れでモジュールを読み込むことで、モジュールを明示的に好きなタイミングでモックすることができるようになります。

  1. resetModulesしてキャッシュをクリアしておく
  2. doMockでモックしたモジュールを読み込みたいことを宣言する
  3. requireでモジュールを読みこむ -> モックされたモジュールが読み込まれる

2. doMockしたあとmyRandomも読み込みなおす

randomモジュールをdoMockしたあと、myRandomを読み込みなおしています。
理由は、doMockより前にmyRandomを読み込んだ場合、myRandomは内部でrandomモジュールをインポートしているため、doMockが効かない(既に実体が読み込まれてしまっている)からです。
キャッシュを削除し、doMockでモックすることを宣言し、関連しているモジュールをimportし直さなければ狙った通りに動いてくれません。

3. dontMockは必要か?

必要です。doMockでモックすることを宣言しているため、宣言したあとは何度キャッシュを消してもモックされたモジュールが読み込まれます。
そこで、requireの前にdontMockでモックしないことを宣言する必要があるのです。

4. import文も使える

requireの代わりに、動的importも使えます。やることはあまり変わりませんが、requireかimport、どっちかに統一して書いた方がよいでしょう。

まとめ

以上、いくつかのユースケースを例にあげてJestのモックの使い方を説明しました。
私はjest.fn()くらいしか使っていませんが、他の例もトリッキーなパターンでは必要になることもあるでしょう。
そもそも、テストがしづらい場合には何か設計がおかしいことも多いので、それを見直してみるのもよいでしょう。

以上です。よろしくお願いいたします。

Discussion