Sinon.jsというライブラリを利用することで、テストダブルを用いたテストを書くことが出来る。
テストを書く際、そのテストが依存している特定の処理を書き換えることで、テストが書きやすくなるケースがある。
APIを叩いたり、データベースと接続したりする処理などが、それにあたる。
これらは環境を用意するのが面倒であること多く、こういった処理があるとテストが書きにくい。
そこで、そういった面倒な処理を別のオブジェクトで置き換えてしまう。こうすることでテストが書きやすくなる。
この、面倒な処理を置き換えるためのオブジェクトが、テストダブルである。
テストダブルにはいくつか種類があり、代表的なのがspy,stub,mockの3つ。
以下、それぞれの使い方を見ていく。テストフレームワークにはmochaを使っている。
spy
いきなりだが、spyは、上記の「テストダブル」の定義からは少し外れている気がする。
既存の処理を置き換えてしまうのではなくて、スパイの名の通り、既存の処理に「仕込む」といったほうが正しいように思う。
spyを既存の関数に仕込んでおくことで、その関数が呼ばれた際の引数や戻り値、呼ばれた回数などを記録し、検証することが出来る。
まず以下のような形で、テストしたい対象にスパイを仕込む。
const spy = sinon.spy(対象のオブジェクト, '対象のメソッドの名前');
後は、spyオブジェクトが持っている様々なメソッドを使って、テストが出来る。
例えば、spy.callCount
ではテスト対象が呼ばれた回数を、spy.withArgs(arg).callCount
ではテスト対象が特定の引数で呼ばれた回数を、それぞれ調べることが出来る。
他にも様々なメソッドがある。
以下はそのサンプル。
myObj.incCount
はmyObj.operateData
に渡された引数が0を越えているときにの呼ばれるが、その挙動をテストしている。
import sinon from 'sinon'; import assert from 'assert'; const myObj = { count: 0, operateDate(arg) { if (myObj.isPositive(arg)) myObj.incCount(arg); }, incCount(arg) { myObj.count += arg; return myObj.count; }, isPositive(arg) { return arg > 0; }, }; describe('spy', () => { beforeEach(() => { myObj.count = 0; }); it('method', () => { const spy = sinon.spy(myObj, 'incCount'); myObj.operateDate(5); myObj.operateDate(-1); myObj.operateDate(3); myObj.operateDate(0); assert(spy.callCount === 2); // incCountが呼ばれた回数 assert(spy.getCall(0).args[0] === 5); // 最初にincCountが呼ばれた際の引数 assert(spy.args[0][0] === 5); // 同じく、最初にincCountが呼ばれた際の引数 assert(spy.returnValues[1] === 8); // 2回目にincCountが呼ばれた際の戻り値 assert(spy.withArgs(3).callCount === 1); // incCountが引数3で呼ばれた回数 }); });
restore()
以下のテストは、アサーションは正しいが、パスしない。
TypeError: Attempted to wrap incCount which is already wrapped
というエラーが出る。
import sinon from 'sinon'; import assert from 'assert'; const myObj = { count: 0, operateDate(arg) { if (myObj.isPositive(arg)) myObj.incCount(arg); }, incCount(arg) { myObj.count += arg; return myObj.count; }, isPositive(arg) { return arg > 0; }, }; describe('spy', () => { beforeEach(() => { myObj.count = 0; }); it('method', () => { const spy = sinon.spy(myObj, 'incCount'); myObj.operateDate(1); assert(spy.callCount === 1); // incCountが呼ばれた回数 }); it('method2', () => { const spy = sinon.spy(myObj, 'incCount'); myObj.operateDate(-1); assert(spy.callCount === 0); // incCountが呼ばれた回数 }); });
incCount
に対して二重にスパイを仕込む形になってしまうから、エラーになったのだろう。
そのため、テストが終わる度に、スパイの解除が必須になる。
具体的にはspy.restore()
とすると、メソッドに対するスパイを解除することが出来る。
上記の例では、1回目のit
の最後にspy.restore()
とすることで、テストがパスするようになる。
これは、スタブやモックなど、他のテストダブルでも同様。
stub
スタブはスパイと違い、対象のメソッドを完全に置き換える。ラップする、といったほうが正確のようだが。
スパイとは異なり、元の挙動を完全に上書きする。
スタブによってラップされたメソッドは、従来の処理を全く行わない。
戻り値はundefined
となる。
import sinon from 'sinon'; import assert from 'assert'; const myObj = { count: 0, operateDate(arg) { if (myObj.isPositive(arg)) myObj.incCount(arg); }, incCount(arg) { myObj.count += arg; return myObj.count; }, isPositive(arg) { return arg > 0; }, }; describe('stub', () => { beforeEach(() => { myObj.count = 0; }); it('wrap', () => { assert(myObj.incCount(2) === 2); assert(myObj.count === 2); const stub = sinon.stub(myObj, 'incCount'); assert(myObj.incCount(3) === undefined); // stubの戻り値はundefinedになる assert(myObj.count === 2); // myObj.countは2のまま stub.restore(); // restore()によって、ラップされた状態は終わる assert(myObj.incCount(3) === 5); assert(myObj.count === 5); }); });
stub.returns()
で戻り値を指定することも出来る。
また、stub.withArgs(hoge).returns(true)
とすると、引数がhoge
の場合はtrue
を返す。
渡された引数についての指定がある場合はそれを、無い場合はstub.returns()
で指定されたものを、その指定がない場合はundefined
を、返す。
describe('stub', () => { beforeEach(() => { myObj.count = 0; }); it('returns', () => { const stub = sinon.stub(myObj, 'incCount'); stub.returns('hoge'); assert(myObj.incCount(0) === 'hoge'); stub.withArgs(0).returns('zero'); stub.withArgs(9).returns('nine'); assert(myObj.incCount(0) === 'zero'); assert(myObj.incCount(1) === 'hoge'); assert(myObj.incCount(9) === 'nine'); stub.restore(); }); });
コールバックを指定することも可能。
stub.callsArg(Number)
とすることで、Number
番目の引数を、コールバックとして実行するようになる。
また、stub.callsArg(Number, Arg1, Arg2)
とすると、Arg1
以降を、コールバック関数に引数として渡す。これは複数設定することが出来る。
describe('stub', () => { beforeEach(() => { myObj.count = 0; }); it('callback', () => { const countIsNine = () => { myObj.count = 9; }; const changeCount = (arg) => { myObj.count = arg; return false; }; const stub = sinon.stub(myObj, 'incCount'); stub.callsArg(0); myObj.incCount(countIsNine); assert(myObj.count === 9); stub.callsArgWith(1, 'hoge'); myObj.incCount(countIsNine, changeCount); // changeCount('hoge')を実行する assert(myObj.count === 'hoge'); stub.restore(); }); });
stub.throws()
とすれば、スタブを実行した際に例外が投げられる。
特定の引数が渡された際にのみ例外を投げたければ、stub.withArgs(引数).throws();
とすればいい。
describe('stub', () => { beforeEach(() => { myObj.count = 0; }); it('throws', () => { const stub = sinon.stub(myObj, 'incCount'); stub.withArgs(false).throws('error message'); try { myObj.incCount(); } catch (e) { myObj.count = e.name; } assert(myObj.count === 0); try { myObj.incCount(false); } catch (e) { myObj.count = e.name; } assert(myObj.count === 'error message'); stub.restore(); }); });
mock
モックは、メソッドがどのように呼び出されるかなどを調べるために、使われる。
オブジェクトをラップすることで使えるようになる。
その後、調べたいメソッドについてexpects
を設定する。
obj.method
が1度呼ばれることを期待する場合は、以下のように書く。
const mock = sinon.mock(obj); mock.expects('method').once();
このようにすると、obj.method
の呼び出しをチェックするようになる。
また、スタブと同じようにラップされ、本来の挙動は行われない。
method
以外のobj
のメソッドは、通常通りに動く。
expects
を設定したメソッドのみがラップされる。
mock.verify()
を実行すると、メソッドが期待された動きをしたかチェックし、期待から外れていた場合は例外を投げる。
また、verify()
を実行しなくても、期待から外れた時点で、verify()
の実行を待つこと無く、例外を投げる。
先程のmock.expects('method').once();
の例だと、obj.method
が2回呼ばれた時点で、例外を投げる。
withArgs()
を使うと、メソッドを呼び出す際の引数を指定できる。
指定した以外の引数でメソッドを呼び出すと、その時点で例外を投げる。
import sinon from 'sinon'; import assert from 'assert'; const myObj = { count: 0, operateDate(arg) { if (myObj.isPositive(arg)) myObj.incCount(arg); }, incCount(arg) { myObj.count += arg; return myObj.count; }, isPositive(arg) { return arg > 0; }, }; describe('mock', () => { let mock; beforeEach(() => { myObj.count = 0; }); afterEach(() => { mock.restore(); }); it('basic', () => { mock = sinon.mock(myObj); assert(myObj.incCount(3) === 3); assert(myObj.count === 3); mock.expects('incCount').once(); assert(myObj.incCount(2) === undefined); // incCountはwrapされる assert(myObj.count === 3); assert(myObj.isPositive(-1) === false); // isPositiveはwrapされていない mock.verify(); }); it('verify1', () => { mock = sinon.mock(myObj); mock.expects('incCount').once(); mock.verify(); // ここで例外を投げる console.log('このコードは実行されない'); }); it('verify2', () => { mock = sinon.mock(myObj); mock.expects('incCount').once(); myObj.incCount(1); myObj.incCount(1); // ここで例外を投げる console.log('このコード以降は実行されない'); mock.verify(); }); it('arg1', () => { mock = sinon.mock(myObj); mock.expects('incCount').withArgs(1).exactly(2); myObj.incCount(1); myObj.incCount(1); mock.verify(); // このテストはパスする }); it('arg2', () => { mock = sinon.mock(myObj); mock.expects('incCount').withArgs(1).exactly(2); myObj.incCount(1); mock.verify(); // ここで例外を投げる }); it('arg3', () => { mock = sinon.mock(myObj); mock.expects('incCount').withArgs(1).exactly(2); myObj.incCount(0); // ここで例外を投げる }); });