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);
assert(spy.getCall(0).args[0] === 5);
assert(spy.args[0][0] === 5);
assert(spy.returnValues[1] === 8);
assert(spy.withArgs(3).callCount === 1);
});
});
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);
});
it('method2', () => {
const spy = sinon.spy(myObj, 'incCount');
myObj.operateDate(-1);
assert(spy.callCount === 0);
});
});
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);
assert(myObj.count === 2);
stub.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);
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);
assert(myObj.count === 3);
assert(myObj.isPositive(-1) === false);
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);
});
});
参考資料