單元測試:Mocha、Chai 和 Sinon

單元測試(Unit Test)

什麼是 Unit Test?

以 function 為最小單位,驗證在特定情況下的 input 和 output 是否正確。

例如

// 給定輸入為 1 和 2
var result = add(1, 2);

// 預期得到輸出 3
if (result !== 3) {
  throw new Error('Failed');
}

為什麼要做 Unit Test?

以上這些目的都可以使用人工測試達到,但如果能靠程式自動且在極短時間內完成呢?這樣是不是很美好?使用以下的方法做 Unit Test 就可以這麼輕鬆愉快喔 :)

如何做 Unit Test?

Mocha

Mocha 是一個 JavaScript 的測試框架,目的是用來管理測試的程式碼。

在這裡指定介面(Interface)為 BDD。若使用 BDD 則提供 describe()it()before()after()beforeEach()afterEach()方法。

語法說明

語法範例

describe('hooks', function() {
  // 測試區塊
  before(function() {
    // 在所有測試開始前會執行的程式碼區塊
  });

  after(function() {
    // 在所有測試結束後會執行的程式碼區塊
  });

  beforeEach(function() {
    // 在每個 Test Case 開始前執行的程式碼區塊
  });

  afterEach(function() {
    // 在每個 Test Case 結束後執行的程式碼區塊
  });

  // 撰寫個別 Test Case
  it('should ...', function() {
    // 執行 Test Case
  });
});

TDD vs BDD

比較 TDD 與 BDD 的差異。

  TDD BDD
全名 測試驅動開發
Test-Driven Development
行為驅動開發
Behavior Driven Development
定義 在開發前先撰寫測試程式,以確保程式碼品質與符合驗收規格。 TDD 的進化版。
除了實作前先寫測試外,還要寫一份「可以執行的規格」。
特性 從測試去思考程式如何實作。
強調小步前進、快速且持續回饋、擁抱變化、重視溝通、滿足需求。
從用戶的需求出發,強調系統行為。
使用自然語言描述測試案例 ,以減少使用者和工程師的溝通成本。
測試後的輸出結果可以直接做為文件閱讀。

參考-認試軟體測試的世界 & TDD/BDD 入門

Chai

Chai 提供 BDD 語法測試用的斷言庫(Assertion Library)。斷言庫是一種判斷工具,驗證執行結果是否符合預期,若實際結果和預測不同,就是測到 bug 了。以下分 Assert 和 Expect / Should 說明。

Assert

assert(expression, message):測試這個項目的 expression 'foo' === 'bar' 是否為真,若為假則顯示錯誤訊息 message。

var assert = chai.assert;

describe('AssertTest', function() {
  var foo = 'Hello';
  var bar = 'World';

  it('should be equal', function() {
    assert('foo' === 'bar', 'foo is not bar');
  });
});

Chai Assert

Chai Assert

Expect / Should

預期 3 等於(===)2。這是使用可串連的 getters 來完成斷言。這些可串聯的 getters 有 to、is、have 等。它很像英文,用很口語的方式做判斷。

var expect = chai.expect;

describe('ExpectTest', function() {
  it('should be equal', function() {
    expect(3).to.equal(2);
  });
});

Chai Expect

測試 add(兩數相加)和 sub(兩數相減)功能。

// calculateModule
function _add(x, y) {
  return x + y;
}

function _sub(x, y) {
  return x - y;
}

module.exports = {
  add: _add,
  sub: _sub,
};
const expect = require('chai').expect;
const testModule = require('../modules/calculateModule');

describe('測試 add', () => {
  it('1 + 2 = 3', () => {
    expect(testModule.add(1, 2)).to.equal(3);
  });

  it('3 + 4 = 7', () => {
    expect(testModule.add(3, 4)).to.equal(7);
  });
});

describe('測試 sub', () => {
  it('1 - 2 = -1', () => {
    expect(testModule.sub(1, 2)).to.equal(-1);
  });

  it('11 - 4 = 7', () => {
    expect(testModule.sub(11, 4)).to.equal(7);
  });
});

單元測試:Mocha、Chai 和 Sinon

比較 Assert、Expect、Should 的差異

三者基本上都可完成相同工作,除了

// Assert 和 Expect 的客製化錯誤訊息範例
assert.isTrue(foo, 'foo should be true');
expect(foo, 'foo should be true').to.be.true;

Sinon

用來產生 Test Double(測試替身),可當成假資料來看,分為 Spy、Stub 和 Mock。

Spy

對 function call 蒐集資訊,便於對測試結果做驗證。sinon.spy() 會回傳一個 Spy 物件,這個 Spy 物件像蜜糖般包裹於原 function 外,讓我們可以像 function 一樣呼叫。而這個 Spy 物件的 property 會協助蒐集由 function call 得到的資訊,例如:取得第一次呼叫所輸入的參數、該 function 被呼叫的次數。Spy 是三者最簡單的部份,並且 Stub 和 Mock 是建構於 Spy 之上的。

const expect = require('chai').expect;
const sinon = require('sinon');
const testModule = require('../modules/comparisionModule');

describe('測試 comparePelple', function() {
  it('should call the callback function', function() {
    var nameList = ['Nina', 'Ricky'];
    var callback = sinon.spy();

    testModule.comparePelple(nameList[0], nameList[0], callback);
    console.log(callback.callCount); // 該 function 被呼叫的次數
  });
});

Sinon - Spy

Stub

取代 function。與 Spy 不同的是,Spy 依然會執行真的 function,但 Stub 並不會。適用於 Ajax 和 Timer。

範例

const expect = require('chai').expect;
const sinon = require('sinon');
const uuid = require('node-uuid');

describe('stub example', () => {
  it('check length of uuid ', () => {
    var stub = sinon.stub(uuid, 'v4');
    var mockId = stub.v4();
    expect(mockId.length).to.equal(36);
    uuid.v4.restore();
  });
});

Sinon - Stub

範例:Ajax Request

function saveUser(user, callback) {
  $.ajax(
    '/users',
    {
      first: user.firstname,
      last: user.lastname,
    },
    callback,
  );
}

describe('Stub: ajax', () => {
  it('should call callback after saving', () => {
    var ajax = sinon.stub($, 'ajax'); // 取代 ajax function,並不會真的呼叫
    ajax.yields('Hello', 'World'); // 準備把傳入 callback 的參數 ['Hello', 'World'] 丟進去

    var callback = sinon.spy();
    saveUser({ firstname: 'Han', lastname: 'Solo' }, callback);

    ajax.restore(); // 清除 test double
    expect(callback.callCount).to.equal(777); // 該 function 被呼叫的次數
  });
});

Mock

取代整個物件,包含完整實作細節。

var opts = {
  call: function(msg) {
    console.log(msg);
  },
};

describe('Mock', () => {
  it('should pass Hello World to run call()', function() {
    var mock = sinon.mock(opts);
    mock
      .expects('call')
      .once()
      .withExactArgs('Hello World');
    opts.call('Hello World');
    mock.restore();
    mock.verify();
  });
});

使用時機

總結一下三者使用的時機…

Chai Assertion vs Sinon Assertion

推薦使用 Sinon 的 Assertion,因為不需刻意客製化報錯,即可反應問題所在。

Chai Assertion 報錯範例。

assert('foo' === 'bar');

報錯如下,但難以了解問題所在。

AssertionError: Unspecified AssertionError

加入客製化報錯,清楚明瞭。

assert('foo' === 'bar', 'foo is not bar');
AssertionError: foo is not bar

Sinon Assertion 報錯範例。

var expected = { x: 999 },
  actual = { x: 1, y: 2 };
sinon.assert.match(actual, expected);

報錯如下,無需刻意客製化報錯,即可反應問題所在。

AssertError: expected value to match
expected = { x: 999 }
actual = { x: 1, y: 2 }

QnA

寫測試是否會增加額外工時?

工時是一定會增加的,個人經驗是增加一倍。

除了程式碼的品質保證外,還有什麼好處?

記錄規格。測試案例如同告知開發者規格的細節和範例,再也不怕同事離職,無人可問

附上露天讀書會投影片-單元測試:Mocha、Chai 和 Sinon

單元測試:Mocha、Chai 和 Sinon from Hsin-Hao Tang

推薦閱讀


Mocha Chai Sinon TDD 單元測試 BDD Unit Test 自動化測試 讀書會