クックブック for 単体テスト stub/spy/mock編


はじめに

このドキュメントは、単体テストを書くときに「なんでspyだと戻り値をmockできないんだよ!?」とか「あれこのライブラリのメソッドどうやってmockすればいいの?」とか「spyとmockとstubの使い分けが全然わからん・・・」とかいう悩みを幾度となく繰り返した末にその場しのぎのテストコードを書き続けた結果、にっちもさっちも行かなくなりテストが書きづらくなってしまったコードをメンテするツラミにぶちあったっている自分のために、「次はここをみてちゃんとテスト書くんだ・・・!」という決意をするためのドキュメントです。

要はカンペ

あ、ちなみに一応調べながら書いていますが、深くはほっていないので勘違いがあるかも・・・気づいたところがあれば編集リクエストくれると嬉しいです。

対象環境

モックライブラリにjest/sinon
テストランナーはjest
NodeJsで開発したアプリケーションに対する単体テストを想定しています。

常に書き足していくことを想定しているため、バージョンはその時のstable最新を想定しています。
2019/05/17現在はjest 24.8 Sinon.Jsは7.3.2で動かしてます。

sinon

spy

spyしたメソッドが何回実行されて、戻り値は何でなんの引数が渡されたかなど、様々情報を記憶する。
spyしたメソッドは基本的にそのまま実行される
また自分が調べた限りspyしたオブジェクトのメソッドの戻り値をモックする方法が見つからなかった。

stub

Q.stubってどういうときに使うの?

A. テストでメソッドの挙動をコントロールしたいときに使うよ。
例えばエラーハンドリングとかテストしたいときとか
XMLHttpRequestとかreadFileとかテスト時に実行してほしくないメソッドを実行させないために使うときもあるね。

つまりテスト内部のプログラムの挙動をコントロールしたいときに使うのが良さそう。
sinon.assert.calledWith(stbObj, arg1, arg2,..)とかを利用すると渡された引数のテストとかもできる。

mock

また今度

jest

また今度

ユースケース

fs系のライブラリのメソッドをモックしたい

readFileSyncとかそういうやつ。まぁありがち

そんな簡単やろ!readFileSyncをモックするだけやんけ!
でも念の為に(?)console.logでモックした中身見たろ!

index.js

const fs = require('fs');
const main = () => {
    const res = fs.readFileSync('index.js');
    console.log(res)
    return res;
}

module.exports.main = main;
index.spec.js

const sinon = require('sinon'),
  fs = require('fs'),
  assert = require('assert'),
  index = require('../index');
describe('test index.js', () => {
  let stubFs;
  beforeEach(() => {
    stubFs = sinon.stub(fs, 'readFileSync').callsFake(() => '')
  });
  it('execute console.log', () => {
    assert.equal(index.main(), '');
  })
});

テスト結果

 FAIL  test/index.spec.js
  test index.js
    ✕ execute console.log (8ms)

  ● test index.js › execute console.log

    TypeError: Cannot read property 'charCodeAt' of undefined

      at _callsites (node_modules/@jest/source-map/build/getCallsite.js:19:39)
      at _default (node_modules/@jest/source-map/build/getCallsite.js:85:21)
      at Function.write (node_modules/@jest/console/build/BufferedConsole.js:104:51)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        2.055s
Ran all test suites matching /test\/index.spec.js/i.

上記のスタブの仕方だと、すべてのreadFileSyncの戻り値をmocksにしてしまっている。
結構ありがちでconsole.logだけでなくconfigライブラリを使用している場合とかもreadFileSyncをstubすると影響がでるっぽい。

stub.withArgs(arg1[, arg2, ...]);

Stubs the method only for the provided arguments.

与えられた引数だけメソッドをスタブするって書いてある?

というとこれで引数を指定すると、特定の引数を与えられた関数だけスタブするとかそんな素晴らしいことができる。 -> できなかった。
指定した引数以外だと値が帰ってこない。(ドキュメントにもそう書いてある)

stub.callThrough();

Causes the original method wrapped into the stub to be called when none of the conditional stubs are matched.

大変それっぽい。これを使用してテストを修正してみた。

index.spec.js
const sinon = require('sinon'),
  fs = require('fs'),
  assert = require('assert'),
  index = require('../index');
describe('test index.js', () => {
  let stubFs;
  beforeEach(() => {
    stubFs = sinon.stub(fs, 'readFileSync').withArgs('index.js').callsFake(() => '')
   fs.readFileSync.callThrough();
  });
  it('execute console.log', () => {
    assert.equal(index.main(), '');
  })
});

これでindex.js以外の引数がreadFileSyncに渡されたときは通常のメソッドが呼ばれるようになった。やったね。

classのメソッドをスタブしたい

sinon.stub(Class.prototype, 'method')

で行けるぞ