はじめに
JavaScriptでの、テストダブルを使ったユニットテストの書き方について書く。テストランナーにはJsTestDriver, モックライブラリにはSinon.JSを使う。
ベースとなるコードには、
Sinon.JS > Getting startedから、Spies, Stubs, Testing Ajax, Fake XMLHttpRequest, Fake Serverの5つを使う。これらのコードはそのままでは実行できない (テストランナーにJasmineやMochaを使う場合のテストメソッドが切り出されている) ので、JsTestDriverで実行できるように書き換えて、サンプルコードとする。
書き換えの際、
Sinon.JS > Documentationや『テスト駆動JavaScript』を参考に、次のTIPSを導入する。
- JsTestDriverとSinon.JSのアサーションの統合
- サンドボックスの導入
環境は次の通り。
「JsTestDriverでUnit Test + Code Coverage」とほぼ同じ。
- OS: Ubuntu 12.10 (Xubuntu)
- エディタ: gedit
- テストランナー: JsTestDriver 1.3.5
- モックライブラリ: Sinon.JS 1.6.0
- テストブラウザ: Firefox 20.0
Spies
最初にスパイの書き方。テストコードの書き換えは素直なので、ここでアサーションの統合についても書く。
テスト対象関数は次の通り。この関数は、引数に渡された関数を一度だけ実行し、結果をキャッシュする。
function once(fn) {
var returnValue, called = false;
return function () {
if (!called) {
called = true;
returnValue = fn.apply(this, arguments);
}
return returnValue;
};
}
これに対するJsTestDriverのテストコードは次の通り。テストケースの書き換え自体には、特に注意するところはないと思う。
注意すべきは、アサーションの書き方。3つのテストケースで、書き方を変えている。1つ目と2つ目のテストメソッドの書き方は、統合不要。3つ目のテストメソッドの書き方には、統合が必要。それぞれの特徴はコメントを参照。
// JsTestDriverとSinon.JSのアサーションの統合
// 普通は全テストケースで共有するために、グローバルヘルパーで実行する
sinon.assert.expose(this);
TestCase('SpyExample', {
'test calls the original function':function() {
var callback = sinon.spy();
var proxy = once(callback);
proxy();
// JsTestDriverのアサーションを使う場合。メッセージが不親切
assertTrue(callback.called);
},
'test calls the original function only once':function() {
var callback = sinon.spy();
var proxy = once(callback);
proxy();
proxy();
// Sinon.JSのアサーションを完全修飾して使う場合。メッセージがフレンドリィ
sinon.assert.calledOnce(callback);
},
'test calls original function with right this and args':function() {
var callback = sinon.spy();
var proxy = once(callback);
var obj = {};
proxy.call(obj, 1, 2, 3);
// Sinon.JSのアサーションを統合して使う場合。
assertCalledOn(callback, obj);
assertCalledWith(callback, 1, 2, 3);
}
});
なお、統合は、
sinon.assert.expose
で行っている。メソッド名などオプション引数でカスタマイズできるので、詳しくはリンク先を参照のこと(JsTestDriver以外のテストランナーと統合する場合にも要参照。アサーションを持つオブジェクトによって引数に渡すべき値を変えたり、統合先のテストランナーの失敗の扱い方によってsinon.assert.failのオーバーライドしたりする必要がある)。
Stubs
次に単純なスタブの書き方。テスト対象関数はSpiesと同じ。
テストコードは次の通り。これも特に注意するところはないと思う。
TestCase('StubExample', {
'test returns the return value from the original function':function() {
var callback = sinon.stub().returns(42);
var proxy = once(callback);
assertEquals(42, proxy());
}
});
Testing Ajax
Ajaxスタブの書き方。ここではサンドボックスを導入する。
テスト対象関数は次の通り。Ajaxを簡単に取り扱うために、jQueryを使っている。
function getTodos(listId, callback) {
$.ajax({
url: "/todo/" + listId + "/items",
success: function (data) {
// Node-style CPS: callback(err, data)
callback(null, data);
}
});
}
これに対するテストコードは次の通り。ポイントは2行目。スタブ化したグローバルオブジェクト$を復元しないと、他のコードに影響するかもしれない。そこで、
sinon.test
でテストメソッドをラップしてサンドボックス化している。
TestCase('AjaxExample', {
'test makes a GET request for todo items':sinon.test(function(stub) {
this.stub($, 'ajax');
getTodos(42, sinon.spy());
assertTrue($.ajax.calledWithMatch({url: '/todo/42/items'}));
})
});
スタブへのアクセスは、
this.stub
で行う。『テスト駆動JavaScript』ではthisがないけれど、それだと動作しなかった。バージョンアップで変更になったのだと思う。
サンドボックス化すべきテストメソッドが複数ある場合は、代わりに
sinon.testCase
でテストクラスをラップできる。次の次のFake Serverで使ってみる。
Fake XMLHttpRequest
続いて、XMLHttpRequestのスタブ。あえてサンドボックスを使わないで書くと、復元が面倒になるという例に使う。テスト対象関数は、Testing Ajaxと同じ。
テストコードは次の通り。setUp, tearDown内でテストダブルを自前で管理しなければならない。
TestCase('FakeXMLHttpRequestExample', {
setUp: function() {
this.xhr = sinon.useFakeXMLHttpRequest();
var requests = this.requests = [];
this.xhr.onCreate = function(req) {
requests.push(req);
};
},
tearDown: function() {
// Like before we must clean up when tampering with globals
this.xhr.restore();
},
'test makes a GET request for todo items': function() {
getTodos(42, sinon.spy());
assertEquals(1, this.requests.length);
assertEquals('/todo/42/items', this.requests[0].url);
}
});
Fake server
最後に、サーバのスタブ。テストケースのサンドボックス化を導入する。なお、これもテスト対象関数は、Testing Ajaxと同じ。
テストコードは次の通り。テストケースをサンドボックス化する場合はこうなるはず(ドキュメントに沿えばこうなると解釈したコードで、テストのパスは確認したがテストダブルの復元までは未確認)。
TestCase('FakeServerExample', sinon.testCase({
'test calls callback with deserialized data': function(server) {
this.server = sinon.fakeServer.create();
var callback = sinon.spy();
getTodos(42, callback);
// This is part of the FakeXMLHttpRequest API
this.server.requests[0].respond(
200,
{'Content-Type': 'application/json'},
JSON.stringify([{id: 1, text: 'Provide example', done: true}])
);
assert(callback.calledOnce);
}
}));
References