30 分鐘 Qunit 入門教程

Bob-Chen發表於2016-11-29

30分鐘讓你瞭解 Javascript 單元測試框架 QUnit,並能在程式中使用。補充了控制檯輸出測試結果相關內容。

題外話

有些童鞋可能會問,單元測試真的有必要嗎?
實際上,相信我們寫完程式碼至少都會進行一些簡單的輸入輸出測試,檢查程式碼是否會報錯。但是這相對比較手工,當我們程式碼的內部邏輯進行了一些改動,我們又需要進行一些測試,而且很容易漏掉一些測試,造成迴歸錯誤(改這裡,造成那裡出錯)。如果我們有保留完整的單元測試程式碼,就可以方便的進行測試了。
同時,在進行每日構建的時候,都可以自動執行單元測試程式碼,讓程式碼更健壯。
另外,好的單元測試其實就等於一份程式碼說明書,要如何呼叫某個類,輸入什麼,輸出什麼,直接看單元測試程式碼,所謂的 don't bb show me the code :-)

QUnit是什麼

QUnit是一個強大,易用的JavaScript單元測試框架,由jQuery團隊的成員所開發,並且用在jQuery,jQuery UI,jQuery Mobile等專案。


Hello World

學習QUnit還是從例子開始最好,首先我們需要一個跑單元測試的頁面,這裡命名為index-test.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>QUnit Example</title>
  <link rel="stylesheet" href="http://code.jquery.com/qunit/qunit-1.17.1.css">
</head>
<body>
  <div id="qunit"></div>
  <div id="qunit-fixture"></div>
  <script src="http://code.jquery.com/qunit/qunit-1.17.1.js"></script>
  <script src="tests.js"></script>
</body>
</html>複製程式碼

這裡主要引入了兩個檔案,一個是QUnit的CSS檔案,一個是提供斷言等功能的JS檔案。

這裡另外引入了一個tests.js檔案,我們的測試用例就寫在這個檔案裡面。
tests.js:

QUnit.test( "hello test", function( assert ) {
  assert.ok( "hello world" == "hello world", "Test hello wordl" );
});複製程式碼

頁面載入完畢,QUnit就會自動執行test()方法,第一個引數是被測試的單元的標題,第二個引數,就是實際的而是程式碼,這裡的引數assert為QUnit的斷言物件,其中提供了不少斷言方法,這裡使用了ok()方法,ok()方法接受兩個引數,第一個是表明測試是否通過的bool值,第二個則是需要輸出的資訊。

我們在瀏覽器中執行index-test.html,就會看到測試結果:

30 分鐘 Qunit 入門教程

從上到下,可以看到有三個checkbox,這幾個的作用,我們後面再說。然後看到瀏覽器的User-Agent資訊。之後是總的測試資訊,跑了幾個斷言,通過了幾個,失敗了幾個。最後是詳細資訊。

假如我們稍微修改一下剛才的斷言條件,改成!=

assert.ok( "hello world" != "hello world", "Test hello wordl" );複製程式碼

則會得到測試失敗的資訊:

30 分鐘 Qunit 入門教程

詳細資訊中有錯誤的行號,以及 diff 資訊等。


更多斷言

上面介紹了assert.ok()方法,QUnit 還提供了一些別的斷言方法,這裡再介紹幾個常用的。

equal(actual, expected [,message])
equal()斷言用的是簡單的==來比較實際值和期望值,相同則通過,否則失敗。
修改一下tests.js:

QUnit.test( "hello test", function( assert ) {
  //assert.ok( "hello world" == "hello world", "Test hello wordl" );
  assert.equal( 0, 0, "Zero, Zero; equal succeeds" );
  assert.equal( "", 0, "Empty, Zero; equal succeeds" );
  assert.equal( "", "", "Empty, Empty; equal succeeds" );
  assert.equal( 0, false, "Zero, false; equal succeeds" );

  assert.equal( "three", 3, "Three, 3; equal fails" );
  assert.equal( null, false, "null, false; equal fails" );
});複製程式碼

瀏覽器中執行:

30 分鐘 Qunit 入門教程

如果你需要嚴格的比較,需要用strictEqual()方法。

deepEqual(actual, expected, [,message])
deepEqual()斷言的用法和equal()差不多,它除了使用===操作符進行比較之外,還可以通過比較{key : value}是否相等,來比較兩個物件是否相等。

QUnit.test( "deepEqual test", function( assert ) {
  var obj = { foo: "bar" };

  assert.deepEqual( obj, { foo: "bar" }, "Two objects can be the same in value" );
});複製程式碼

如果要顯式的比較兩個值,equal()也可以適用。一般來說,deepEqual()是個更好的選擇。

同步回撥
有時候,我們的測試用例包含回撥函式,要在回撥函式中進行斷言。這裡可以用到assert.expect()函式,它接受一個表示斷言數量的int值,表示這個test裡面,預計要跑多少個斷言。這裡為了方便,引入了jQuery庫,在index-test.html中加入<script src="http://code.jquery.com/qunit/qunit-1.17.1.js"></script>

QUnit.test( "a test", function( assert ) {
  assert.expect( 1 );

  var $body = $( "body" );

  $body.on( "click", function() {
    assert.ok( true, "body was clicked!" );
  });

  $body.trigger( "click" );
});複製程式碼

非同步回撥
assert.expect()對同步的回撥非常有用,但是對非同步回撥卻不是那麼適用。
稍微修改一下上面的例子:

QUnit.test( "a test", function( assert ) {
  var done = assert.async(); 
  var $body = $( "body" );

  $body.on( "click", function() {
    assert.ok( true, "body was clicked!" );
    done();
    $body.unbind('click');
  });

  setTimeout(function(){
      console.log("To click body")
      $body.trigger( "click" );
  }, 1000)

});複製程式碼

使用assert.async()返回一個"done"函式,當操作結束的時候,呼叫這個函式。另外我在"done"函式呼叫結束之後,把body的click事件給移除了,這個是為了方便我在點選結果網頁的時候,不要出發多次done函式。
結果:

30 分鐘 Qunit 入門教程

這裡我們也可以使用QUnit.start()與QUnit.stop()來控制非同步回撥中斷言的判斷。

QUnit.test( "a test 1", function( assert ) {
  QUnit.stop()
  var $body = $( "body" );

  $body.on( "click", function() {
    assert.ok( true, "body was clicked!" );
    QUnit.start();
    $body.unbind('click');
  });

  setTimeout(function(){
      console.log("To click body")
      $body.trigger( "click" );
  }, 1000)

});複製程式碼

QUnit還提供了QUnit.asyncTest()方法來簡化非同步呼叫的測試,不需要自己手動呼叫QUnit.stop()方法,並且從函式名也可以更容易的讓人知道這是個非同步呼叫的測試。

QUnit.asyncTest( "a test 2", function( assert ) {
  var $body = $( "body" );

  $body.on( "click", function() {
    assert.ok( true, "body was clicked!" );
    QUnit.start();
    $body.unbind('click');
  });

  setTimeout(function(){
      console.log("To click body")
      $body.trigger( "click" );
  }, 1000)

});複製程式碼

原子性
保持測試用例之間互不干擾很重要,如果要測試DOM修改,我們可以使用#qunit-fixture這個元素。#qunit-fixture就好比是拿來練級的小怪,每次打死,下次來又會滿血復活。
這個元素中你可以寫任何初始的HTML,也可以置空,每個test()結束,都會恢復初始值。

QUnit.test( "Appends a span", function( assert ) {
  var fixture = $( "#qunit-fixture" );

  fixture.append( "<span>hello!</span>" );
  assert.equal( $( "span", fixture ).length, 1, "div added successfully!" );
});

QUnit.test( "Appends a span again", function( assert ) {
  var fixture = $( "#qunit-fixture" );

  fixture.append("<span>hello!</span>" );
  assert.equal( $( "span", fixture ).length, 1, "span added successfully!" );
});複製程式碼

這裡我們無論對#qunit-fixture裡面的東西做什麼,下次測試開始的時候都會“滿血復活”。

分組
在QUnit中可以對測試進行分組,並且可以指定只跑哪組測試。
分組需要使用QUnit.module()方法。我們可以將剛才我們測試的程式碼進行一個簡單的分組。

QUnit.module("Group DOM Test")
QUnit.test( "Appends a span", function( assert ) {
  var fixture = $( "#qunit-fixture" );

  fixture.append( "<span>hello!</span>" );
  assert.equal( $( "span", fixture ).length, 1, "div added successfully!" );
});

QUnit.test( "Appends a span again", function( assert ) {
  var fixture = $( "#qunit-fixture" );

  fixture.append("<span>hello!</span>" );
  assert.equal( $( "span", fixture ).length, 1, "span added successfully!" );
});

QUnit.module("Group Async Test")
QUnit.test( "a test", function( assert ) {
  var done = assert.async(); 
  var $body = $( "body" );

  $body.on( "click", function() {
    assert.ok( true, "body was clicked!" );
    done();
    $body.unbind('click');
  });

  setTimeout(function(){
      console.log("To click body")
      $body.trigger( "click" );
  }, 1000)

});複製程式碼

結果網頁中會多一個下拉框,可以選擇分組。

30 分鐘 Qunit 入門教程

並且module也支援在每個測試之前或之後做些準備工作。

QUnit.module("Group DOM Test", {
    setup: function(){
        console.log("Test setup");
    },
    teardown: function(){
        console.log("Test teardown");
    }
})複製程式碼

在執行這個分組的每個test()執行前後會分別執行setup()teardown()函式。

AJAX測試
AJAX在前端中佔據了非常大的比重,由於AJAX的非同步回撥的複雜性,要做到業務程式碼和測試程式碼分離,也不容易,如果像jasmine框架中,用waitsFor來不停檢查,超時等,其實不是太優雅。
這裡結合jQuery,來一個比較優雅的,如果是使用別的框架,還需要另外研究。
不多說,直接上程式碼:
我們有一個進行ajax呼叫的物件:

var X = function () {
    this.fire = function () {
        return $.ajax({ url: "someURL", ... });
    };
};複製程式碼

然後是測試程式碼:

// create a function that counts down to `start()`
function createAsyncCounter(count) {
    count = count || 1; // count defaults to 1
    return function () { --count || QUnit.start(); };
}

// an async test that expects 2 assertions
QUnit.asyncTest("testing something asynchronous", 2, function(assert) {
    var countDown = createAsyncCounter(1), // the number of async calls in this test
        x = new X;

    // A `done` callback is the same as adding a `success` handler
    // in the ajax options. It's called after the "real" success handler.
    // I'm assuming here, that `fire()` returns the xhr object
    x.fire().done(function(data, status, jqXHR) {
        assert.ok(data.ok);
        assert.equal(data.value, "123");
    }).always(countDown); // call `countDown` regardless of success/error
});複製程式碼

countDown方法是用來記錄有多少個AJAX呼叫,然後在最後一個完成之後,呼叫QUnit.start()方法。QUnit.asyncTest中第二個引數"2"類似assert.expect( 2 )中的“2”。這裡done()和always()方法是jQuery的deferred物件提供的,而$.ajax()會返回jqXHR物件,這個物件具有deferred物件的所有隻讀方法。
如果你需要記錄一些錯誤資訊,可以新增.fail()方法。

自定義斷言
自定義斷言,就是直接使用QUnit.push()封裝一些自定義的判斷。QUnit.push()assert.equal的關係就類似於$.ajax$.get的關係。

QUnit.assert.mod2 = function( value, expected, message ) {
    var actual = value % 2;
    this.push( actual === expected, actual, expected, message );
};

QUnit.test( "mod2", function( assert ) {
    assert.expect( 2 );

    assert.mod2( 2, 0, "2 % 2 == 0" );
    assert.mod2( 3, 1, "3 % 2 == 1" );
});複製程式碼

上面的程式碼自定義了一個叫mod2的斷言。QUnit.push()有四個引數,一個Boolean型的result,一個實際值actual,一個期望值expected,以及一個說明message。
官網建議把自定義斷言定義在全域性的QUnit.assert物件上,方便重複利用。

控制檯輸出結果
QUnit 提供了 QUnit.log() 這個介面用於控制檯輸出,用法如下:

QUnit.log(function( details ) {
  console.log( "Log: ", details.result, details.message );
});複製程式碼

控制檯輸出結果:

Test setup 
Log: true div added successfully!
Test teardown複製程式碼

每次執行完一個測試用例都會呼叫這個方法列印相應的資訊,這裡 details.result 表示結果,如果用例 Pass 則為 true。另外 details 物件裡面還有很多資訊,這裡只用了兩個。

可以參考文件:api.qunitjs.com/QUnit.log/

控制檯輸出結果主要是用來和 PhantomJS 結合做自動化測試的,可以看下 qunit-phantomjs-runner

除錯工具與其他
最後我們來看看一開始說到的三個checkbox。

30 分鐘 Qunit 入門教程

  • Hide passed tests
    很好理解,就是隱藏通過的測試,勾選之後,通過的測試就不顯示出來了,在測試用例多的時候非常有用。而且使用了HTML5的sessionStorage技術,會記住之前沒通過的測試,然後頁面重新載入的時候只測試之前那部分沒有通過的case。
  • Check for Globals
    “全域性檢查“,如果勾選了這項,在進行測試之前,QUnit會檢查測試之前和測試之後window物件中的屬性,如果前後不一樣,就會顯示不通過。
  • No try-catch
    選中則意味著QUnit會在try-catch語句之外執行回撥,此時,如果測試丟擲異常,測試就會停止。主要是因為有些瀏覽器的除錯工具是相當弱的,尤其IE6,一個未處理的異常要比捕獲的異常可以提供更多的資訊。即使再次丟擲,由於JavaScript不擅長異常處理,原來的堆疊跟蹤在大多數瀏覽器裡都丟失了。如果遇到一個異常,無法追溯錯誤程式碼的時候,就可以使用這個選項了。

另外每個測試旁邊都有個"Rerun"的按鈕,可以單獨執行某個測試。

30 分鐘 Qunit 入門教程

30 分鐘 Qunit 入門教程


結語

好吧,我承認,我騙了你,讀到這裡,你肯定花了不止30分鐘。但是相信我單元測試是非常必要的,寫單元測試一開始可能會讓你不適應,但是慢慢的你會發現效率提高了,更加愉悅了。

Demo 原始碼地址: github.com/bob-chen/qu…

參考資料

QUnit官網
QUnit Cookbook
stackoverflow.com/questions/9…
www.zhangxinxu.com/wordpress/2…

相關文章