はじめに
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つの関数を用意する。
function double(x: number): number {
return 2 * x;
}
double
は受け取った引数を2倍にして返す関数である。
function doubleSquare(x: number): number {
const d = double(x);
return d * d;
}
doubleSquare
は、引数を2倍にして自乗する関数である。 2倍にするときに、 double
関数を利用している。
これら関数の関係をシーケンス図にすると次のような関係になっている。
上図のとおり、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
を使って、他からインポート可能にする。
export default function double(x: number): number {
return 2 * x;
}
doubleSquareでは、doubleをimport
する。
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
を利用するので、もとのコードを以下のように変更する。
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
使いこなせれば大抵困らないと思う。