Best Practices for Spies, Stubs and Mocks in Sinon.js

Best Practices for Spies, Stubs and Mocks in Sinon.js

本文是 Best Practices for Spies, Stubs and Mocks in Sinon.js 的筆記。

在開始讀之前請注意

不同的語言或測試套件可能對 Spies, Stubs 和 Mocks 有不同的解釋

你應該查閱測試套件對它們的解釋和使用時機

Example Function

下面為本文討論的例子

function setupNewUser(info, callback) {
  var user = {
    name: info.name,
    nameLowercase: info.name.toLowerCase()
  };

  try {
    Database.save(user, callback);
  }
  catch(err) {
    callback(err);
  }
}

Spies, Stubs and MocksS

它們被稱為 測試替身 test doubles

When Do You Need Test Doubles?

為了了解何時使用它們,我們先將 function 分成兩類:

  • 無邊際效應的函數
  • 有邊際效應的函數

無邊際效應的函數 是指函數結果只和參數有關,同樣的參數會產生一樣的結果

有邊際效應的函數 是指除了本身的參數外還與外界有關的函數,像是其它物件的狀態、當前時間、資料庫的回傳。

因此,toLowerCase() 是無邊際效應的函數,它只和字串值有關。然而 Database.save() 是有邊際效應的函數,因為它結果受到外界影響,可能每次的結果都不一樣。

若要測試 setupNewUser,我們替 Database.save() 換成測試替身,因為不能讓他產生邊際效應。也就是說

當測試目標有邊際效應時,會使用測試替身,讓我們的當測試目標變成無邊際效應

除了針對有邊際效應函數使用測試替身,常常 CPU-sensitivy 函數也可以使用測試替身,因為它們會托慢我們的測試。

When to Use Spies

Syies 用來取得函數的呼叫資訊,像是函數的:

  1. 呼叫次數
  2. 呼叫時輸入的參數
  3. 回傳值
  4. 丟出什麼 error

因此,它們被用來驗証與外介的互動。用Sinon的 assertions ,可以做下列的事:

  1. 檢查函數被呼叫幾次
  2. 檢查被呼叫時傳入的參數

檢查函數被呼叫幾次 可以用 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.lastCallspy.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

When to Use Stubs

Stubs 就像 Syies 可以進行各種驗証,不僅如此,它們被用來取代目標函數,像是:

  1. 替換成客制的行為:回傳值或丟出例外
  2. 自動地呼叫 callback function 且送入指定的參數
  3. 回傳 Promise:可以是 resolved promise 或 rejected promise

常常被用來:

  1. 取代有問題的程試片斷 - 外介相關、未完成或壞掉的片斷
  2. 用來觸發程式路徑 - error handling
  3. 使非同步程式更容易測試 - 自動呼叫 callback function 或回傳 Promise

取代有問題的程試片斷 有問題的程式片斷使測試變難。常是因為與外介有關,像是網路連線,資料庫或非 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 所設定的參數。

When to Use Mocks

當使用 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();
});

注意到:

  1. 我們的期望(expectation)是定義在前面,而不是一般地定義在程式的後面。
  2. 當使用 mock 時,我們直接在mock上定義一個 mocked function 在
  3. 在程式碼的最後用 verity 執行驗証

在上面的例子中 once()withArgs() 用來定義呼叫次數和送入的參數。若使用 stub 要驗証多個條件。

因為 mock 可以方便地同時對許多條件驗証,因此容易過度使用。我們容易寫出驗証的條件比實際上所需的還要多,導致測試更難理解和易碎。這也避免多條件驗証原因之一,當用 mock 時需要留意。

Best Practices and Tips

Use 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

Async Tests with sinon.test

這裡章節因為與 Mocha 測試框架、sinon.test 有關,所以我們跳過。

關於覆寫setTimeout和其它相關的全域函數,請參考 Fake timers - Sinon.JS

Create Shared Stubs in beforeEach

若需要在每個測試中重新產生測試替身,你可以將產生它們的碼放在 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` 是否有清除它。不這麼做可能會引起其它測試的錯誤。

 Checking the Order of Function Calls or Values Being Set

若你要驗証呼叫順序,可以用 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的值。

Conclusion

Sinon 是很有用的工具,透過這篇你可以避免常見的使用錯誤。最重要的是記得使用 sandbox,否則cascading failure會是你的挫折來源。

results for ""

    No results matching ""