sinon.js基礎使用教程—單元測試

發表於2019-03-03

原文地址:www.sitepoint.com/sinon-tutor…

譯文

當我們寫單元測試時一個最大的絆腳石是當你面對的程式碼過於複雜。

在真實的專案中,我們的程式碼經常要做各種導致我們測試很難進行的事情。Ajax請求,timer,日期,跨瀏覽器特性…或者如果你使用Nodejs,則面對資料庫,網路,檔案操作等。

所有這些事情之所以不容易測試是因為你無法輕易用程式碼控制它們。如果你使用Ajax,你需要一個服務端來響應請求,這樣才能讓你的測試項通過。如果你使用setTimeout,你的測試項不得不等待它。如果是資料庫或網路,也類似–你需要一個包含正確資料的資料庫,或一個網路服務。

真實世界不像那些測試教程裡看起來的那樣簡單。但你知道有一個解決方案麼?

By using Sinon, we can make testing non-trivial code trivial! (譯者:這個口號不太好翻譯,non-trivial)

讓我們看看該怎麼做。

是什麼讓Sinon如此重要?

簡單的說,Sinon允許你去替換程式碼中複雜的部分,以此來簡化你的測試程式碼。

當我們測試某部分程式碼時,你不希望受到其它部分的影響。如果有外部因素影響測試,那麼測試項將變得非常複雜且不穩定。

如果你想測試一個使用了ajax的程式碼,你該怎麼做?你需要跑一個服務端,並保證該服務端返回指定的響應資料來支撐你的測試項。這很難完成也讓執行測試很麻煩。

那如果你的程式碼依賴時間呢?假如它需要等待一秒鐘才執行。怎麼辦?你需要在你的測試項中使用setTimeout,但這會讓測試變得緩慢。想像一下,如果間隔時間很久,例如五分鐘。我想你不會希望每次跑測試項都等待五分鐘吧。

如果使用Sinon,我們可以搞定這些問題(甚至更多),並減少複雜度。

Sinon是怎麼工作的?

Sinon通過允許我們簡單的建立test-doubles從而幫助我們減少測試項編寫的複雜度。

正如它名字一樣,Test-doubles作用是在測試中替換某部分程式碼。上面提到的ajax的例子中,不需要建立服務端,我們可以使用test-doubles替換掉Ajax呼叫。在timer例子中,我們可以使用test-doubles來控制時間。

聽起來可能很複雜,但基本思想很簡單。基於javascript的動態性,我們可以替換任何函式。Test-doubles只是在這個思想的基礎上走的更遠了一些。使用Sinon,我們可以使用test-doubles替換任何javascript函式,並提供很多方便測試的配置。

Sinon中test-doubles分三類:

  • Spies,提供了函式呼叫的資訊,但不會改變其行為(譯者注:類似動態代理)
  • Stubs,類似Spies,但是是完全替換目標函式。這可以讓你隨心所欲的控制函式–拋異常,返回指定結果等
  • Mocks,提供了替換整個物件的能力

此外,Sinon還提供了其他的輔助功能,本文不包含下面的範圍:

基於這些功能,Sinon可以讓你解決測試中遇到的由外部依賴帶來的所有複雜問題。如果你學會了Sinon提供的這些技巧,你幾乎不需要其它別的工具了。

安裝Sinon

開始之前,我們需要安裝Sinon

Nodejs

  1. 使用npm install sinon安裝sinon
  2. 在測試項中引入sinon:var sinon = require(`sinon`);

瀏覽器

  1. 你可以選擇npm install sinon,或使用CDN,也可以從官網下載到本地
  2. 在你的測試頁面引入sinon.js

入門指南

sinon包含許多功能,但它們多數都存在關係。你只需要掌握一部分,就會了解剩餘部分。這讓sinon很容易使用,只需要你瞭解了基本用法並知道它們之間的差別。

只要我們的程式碼呼叫了一個不容易控制的函式,我們通常就需要sinon。

對於Ajax,它可能是$.get或者XMLHttpResquest。對於timer,它可能是setTimeout。對於資料庫,它可能是mongodb.findOne

為了方便我們討論,後面我將成這類函式為依賴方。我們測試的目標函式依賴其它函式的返回結果。

最常見的使用sinon方式是使用test-doubles替換掉問題依賴方

  • 當測試Ajax時,我們使用test-doubles替換XMLHttpResquest來偽造ajax請求
  • 當測試timer時,我們偽造替換setTimeout
  • 當測試資料庫時,我們使用test-doubles來替換mongodb.findOne來直接返回偽造資料

讓我們寫點程式碼吧。

Spies

Spies很簡單,但其它很多功能依賴它。

spies的主要用法是收集函式的呼叫資訊。你可以用來驗證一些事兒,例如函式是否被呼叫。

1
2
3
4
5
6
7
複製程式碼
var spy = sinon.spy();

//我們可以像呼叫函式一樣呼叫spy
spy(`Hello`, `World`);

//我們可以得到呼叫資訊
console.log(spy.firstCall.args); //output: [`Hello`, `World`]
複製程式碼

sinon.spy函式返回一個Spy物件,該物件可以像函式一樣被呼叫,它記錄每次被呼叫資訊。在上面的例子裡,firstCall屬性包含了第一次呼叫的資訊,例如firstCall.args表示呼叫時的引數列表。

雖然你可以像上面例子那樣建立一個匿名spies,但通常情況下你需要使用spy替換一個其它函式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
複製程式碼
var user = {
  ...
  setName: function(name){
    this.name = name;
  }
}

//為user.setName建立一個spy
var setNameSpy = sinon.spy(user, `setName`);

//現在,每次呼叫目標函式,spy都會記錄相關資訊
user.setName(`Darth Vader`);

//我們可以使用spy物件檢視相關資訊
console.log(setNameSpy.callCount); //output: 1

//非常重要的步驟--拆除spy
setNameSpy.restore();
複製程式碼

上面例子展示了使用spy替換其它函式的寫法,最重要的一點是:當你確定不再需要spy後,你記得恢復原始函式,參考例子中的最後一行。不然測試可能出現非預期行為。

Spies包含許多不同的屬性,用來提供不同的資訊。spy文件列出了完整的屬性列表。

在實際場景中,你可能不會經常使用spies。你更多時候使用的是stub,但是spies用來檢測函式是否被呼叫非常方便:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
複製程式碼
function myFunction(condition, callback){
  if(condition){
    callback();
  }
}

describe(`myFunction`, function() {
  it(`should call the callback function`, function() {
    var callback = sinon.spy();

    myFunction(true, callback);

    assert(callback.calledOnce);
  });
});
複製程式碼

在這個例子中,我們使用Mocha作為測試框架,使用Chai作為斷言庫。如果你想了解更多資訊,可以參考我之前的文章:使用Mocha和Chai來單元測試你的javascript

See the Pen Sinon Tutorial: JavaScript Testing with Mocks, Spies & Stubs

sinons斷言

在我們介紹stubs之前,我們快速看一下sinon斷言

大多數使用spies(和stubs)的測試方案中,你需要一些工具來校驗測試結論。

我們可以使用任何斷言來驗證結論。前面的例子中,我們使用Chai的assert函式來驗證值的真實性。

1
複製程式碼
assert(callback.calledOnce);
複製程式碼

這樣做的問題是錯誤資訊並不清晰。你將得到“false was not true”,或類似資訊。你可以想象的到,這對於定位錯誤並不是很有價值,你需要在測試程式碼中翻找才能最終找到。一點都不美。

解決這個問題,我們可惡意包含一個自定義的錯誤資訊在斷言中。

1
複製程式碼
assert(callback.calledOnce, `Callback was not called once`);
複製程式碼

但如果我們使用sinon的斷言庫呢?

1
2
3
4
5
6
7
8
9
10
複製程式碼
describe(`myFunction`, function() {
  it(`should call the callback function`, function() {
    var callback = sinon.spy();

    myFunction(true, callback);

    sinon.assert.calledOnce(callback);
  });
});
複製程式碼

使用sinon斷言我們可以得到更多有價值的錯誤資訊。這在當你驗證比較複雜的條件時非常有用,例如函式的引數。

下面列出一些sinon提供的其它強大斷言的一些例子:

  • sinon.assert.calledWith可以用來驗證函式是否使用指定的引數(這可能是我用的最多的一個)
  • sinon.assert.callOrder用來驗證函式的呼叫順序

sinon斷言文件介紹了所有的內容。如果你喜歡使用Chai,有一個sinon-chai-plugin可以讓你通過chai的expectshould介面來使用sinon斷言。

Stubs

stubs歸類於test-doubles是因為它的靈活和方便性。它擁有spies的全部功能,此外它還徹底的替換掉了目標函式。換句話說,當你使用spy,原始的函式依然會被呼叫,但如果使用stub,原始函式就不會被執行了。

這個特性讓stub可以勝任許多工,例如:

  • 替換像ajax或其它外部函式等讓測試變複雜或慢的呼叫
  • 根據函式的響應來觸發不同的程式碼流程
  • 測試不尋常的條件,如丟擲異常

我們可以像建立spies一樣建立stubs:

1
2
3
4
5
6
複製程式碼
var stub = sinon.stub();

stub(`hello`);

console.log(stub.firstCall.args); //output: [`hello`]
複製程式碼

我們建立了一個匿名的stubs,但用stubs來替換存在的函式更有意義。

舉個例子,如果你有一段程式碼呼叫了jquery的Ajax,測試它將變得麻煩。程式碼會傳送請求到我們配置的服務端,所以我們需要保證服務端的有效性,或者給程式碼新增特定的分支來適配測試環境 – 這麼做真的大錯特錯。你不應該在程式碼中編寫任何測試特定邏輯。

我們可以使用sinon的stub來替換ajax呼叫。這會讓測試變得簡單。

下面的例子中,我們使用ajax向預定url傳送一個攜帶引數的請求。

1
2
3
4
5
6
7
複製程式碼
function saveUser(user, callback) {
  $.post(`/users`, {
    first: user.firstname,
    last: user.lastname
  }, callback);
}
複製程式碼

通常,測試這個函式將變的很麻煩,但我們有了stub,一切變得美好。

假如我們想要確保傳遞給saveUser函式的回撥方法在請求結束後正確的被執行了一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
複製程式碼
describe(`saveUser`, function() {
  it(`should call callback after saving`, function() {

    //We`ll stub $.post so a request is not sent
    var post = sinon.stub($, `post`);
    post.yields();

    //We can use a spy as the callback so it`s easy to verify
    var callback = sinon.spy();

    saveUser({ firstname: `Han`, lastname: `Solo` }, callback);

    post.restore();
    sinon.assert.calledOnce(callback);
  });
});
複製程式碼

See the Pen Sinon Tutorial: JavaScript Testing with Mocks, Spies & Stubs

這裡,我們將ajax函式替換成了stub。這意味著請求不會被髮送,我們不需要一個服務端 – 我們全權控制了我們的測試程式碼!

介於我們想確認我們傳給saveUser的回撥會被執行,我們讓stub立刻返回。這意味著stub將自動呼叫callback引數。這模仿了$.post在請求完成後的行為。

除了stub,我們還建立了一個spy。我們可以使用一個普通的函式作為回撥,但使用spy會讓sinon.assert.calledOnce更方便驗證測試結論。

大多數需要stub的場景,都類似下面步驟:

  • 確認是否包含問題函式,例如$.post
  • 觀察並掌握其行為
  • 建立一個stub
  • 讓stub來模擬目標行為

stub不需要模擬所有的行為,只需要足夠你的測試項使用即可,其它細節可以忽略。

另外一些stub的常用場景是驗證一個函式是否使用特定的引數。

舉個例子,在我們的ajax函式中,我們希望確定正確的資料被提交。因此,我們可能會這麼做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
複製程式碼
describe(`saveUser`, function() {
  it(`should send correct parameters to the expected URL`, function() {

    //We`ll stub $.post same as before
    var post = sinon.stub($, `post`);

    //We`ll set up some variables to contain the expected results
    var expectedUrl = `/users`;
    var expectedParams = {
      first: `Expected first name`,
      last: `Expected last name`
    };

    //We can also set up the user we`ll save based on the expected data
    var user = {
      firstname: expectedParams.first,
      lastname: expectedParams.last
    }

    saveUser(user, function(){} );
    post.restore();

    sinon.assert.calledWith(post, expectedUrl, expectedParams);
  });
});
複製程式碼

see the pen Sinon Tutorial: JavaScript Testing with Mocks, Spies & Stubs

這次,我們有建立了一個$.post()的stub,但這回我們並沒有讓它直接返回。這次我們的測試目標不是回撥,因此讓它返回並不是必須的。

我們設定了一些變數來存期望的資料 – url和引數。這是一個好的實踐,讓我們很容易知道什麼是測試必須的。也可以幫助我們減少重複程式碼。

這次我們使用sinon.assert.calledWith()斷言。我們將stub傳遞進去,因為我們想確定stub包含了正確的引數。

使用sinon,還有其它的方法來測試ajax請求。例如使用sinon的偽造XMLHttpResquest功能。我們不會在這裡去介紹細節,如果你想了解更多可以參考my article on Ajax testing with Sinon’s fake XMLHttpRequest

Mocks

Mocks不同於stubs。如果你之前聽過mock object這個術語,那沒錯了 – sinon的mocks用來替換整個物件,並改變其行為。

如果你需要替換某個物件的多個方法,你就應該使用mocks。如果你只是希望替換某個單獨的方法,stub更方便。

使用mocks時你需要小心!因為它太TM強大了,很容易讓你的測試過於特定 – 測試的太細或太刻意 – 從而讓你的測試太容易過期。

與spies和stubs不同,mocks包含內建的斷言。當使用mock物件時,你可以定義你期望的結果,你期望的行為。

假設我們使用store.js來儲存一些資料到localstorage,我們打算測試這個特性。我們可以使用mock來寫測試:

1
2
3
4
5
6
7
8
9
10
11
12
13
複製程式碼
describe(`incrementStoredData`, function() {
  it(`should increment stored value by one`, function() {
    var storeMock = sinon.mock(store);
    storeMock.expects(`get`).withArgs(`data`).returns(0);
    storeMock.expects(`set`).once().withArgs(`data`, 1);

    incrementStoredData();

    storeMock.restore();
    storeMock.verify();
  });
});
複製程式碼

See the Pen Sinon Tutorial: JavaScript Testing with Mocks, Spies & Stubs

使用mocks時,我們可以使用鏈式呼叫風格來定義期望的呼叫和結果。這和使用斷言驗證結果一樣,除了我們需要提前定義,並在測試結束時校驗它們storeMock.verify()

呼叫mock物件的mock.expects(something)會建立一個期望值。意味著mock.something()方法期望被呼叫。Each expectation, in addition to mock-specific functionality, supports the same functions as spies and stubs.(譯者注:只能意會無法言表啊)

你可能會覺得通常stub都比mock更簡單 – 沒錯。Mocks要小心使用。

mock特定的特性,可以檢視sinon的mock文件

重要的最佳實踐:使用sinon.test()

這裡有個使用sion的很重要的最佳實踐,不管是使用spies,stubs還是mocks都應該牢記。

如果你用test-doubles替換了一個存在的函式,則使用sinon.test()

前面的例子中,我們使用stub.restore()mock.restore()來在我們使用完後清理它們。這很有必要,否則test-doubles將持續有效,這將可能影響其他的測試項並導致錯誤。

但是,直接使用restore()可能很難,有可能因為某個異常導致restore()沒有被呼叫!

我們有兩種方法來解決這個問題:我們可以自己包裝完整的try catch塊。這允許我們將restore()放在finally塊中呼叫來確保一切正常。

或者,一個更好的做法是我們可以將測試體寫在sinon.test()中:

1
2
3
4
5
6
7
複製程式碼
it(`should do something with stubs`, sinon.test(function() {
  var stub = this.stub($, `post`);

  doSomething();

  sinon.assert.calledOnce(stub);
});
複製程式碼

上面的程式碼中,注意it()的第二個引數,它被sinon.test()包裹。此外注意我們使用this.stub()代替了sinon.stub()

使用sinon.test()包裹測試體可以讓我們使用sinon沙盒特性,其允許我們使用this.spy()this.stub()this.mock()來建立spies, stubs和mocks。任何你在沙盒中建立的test-doubles都會自動被清理。

我們上面的程式碼中並沒有stub.restore() – 託沙盒的福它已經不再需要了。

請儘可能使用sinon.test(),你會避免由於前面的測試項沒有清理test-doubles而導致的靈異問題。

Sinon並不是黑魔法

Sinon很強大,而且某些時候很難理解它是如何工作的。讓我們看一下Sion工作原理的原生javascript的例子,這樣我們可以更好的理解其思想。

我們可以自己實現spies, stubs和mocks。使用Sinon只是因為它更方便 – 自己實現會非常複雜。

首先,spy本質上是一個函式wrapper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
複製程式碼
//A simple spy helper
function createSpy(targetFunc) {
  var spy = function() {
    spy.args = arguments;
    spy.returnValue = targetFunc.apply(this, arguments);
    return spy.returnValue;
  };

  return spy;
}

//Let`s spy on a simple function:
function sum(a, b) { return a + b; }

var spiedSum = createSpy(sum);

spiedSum(10, 5);

console.log(spiedSum.args); //Output: [10, 5]
console.log(spiedSum.returnValue); //Output: 15
複製程式碼

我們可以很容易的使用自定義函式來實現spy的功能。但注意sinon的spies提供了非常多的特性 – 包括斷言的支援。這讓sinon更方便使用。

關於Stub Then?

實現一個簡單的stub, 你可以簡單的替換成一個新的:

1
2
3
4
5
6
7
複製程式碼
var stub = function() { };

var original = thing.otherFunction;
thing.otherFunction = stub;

//Now any calls to thing.otherFunction will call our stub instead
複製程式碼

但是,sinon的stub提供了許多更好用的功能:

  • 它們擁有spy的全特性
  • 你可以呼叫stub.restore()來恢復原始的行為
  • 你可以結合sinon的斷言

Mocks simply combine the behavior of spies and stubs, making it possible to use their features in different ways.

儘管有時候sinon看起來像個“黑魔法”,但它的大多數功能其實很容易自己實現。但比起自己來實現一套來說,sinon非常方便使用。

總結

真實專案的測試有時非常的複雜,導致你可能徹底放棄。但是使用sinon,測試變得非常簡單。

記住一個重要的準則:如果一個函式很難被測試,嘗試使用test-doubles替換它。

想知道更多關於如何讓你的程式碼使用sinon?當我的網站來,我會提供Sinon in the real-world guide給你,包含了sinon的最佳實踐,和三個真實的例子來講解如何在不同的測試方案中使用它。

相關文章