本文是 Best Practices for Spies, Stubs and Mocks in Sinon.js
的筆記。
在開始讀之前請注意
不同的語言或測試套件可能對 Spies, Stubs 和 Mocks 有不同的解釋
你應該查閱測試套件對它們的解釋和使用時機
下面為本文討論的例子
function setupNewUser(info, callback) {
var user = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
try {
Database.save(user, callback);
}
catch(err) {
callback(err);
}
}
它們被稱為 測試替身
test doubles
為了了解何時使用它們,我們先將 function 分成兩類:
無邊際效應的函數
是指函數結果只和參數有關,同樣的參數會產生一樣的結果
有邊際效應的函數
是指除了本身的參數外還與外界有關的函數,像是其它物件的狀態、當前時間、資料庫的回傳。
因此,toLowerCase() 是無邊際效應的函數
,它只和字串值有關。然而 Database.save() 是有邊際效應的函數
,因為它結果受到外界影響,可能每次的結果都不一樣。
若要測試 setupNewUser
,我們替 Database.save() 換成測試替身,因為不能讓他產生邊際效應。也就是說
當測試目標有邊際效應時,會使用測試替身,讓我們的當測試目標變成無邊際效應
除了針對有邊際效應函數
使用測試替身,常常 CPU-sensitivy 函數也可以使用測試替身,因為它們會托慢我們的測試。
Syies 用來取得函數的呼叫資訊,像是函數的:
因此,它們被用來驗証與外介的互動。用Sinon的 assertions ,可以做下列的事:
檢查函數被呼叫幾次 可以用 sinon.assert.callCount
, sinon.assert.calledOnce
, sinon.assert.notCalled
it('should call save once', function() {
var save = sinon.spy(Database, 'save');
setupNewUser({ name: 'test' }, function() { });
save.restore();
sinon.assert.calledOnce(save);
});
檢查被呼叫時傳入的參數 可以用 sinon.assert.calledWith
或用 spy.lastCall
或 spy.getCall()
取得參數。
it('should pass object with correct values to save', function() {
var save = sinon.spy(Database, 'save');
var info = { name: 'test' };
var expectedUser = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
setupNewUser(info, function() { });
save.restore();
sinon.assert.calledWith(save, expectedUser);
});
除了上例外 Sinon的 assertions 還有其它可以使用,像是 sinon.assert.callOrder
確認呼叫順序。
最後有件事很重要
Spyies "不會" 取代原來的函數,原來的函數行為一樣沒變。若你要改變行為需改用 stub
Stubs 就像 Syies 可以進行各種驗証,不僅如此,它們被用來取代目標函數,像是:
常常被用來:
取代有問題的程試片斷 有問題的程式片斷使測試變難。常是因為與外介有關,像是網路連線,資料庫或非 Javascript system。這些問題常需要人工處理。像是在運行測試前要為資料庫填入資料,這些使得測試變複雜。
在之前的例子中,Database.save
會和資料庫有關,若不在測試前設定好它們,可能會產生問題,因此我們 stub(這是動詞) 來防止問題產生。 因為我們要取代 Database.save
原來的行為,所以用的是 stub,不是 spy
it('should pass object with correct values to save', function() {
var save = sinon.stub(Database, 'save');
var info = { name: 'test' };
var expectedUser = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
setupNewUser(info, function() { });
save.restore();
sinon.assert.calledWith(save, expectedUser);
});
Database.save
換掉原來的行為,所以不再與外介有相關。最後可以像 spy 一樣斷言是否有傳入參數。
用來觸發程式路徑 當我們的測試碼中呼叫別的函數時,有時可能會產生不同的執行路徑,像是 error 發生時。我們可以利用 stub 來觸發不同的執行路徑:
it('should pass the error into the callback if save fails', function() {
var expectedError = new Error('oops');
var save = sinon.stub(Database, 'save');
save.throws(expectedError);
var callback = sinon.spy();
setupNewUser({ name: 'foo' }, callback);
save.restore();
sinon.assert.calledWith(callback, expectedError);
});
使非同步程式更容易測試 若我們 stub 一個非同步函數,可以要求它立即呼叫 callback,使的測試變成同步和不用做非同步的測試處理。
it('should pass the database result into the callback', function() {
var expectedResult = { success: true };
var save = sinon.stub(Database, 'save');
save.yields(null, expectedResult);
var callback = sinon.spy();
setupNewUser({ name: 'foo' }, callback);
save.restore();
sinon.assert.calledWith(callback, null, expectedResult);
});
save.yields
設定傳入 callback 的參數,當Database.save
呼叫時,它的第一個 callback 立即會被叫起,並送入 save.yields
所設定的參數。
當使用 mocks 時要特別小心,因為 mocks 可以做任何 spies 和 stubs 的所有事,所以可能會忽略 spies 和 stubs。 然而, mocks 容易使你的測試變的特化,這會使測試易碎。一個易碎的測試是指當程式改變時,測試容易壞掉。
Mock 的主要的使用時機 當你使用 stub 時,你需要瞼証很多特別的行為時
.
下面的例子是使用 mock 驗証一個資料庫存檔的情境
it('should pass object with correct values to save only once', function() {
var info = { name: 'test' };
var expectedUser = {
name: info.name,
nameLowercase: info.name.toLowerCase()
};
var database = sinon.mock(Database);
database.expects('save').once().withArgs(expectedUser);
setupNewUser(info, function() { });
database.verify();
database.restore();
});
注意到:
verity
執行驗証在上面的例子中 once()
和 withArgs()
用來定義呼叫次數和送入的參數。若使用 stub 要驗証多個條件。
因為 mock 可以方便地同時對許多條件驗証,因此容易過度使用。我們容易寫出驗証的條件比實際上所需的還要多,導致測試更難理解和易碎。這也避免多條件驗証原因之一,當用 mock 時需要留意。
sinon.sandbox
Whenever Possible這裡的原章節是 "Use sinon.test Whenever Possible",我們改用 sinon.sandbox 新的api取代。 雖然換了 api ,但作用一樣沒有改變。
當使用 spies, stubs, mocks 時,你應該盡可能的放到 sandbox 中,因為可以它可以幫我們自動地清除它們,就如同前面的 save.restore()
清除測試替身。若不做清除,會導致 cascading failure
- 一個失敗測試產生更多的失敗測試。 cascading failure
會遮住有問題的真正原碼,我們要避免。
it('should call save once', function() {
var save = sinon.spy(Database, 'save');
setupNewUser({ name: 'test' }, function() { });
save.restore();
sinon.assert.calledOnce(save);
});
若 setupNewUser
在這測試中丟出例外,且 spy 未被清除,因為 Databse 已經換上 spy,所以這 spy 會使其它的測試誤判。
下面使用 sinon.sandbox
:
var sandbox = sinon.sandbox.create();
describe('setupNewUser method', function () {
afterEach(function () {
sandbox.restore();
});
it('should call save once', function () {
var save = sandbox.spy(Database, 'save');
setupNewUser({ name: 'test' }, function() { });
sinon.assert.calledOnce(save);
});
});
sandbox.restore();
幫我們清除測試替身。
下面是測試替身放入 sandbox
sinon.spy
變成 sandbox.spy
sinon.stub
變成 sandbox.stub
sinon.mock
變成 sandbox.mock
這裡章節因為與 Mocha 測試框架、sinon.test 有關,所以我們跳過。
關於覆寫setTimeout
和其它相關的全域函數,請參考 Fake timers - Sinon.JS
若需要在每個測試中重新產生測試替身,你可以將產生它們的碼放在 beforeEach
hook 中。
describe('Something', function() {
var save;
beforeEach(function() {
save = sinon.stub(Database, 'save');
});
afterEach(function() {
save.restore();
});
it('should do something', function() {
//you can use the stub in tests by accessing the variable
save.yields('something');
});
});
你也應該留意 `afterEach` 是否有清除它。不這麼做可能會引起其它測試的錯誤。
若你要驗証呼叫順序,可以用 sinon.assert.callOrder
:
var a = sinon.spy();
var b = sinon.spy();
a();
b();
sinon.assert.callOrder(a, b);
若你要驗証某些值在函數被呼叫前是否被存取,你可以使用callsFake
,來做驗証某些值
原文是用 sinon.stub(object, "method", func),但這被棄用了,所以我們改成用`callsFake`
var object = { };
var expectedValue = 'something';
var func = sinon.stub(example, 'func').callsFake(function() {
assert.equal(object.value, expectedValue);
});
doSomethingWithObject(object);
sinon.assert.calledOnce(func);
我們使用自設的行為取代 example.func()
,然後在裡面放入 object.value
是否有被設定。 這樣做等於在呼叫 stubbed function 前確認 object.value
是否有被設定。
另外,sinon.assert.calledOnce
是用來確保 stubbed function 有被呼叫。若不驗証這項項目,你的測試將不會驗証object.value
的值。
Sinon 是很有用的工具,透過這篇你可以避免常見的使用錯誤。最重要的是記得使用 sandbox
,否則cascading failure
會是你的挫折來源。