209
139

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.

なぜJestのmockライブラリに混乱してしまうのか?

Last updated at Posted at 2020-08-27

はじめに

JavaScriptのモックライブラリでは、 sinon などが有名であるが、テスティングフレームワークに Jest を使ってるならば Jest組み込みのモックライブラリで済ませたほうが学習コスト少なくて済むだろうと思える。

しかし、 sinon の感覚でJestのモックライブラリを使おうとすると違和感というのか、モックへの考え方の違いに気づかされる。 ということで今回は、Jestのモックライブラリの考え方と使い方を整理していきたいと思う。

モックの用語整理とJestモックライブラリの位置づけ

モックと一言でいっても、それが指す内容は微妙に異なる。 ここでは、モックを 広義のMock Object狭義のMock Object と分けて整理してくれているテスト駆動開発を参考に用語を整理する。

テスト駆動開発では、モック用語を、下図のとおり、テストダブルとそのサブクラスとして Dummy, Spy, Stub, Mock, Fake という分け方をする。 これはxUnitを活用したテストコードの形式・パターンを整理する中で登場した考え方であり、 このテストダブルを、 広義のMock Object と呼び、サブクラスのmockは、 狭義のMock Object として両者を明確にわける。 よく耳にするモックモックというのは、 広義のMock Object を指しているということになる。

ファイル名

sinonはこのxUnitの分類に沿って作られている。
よって、 spy, stub, fake , mock を明確に分けてAPIを提供している。

一方でJestの場合、どちらかいうと 広義のモック という観点でAPIを提供してる。
このため spy, stubメソッドはどれかなどとAPIを探しては混乱してしまうことになる。

spyやstubをJestのモックライブラリで使いこなすにはどのように実装すればよいのかということと紹介していこうと思う。

モックする前の設計整理

spy, stubを実装していく前に、まずどのような関数をspy, stubさせていくのかを説明する。
今回モック対象として関数を利用するが、オブジェクト・クラスであっても同じである。

まず以下の2つの関数を用意する。

src/double.ts
function double(x: number): number {
  return 2 * x;
}

doubleは受け取った引数を2倍にして返す関数である。

src/doubleSquare.ts
function doubleSquare(x: number): number {
  const d = double(x);
  return d * d;
}

doubleSquare は、引数を2倍にして自乗する関数である。 2倍にするときに、 double 関数を利用している。

これら関数の関係をシーケンス図にすると次のような関係になっている。

jest.png

上図のとおり、doubleSquareはdoubleを利用している、すなわちdoubleに依存しているわけだ。 よって、 doubleSquare 関数をテストするならば、この依存をうまく検出・排除していくことになる。 プロダクションコードの場合、doubleの部分がHTTPリクエストやDBアクセスなどにとって変わると理解してもらえるよい。

spy

spyの役割は、その名の通りスパイのように特定の関数に忍び込み、その関数のinput/outputがなんであるか情報を盗み取ることである。 引数に何が渡ってきたのか・どんな値を返したのか・返さなかったのかを記録してくれる。 そしてStubとは異なり振る舞い自体を変更することはしない。

今回の場合では、上図の(1)のinput/outputを盗聴し、(2)の結果を変更したりしないということになる。

これをJestで実装していくが、JestはxUnitで分類したようにSpy, Stubなどを明確に使い分けしない。 それより 広義のmock という枠組みで整理されているので、spyの実装も、 spy 関数や, mock 関数の両方が登場してくる。

依存先がexport defaultを使っている場合

double関数を export default を使って、他からインポート可能にする。

src/double.ts
export default function double(x: number): number {
  return 2 * x;
}

doubleSquareでは、doubleをimportする。

src/doubleSquare.ts
import double from './double'

export default function doubleSquare(x: number): number {
  const d = double(x);
  return d * d;
}

これで対象ファイルが用意できたので、テストコードを作成していく。

jest.mockをつかう

まずひとつめのspyのやり方は、jest.mockを使うやり方になる。
mockだけどspy機能をもってるのが、説明してきたJestのモック定義の捉え方である。

import * as assert from 'power-assert';
import double from '../../src/mock/export-default/double';
import doubleSquare from '../../src/mock/export-default/doubleSquare';

jest.mock('../../src/mock/export-default/double', () => {
  const originalModule = jest.requireActual(
    '../../src/mock/export-default/double'
  );
  return {
    __esModule: true,
    default: jest.fn().mockImplementation(originalModule.default),
  };
});

describe('spy export default function with jest.mock', () => {
  afterEach(() => {
    // doubleはmockされていても、importしたときの情報しかもたないので、
    // mockメソッドを使用するには、jest.Mockにキャストが必要
    (double as jest.Mock).mockClear();
  });

  it('doubleが呼び出されるのは1回', () => {
    const actual = doubleSquare(3);
    // .mock.callsは呼び出した回数分の引数の情報がはいる
    // lengthを使えば、何回呼び出されたのかがわかる
    assert.equal((double as jest.Mock).mock.calls.length, 1);
  });

  it('doubleの引数は3である', () => {
    const actual = doubleSquare(3);
    // .mock.calls[index]でindex回目の引数情報にアクセスできる
    assert.equal((double as jest.Mock).mock.calls[0], 3);
  });

  it('double処理は正常終了し、例外をなげない', () => {
    const actual = doubleSquare(3);
    // .mock.resultsは、mock対象の出力情報を配列管理している
    // [0]ではじめに呼び出されたときの出力情報にアクセスでき, typeでそれが正常終了, 異常終了などを判定できる
    assert.equal((double as jest.Mock).mock.results[0].type, 'return');
  });

  it('doubleのreturn値は3を2倍した6である', () => {
    const actual = doubleSquare(3);
    // .mock.results[index].valueでindex回目に出力した値にアクセスできる
    assert.equal((double as jest.Mock).mock.results[0].value, 6);
  });

  // spyなのでdouble関数の振る舞いはそのまま反映されることを確認
  it('doubleSquareの戻り値は、(2 * 3) ^ 2 で36になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 36);
  });
});

この書き方は、mockしたときに振る舞いをもともとの関数で上書きすることで、spyにしてしまうというやり方である。 Jestのmockインスタンスは、spyの機能を持っている。 .mock を使うとその入出力を調べることができる。 だが、mockするとstubのように返り値も変更してしまうので、元々の関数で振る舞いを上書きしようというわけだ。

jest.mock() の第一引数はモックしたいモジュールのファイルパスをさし、第二引数は、そのモジュールの振る舞いを定義する関数である。 この場合、まず '../src/double' をモックし、returnされるオブジェクトに振る舞いを変更している。

もとの関数の振る舞いを取得したいので、 jest.requireActual を使い、 jest.fn().mockImplementation(originalmodule.default) で同じ振る舞いさせている。
export default の関数をモックするときに注意すべきは、 __exModule: true, default というキーである。
これが絶対必要になる。

jest.spyOnをつかう

また別の書き方として、 jest.spyOn を使ってspyを実現できる。

import doubleSquare from '../src/doubleSquare';

describe('spy export default', () => {
  let spy: jest.SpyInstance;

  beforeAll(() => {
    const org = require('../src/double');
    spy = jest.spyOn(org, 'default');
  });

  afterEach(() => {
    // 毎回テストごとに盗聴した入出力情報をリセットする
    spy.mockClear();
  });

  it('呼び出されるのは1回', () => {
    const actual = doubleSquare(3);
    // spy.mock.callsは呼び出した回数分の引数の情報がはいる
    // lengthを使えば、何回呼び出されたのかがわかる
    assert.equal(spy.mock.calls.length, 1);
  });

  it('引数は、3である', () => {
    const actual = doubleSquare(3);
    // spy.mock.calls[index]でindex回目の引数情報にアクセスできる
    assert.equal(spy.mock.calls[0], 3);
  });

  it('処理は正常終了し、例外をなげない', () => {
    const actual = doubleSquare(3);
    // spy.mock.resultsは、spy対象の出力情報を配列管理している
    // [0]ではじめに呼び出されたときの出力情報にアクセスでき, typeでそれが正常終了, 異常終了などを判定できる
    assert.equal(spy.mock.results[0].type, 'return');
  });

  it('return値は3である', () => {
    const actual = doubleSquare(3);
    // spy.mock.results[index].valueでindex回目に出力した値にアクセスできる
    assert.equal(spy.mock.results[0].value, 3);
  });

  it('doubleSquareの戻り値は、(2 * 3) ^ 2 で36になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 36);
  });
});

spyインスタンスから、 .mock にアクセスすることで、入出力を調査することができる。

spyOn メソッドは必ず第二引数を求められるため、次のように export default 関数を import して利用することはできない。

import double from '../src/double';
jest.spyOn(double, 'default')

代わりに require を使うと、 export された関数は default というkey名でアクセスできるので、 beofreAll 内でjest.spyOn(org, 'default') と記述し、spyを可能にしている。

どうしても import を使いたい場合、 alias を使えば次のようにもかける。

// * asを利用することでdefaultでアクセス可能にする
import * as double from '../src/double';

describe('spy export default', () => {
  let spy: jest.SpyInstance;
  beforeAll(() => {
    spy = jest.spyOn(double, 'default');
  });

  afterEach(() => {
    spy.mockClear();
  });

  it('spyが呼び出されるのは1回', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.calls.length, 1);
  });

  it('spyの引数は、3である', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.calls[0], 3);
  });

  it('spyは正常に処理が終了する', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.results[0].type, 'return');
  });

  it('spyのreturn値は3である', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.results[0].value, 3);
  });

  it('doubleSquareの戻り値は、(2 * 3) ^ 2 で36になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 36);
  });
});

依存先がexportを使っている場合

export defaultではなく、 export を利用するので、もとのコードを以下のように変更する。

src/double.ts
export function double(x: number): number {
  return 2 * x;
}

jest.mockをつかう

import { double } from '../src/double';

jest.mock('../src/double').mockimplementation(() => {
  const originalModule = jest.requireActual('../src/double');
  return {
    double: jest.fn().mockImplementation(originalModule.double),
  }
});

詳細実装は割愛するが、基本export defaultのときと同じである。
違いは、 __esModule: true, default がなくなっていることである。

jest.spyOnをつかう

require を使用するが、export defaultのときと同じになる。

import doubleSquare from '../src/doubleSquare';

describe('spy export', () => {
  let spy: jest.SpyInstance;

  beforeAll(() => {
    const org = require('../src/double');
    spy = jest.spyOn(org, 'double');
  });

  afterEach(() => {
    spy.mockClear();
  });

  it('呼び出されるのは1回', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.calls.length, 1);
  });

  it('引数は、3である', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.calls[0], 3);
  });

  it('処理は正常終了し、例外をなげない', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.results[0].type, 'return');
  });

  it('return値は3である', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.results[0].value, 3);
  });

  it('doubleの戻り値は、36になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 36);
  });
});

aliasを使用する場合

import * as myModule from '../src/double';
const spy = jest.spyOn(myModule, 'double')

stub

Jestのstubの機能についてみていく。stubの役割は出力情報を書き換えることにある。 たとえば、DBアクセス、HTTP通信などの副作用があったり、処理に時間かかる部分を実行させるのではなく、任意の値に変更する。 これにより、依存先の処理が成功・失敗したとき、どのように振る舞うかを簡単にテストすることができる。 そしてspyのように入出力情報に関心はない。

依存先がexport defaultを使っている場合

export default function double(x: number): number {
  return 2 * x;
}

jest.mockをつかう

import doubleSquare from '../src/doubleSquare';
import double from '../src/double';
jest.mock('../src/double');

describe('stub export default function with jest.mock', () => {
  beforeAll(() => {
    // doubleの返り値を、2に変更する
    (double as jest.Mock).mockReturnValue(2);
  })

  afterAll(() => {
    (double as jest.Mock).mockClear();
  })

  it('返り値は、4になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
})

spyのときとほとんど変わらないが、一点 mockReturnValue() が異なる。 このメソッドを使うことで、出力情報を変更している。

出力情報を変更するメソッドは他にも、 mockResolveValue(), mockRejectValue() などがある。 詳細なAPIについて、公式サイト参照。

jest.spyOnをつかう

import doubleSquare from '../src/doubleSquare';

describe('stub export default function with jest.spyOn', () => {
  let spy: jest.SpyInstance;
  beforeAll(() => {
    const org = require('../src/double');
    // spyOn()で返却されるインスタンスにmockReturnValueを使ってスタブにする
    spy = jest.spyOn(org, 'default').mockReturnValue(1);
  });

  afterAll(() => {
    spy.mockRestore();
  });

  it('戻り値は4になる。', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
});

spyOnでも同じである。jest.SpyInstanceは、mockと同じAPIをもつので、 mockReturnValue() を使って出力情報を変更する。

依存先がexportを使っている場合

export function double(x: number): number {
  return 2 * x;
}

jest.mockをつかう

import doubleSquare from '../src/doubleSquare';
import { double } from '../src/double';
jest.mock('../src/double');

describe('stub export function with jest.mock', () => {
  beforeAll(() => {
    (double as jest.Mock).mockReturnValue(2);
  })

  afterAll(() => {
    (double as jest.Mock).mockClear();
  })

  it('返り値は、4になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
});

export defaultと全く同じでよい。

jest.spyOnをつかう

import doubleSquare from '../src/doubleSquare';

describe('stub export function with jest.spyOn', () => {
  let spy: jest.SpyInstance;
  beforeAll(() => {
    const org = require('../src/double');
    spy = jest.spyOn(org, 'double').mockReturnValue(1);
  });

  afterAll(() => {
    spy.mockRestore();
  });

  it('戻り値は4になる。', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
});

こちらもほとんどexport defaultのときと同じである。 spyOnの第二引数が default から double へと変わる。

spyとstubの両方の機能がつかいたい

出力情報を変更して、入力情報もテストしたいというユースケースもあるだろう。 Jestはspy, stubを明確に区別しないので、今まで紹介してきたやり方で簡単に実現できる。

spyOn, mock どちらを使ってもよい。 というのも、どちらを使っても .mock で入力情報にアクセスでき、また .mockReturnValue() などの出力情報変更メソッドが使えるからである。 ここでは、依存先がexport defaultを使用しているケースでみていく。

import doubleSquare from '../src/doubleSquare';
import double from '../src/double';
jest.mock('../src/double');

describe('stub export function with jest.mock', () => {
  beforeAll(() => {
    // 出力情報の変更
    (double as jest.Mock).mockReturnValue(1);
  })

  afterEach(() => {
    (double as jest.Mock).mockClear();
  });

  it('doubleが呼び出されるのは1回', () => {
    const actual = doubleSquare(3);
    assert.equal((double as jest.Mock).mock.calls.length, 1);
  });

  it('doubleの引数は3である', () => {
    const actual = doubleSquare(3);
    assert.equal((double as jest.Mock).mock.calls[0], 3);
  });

  it('double処理は正常終了し、例外をなげない', () => {
    const actual = doubleSquare(3);
    assert.equal((double as jest.Mock).mock.results[0].type, 'return');
  });

  it('doubleのreturn値は3である', () => {
    const actual = doubleSquare(3);
    assert.equal((double as jest.Mock).mock.results[0].value, 3);
  });

  it('返り値は、4になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
});
import doubleSquare from '../src/doubleSquare';

describe('stub export function with jest.spyOn', () => {
  let spy: jest.SpyInstance;
  beforeAll(() => {
    const org = require('../src/double');
    // spyと同時に出力情報の変更
    spy = jest.spyOn(org, 'double').mockReturnValue(1);
  });

  afterAll(() => {
    spy.mockRestore();
  });

  // spyの確認
  it('呼び出されるのは1回', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.calls.length, 1);
  });

  it('引数は、3である', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.calls[0], 3);
  });

  it('処理は正常終了し、例外をなげない', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.results[0].type, 'return');
  });

  it('return値は3である', () => {
    const actual = doubleSquare(3);
    assert.equal(spy.mock.results[0].value, 3);
  });

  // stubの確認
  it('戻り値は4になる。', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
});

spyとかstubを意識しなくてよいので、非常に簡単である。 これがJestモックライブラリの良さである。

mockした関数をもとにもどしたい

mockしたものをmockする前の状態に戻せる機能として、 resetMock() があるが、これは spyOn を適用した場合にのみ使用できる。 jest.mock を使った場合はもとに戻せない。ということで、実際にみていこう。

import doubleSquare from '../src/doubleSquare';

describe('stub export default with jest.spyOn', () => {
  let spy: jest.SpyInstance;
  beforeEach(() => {
    const org = require('../src/double');
    spy = jest.spyOn(org, 'default').mockReturnValue(1);
  });

  afterEach(() => {
    // テスト終了後にもとの関数にもどしている
    spy.mockRestore();
  });

  it('戻り値は、6になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 4);
  });
});

describe('mockした関数が前の状態に戻っているかどうか確認する', () => {
  let spy: jest.SpyInstance;
  beforeAll(() => {
    const org = require('../src/double');
    spy = jest.spyOn(org, 'default');
  });

  afterEach(() => {
    spy.mockClear();
  });

  it('doubleの戻り値は、36になる', () => {
    const actual = doubleSquare(3);
    assert.equal(actual, 36);
  });
});

jest.mock を利用した場合、そのテストファイル内ではもとに戻すことはもうできない。
一方、jest.spyOn はそれが可能である。
各々テストを独立させ、影響及ぼさないようにしたいならば、 spyOn のほうが便利だろう。

さいごに

以上、Jestのモックライブラリを使って、spy, stubを使用する方法を紹介しました。

依存先が export されているのか export default されているのかによって、微妙に書き方を変えないいけないので それぞれ紹介しました。

JestはxUnitのモック用語に準拠しないためspyだけしたいと考えると、少しばかり戸惑うことがあります。
しかし、特に区別せず大きくモックとして整理されていることを理解すると、非常に簡単に扱えるのが特徴です。
細かくモックを制御していきたいのであればsinonのほうがおすすめだが、そうでなければJestで十分と思える。

さいごにユースケースごとにどれを利用したほうがいいのか整理していきたいと思います。

spy

spy spyOn mock
export default
export

spyOn を利用したほうが個人的には間違いないと思う。 mock の場合、前準備のコード量が多くなること、 jest.Mock へのキャストを何度も要求されてしまうのに比べると、 spyOn のほうが楽に書くことができるからとなる。

stub

stub spyOn mock
export default
export

どちらも変わりないが、コード量を少なくしたいならば、 mock のほうが使いやすい。 ただ、 jest.mock 呼び出すだけでよく、入力情報を見る必要がないので、キャストする必要ないからだ。 入力情報を見る必要がないサードパーティライブラリはこちらを利用したほうがよいだろうと思う。

spy & stub

spy x stub spyOn mock
export default
export

こちらもほとんど差がないが、spyのときの評価に加えて spyOn を使ったほうがリストアできるという点は非常に魅力的である。
前述したが、あるテストが他のテストに影響与えないようにするのが理想的なので、リストアできるというのはこの点で素晴らしい。

サードパーティのライブラリをモックする場合、 mock が便利となり、それ以外は、 spyOn 使いこなせれば大抵困らないと思う。

参考書籍・サイト

209
139
2

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
209
139

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?