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,就會看到測試結果:
從上到下,可以看到有三個checkbox,這幾個的作用,我們後面再說。然後看到瀏覽器的User-Agent資訊。之後是總的測試資訊,跑了幾個斷言,通過了幾個,失敗了幾個。最後是詳細資訊。
假如我們稍微修改一下剛才的斷言條件,改成!=
assert.ok( "hello world" != "hello world", "Test hello wordl" );複製程式碼
則會得到測試失敗的資訊:
詳細資訊中有錯誤的行號,以及 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" );
});複製程式碼
瀏覽器中執行:
如果你需要嚴格的比較,需要用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函式。
結果:
這裡我們也可以使用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)
});複製程式碼
結果網頁中會多一個下拉框,可以選擇分組。
並且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。
- Hide passed tests
很好理解,就是隱藏通過的測試,勾選之後,通過的測試就不顯示出來了,在測試用例多的時候非常有用。而且使用了HTML5的sessionStorage
技術,會記住之前沒通過的測試,然後頁面重新載入的時候只測試之前那部分沒有通過的case。 - Check for Globals
“全域性檢查“,如果勾選了這項,在進行測試之前,QUnit會檢查測試之前和測試之後window
物件中的屬性,如果前後不一樣,就會顯示不通過。 - No try-catch
選中則意味著QUnit會在try-catch
語句之外執行回撥,此時,如果測試丟擲異常,測試就會停止。主要是因為有些瀏覽器的除錯工具是相當弱的,尤其IE6,一個未處理的異常要比捕獲的異常可以提供更多的資訊。即使再次丟擲,由於JavaScript不擅長異常處理,原來的堆疊跟蹤在大多數瀏覽器裡都丟失了。如果遇到一個異常,無法追溯錯誤程式碼的時候,就可以使用這個選項了。
另外每個測試旁邊都有個"Rerun"的按鈕,可以單獨執行某個測試。
結語
好吧,我承認,我騙了你,讀到這裡,你肯定花了不止30分鐘。但是相信我單元測試是非常必要的,寫單元測試一開始可能會讓你不適應,但是慢慢的你會發現效率提高了,更加愉悅了。
Demo 原始碼地址: github.com/bob-chen/qu…
參考資料
QUnit官網
QUnit Cookbook
stackoverflow.com/questions/9…
www.zhangxinxu.com/wordpress/2…