譯註:這是從 QUnit 官網上摘錄的一篇關於如何利用
QUnit
進行單元測試的入門級文檔。文章從最初的示例源代碼開始,通過逐步分析、重構,最終實現了適應QUnit
框架的可擴展的新代碼,其演變過程與重構思路值得借鑑,因此決定試着翻譯一下加深印象,同時也順帶熟悉Markdown
編輯風格。由於水平有限,翻譯不妥的地方,還望不吝賜教,共同進步。
單元測試簡介 | QUnit
你可能知道代碼有單元測試是件好事,但對客戶端代碼進行單元測試所遇到的第一個攔路虎,就是缺少所謂的單元;JavaScript
代碼可能遍佈某應用的各網頁、各模塊,並且很可能與後臺業務邏輯、相關 HTML 緊密混合。最糟糕的情況,是代碼以內聯事件函數的方式完全與 HTML 混爲一談。
這種情況常見於手頭缺少現成的處理 DOM 的 JS 庫的場合。相較於調用 DOM 的 API 來綁定事件函數,編寫內聯代碼要容易得多。越來越多的開發人員使用 jQuery 這樣的庫來操作 DOM,以便將這些內聯代碼抽取爲獨立的腳本,放到頁面某個固定位置,甚至是放到單獨的 JavaScript 文件中來引用。然而,這些經過處理的代碼還不能被視爲一個可供測試的單元。
那麼單元究竟指什麼?最理想的情況,單元是一個某種意義上的純函數 —— 對給定輸入始終輸出同一結果的函數。純函數的單元測試非常容易,但需要花大部分時間在消除其不良影響上。這裏的不良影響特指 DOM 操作。此外,純函數也有助於弄清將代碼重構成哪些單元並構建相應的測試用例。
構建單元測試
有了前面的知識儲備,着手進行單元測試就比完全從零開始容易得多了,但這不是本文的重點。這篇文章旨在處理更難的問題:抽取既有代碼並測試其重點部分,暴露並調試代碼中的潛在漏洞。
像這樣,在不修改當前行爲的情況下,提取代碼並將其轉換爲其他形式的過程,叫做重構。重構是完善程序代碼設計的一種絕佳手段,鑑於代碼的任何改動都可能改變程序的實際行爲,最保險的做法是在測試階段進行重構。
這類雞生蛋蛋生雞的問題,意味着在現有代碼上加入測試,就不得不承擔相應的破壞性風險。爲了將這樣的風險降至最低,在對單元測試有很好的瞭解之前,還是有必要繼續手動測試。
至此,理論就介紹得差不多了,來看一個實際的例子,測試某段與頁面內容聯繫並混雜在一起的 JavaScript 代碼。這段代碼檢索帶 title
屬性的鏈接,並將 title
屬性值用於顯示,相對於某個特定時間,某內容是在何時發佈的:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mangled date examples</title>
<script>
function prettyDate(time){
var date = new Date(time || ""),
diff = (((new Date()).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
}
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate(links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
若運行這個示例,會看到一個問題:沒有任何日期被替換,儘管代碼是有效的。它遍歷了頁面上的所有錨標記並逐一檢查其 title
屬性值,若存在,就傳給 prettyDate
函數執行。如果 prettyDate
進一步返回一個值,就把這個結果值更新到該鏈接的 innerHTML
中。
讓代碼可測
問題出在對超過 31 天的日期,prettyDate
返回 undefined
(通過一個單一的 return
語句隱式返回),這樣就保留了原來錨點的文本。來看看硬編碼一個“當前”日期,執行情況如何:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mangled date examples</title>
<script>
function prettyDate(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
}
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate("2008-01-28T22:25:00Z",
links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
可以看到,鏈接會顯示“2 hours ago”,“Yesterday” 等字樣。但這仍然不是可供測試的單元,在沒有對代碼作進一步改動的情況下,只能對 DOM 的變動情況進行測試。即便這樣可行,任一標記上的改動都可能打斷測試,這樣的測試事倍功半。
重構,階段0
相反地,我們來重構這段代碼,使其僅僅足以運行單元測試。
需要做兩件事:傳入一個當前時間給 prettyDate
函數作參數,而不是用 new Date
;再將函數抽取到一個獨立文件中,以便在一個單獨的頁面引入該文件進行單元測試。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<script src="prettydate.js"></script>
<script>
window.onload = function() {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate("2008-01-28T22:25:00Z",
links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
prettydate.js
內容如下:
function prettyDate(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff == 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff == 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
}
有了可以測試的代碼單元,就可以寫一些實際的單元測試用例了:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<script src="prettydate.js"></script>
<script>
function test(then, expected) {
results.total++;
var result = prettyDate("2008/01/28 22:25:00", then);
if (result !== expected) {
results.bad++;
console.log("Expected " + expected +
", but was " + result);
}
}
var results = {
total: 0,
bad: 0
};
test("2008/01/28 22:24:30", "just now");
test("2008/01/28 22:23:30", "1 minute ago");
test("2008/01/28 21:23:30", "1 hour ago");
test("2008/01/27 22:23:30", "Yesterday");
test("2008/01/26 22:23:30", "2 days ago");
test("2007/01/26 22:23:30", undefined);
console.log("Of " + results.total + " tests, " +
results.bad + " failed, " +
(results.total - results.bad) + " passed.");
</script>
</head>
<body>
</body>
</html>
- 運行示例 (先確認啓用類似 Firebug 或 Chrome 的 Web Inspector 這樣的 console 控制檯)
上述示例將創建一個隨機測試框架,僅使用控制檯作輸出。由於不依賴 DOM,可以將代碼放入文件中的 script
標籤直接在無瀏覽器的 JavaScript 環境中運行,如 Node.js
或 Rhino
。
若測試失敗,控制檯會輸出測試的期望值和實際值,最後給出一段測試小結,顯示測試總數,失敗總數和通過總數。
如果通過所有測試,控制檯會看到如下結果:
Of 6 tests, 0 failed, 6 passed.
可以改動部分內容,來看看斷言失敗的情況:
Expected 2 day ago, but was 2 days ago.
Of 6 tests, 1 failed, 5 passed.
這段隨機測試代碼旨在進行概念驗證(proof of concept),你也可以再寫一些代碼作測試程序運行,但更實際的做法是選用能提供更好的輸出、預設更多的基礎環境的現成的單元測試框架。
JavaScript 的 QUnit 測試套件
測試框架的選取通常因人而異。本文剩餘篇幅將使用 QUnit(讀作 “q-unit”),因其描述測試的風格與文中的隨機測試框架更爲吻合。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<script src="prettydate.js"></script>
<script>
QUnit.test("prettydate basics", function( assert ) {
var now = "2008/01/28 22:25:00";
assert.equal(prettyDate(now, "2008/01/28 22:24:30"), "just now");
assert.equal(prettyDate(now, "2008/01/28 22:23:30"), "1 minute ago");
assert.equal(prettyDate(now, "2008/01/28 21:23:30"), "1 hour ago");
assert.equal(prettyDate(now, "2008/01/27 22:23:30"), "Yesterday");
assert.equal(prettyDate(now, "2008/01/26 22:23:30"), "2 days ago");
assert.equal(prettyDate(now, "2007/01/26 22:23:30"), undefined);
});
</script>
</head>
<body>
<div id="qunit"></div>
</body>
</html>
這裏有三處值得留意。
一是在常規 HTML 樣板外引入的三個文件:其中兩個是與 QUnit
相關的(qunit.css
與 qunit.js
),以及之前的 prettydate.js
。
再者,引入了新的腳本代碼塊,調用了一次 test
方法,傳入一個字符串作第一參數(爲本次測試命名)、一個函數作第二參數。該函數具體執行本次測試代碼。測試代碼先定義了一個變量 now
,便於下文重用,然後用不同的參數多次調用了 equal
方法。equal
方法是 QUnit
內置的一個斷言方法,通過測試代碼塊中、回調函數的第一個參數進行調用。equal
方法的第一個參數,是 prettyDate
函數的執行結果,該函數的第一個參數是變量 now
,第二個參數是一個字符串 date
。equal
方法的第二個參數是期望結果,如果 equal
的兩個參數是同一個值,則斷言通過,否則斷言失敗。
最後,是 body
元素中與 QUnit
相關的標記。這些元素是可選的,引入後,QUnit 會將測試結果寫入這些標記。
測試結果如下:
若測試未通過,會得到類似下面的運行結果:
由於包含斷言失敗的測試用例,QUnit 不會收起該用例的測試結果,以便立即查看出錯信息。除了顯示期望值與實際值,還可以看到兩者的差異 diff
,適用於比較更長的字符串。本示例中的出錯信息一目瞭解。
重構,階段1
上述斷言部分還不太完整,因爲漏測了 n weeks ago
(n 周前)的情況。補充完整前,應考慮再次重構測試用例代碼。注意到在每例斷言中調用了 prettyDate
函數並傳入參數 now
。因此可以將其重構到一個自定義斷言方法中:
QUnit.test("prettydate basics", function( assert ) {
function date(then, expected) {
assert.equal(prettyDate("2008/01/28 22:25:00", then), expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
這裏將 prettyDate
函數的調用提取到自定義的 date
函數。該函數內置了變量 now
。最終,各斷言僅用到了相關的數據,可讀性更強;同時底層抽象的邏輯也很清晰。
測試 DOM 操作
prettyDate
函數測得差不多了,再回到先前的例子。除了 prettyDate
函數,源代碼還通過 window
的加載事件選取了 DOM 元素並更新了元素內容。這部分代碼也能用與之前相同的原則進行重構並測試。這裏將爲這兩個函數引入一個模塊,以免混淆全局命名空間,同時也可以給每個函數起一個更有意義的名稱。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<script src="prettydate2.js"></script>
<script>
QUnit.test("prettydate.format", function( assert ) {
function date(then, expected) {
assert.equal(prettyDate.format("2008/01/28 22:25:00", then),
expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
QUnit.test("prettyDate.update", function( assert ) {
var links = document.getElementById("qunit-fixture")
.getElementsByTagName("a");
assert.equal(links[0].innerHTML, "January 28th, 2008");
assert.equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update("2008-01-28T22:25:00Z");
assert.equal(links[0].innerHTML, "2 hours ago");
assert.equal(links[2].innerHTML, "Yesterday");
});
QUnit.test("prettyDate.update, one day later", function( assert ) {
var links = document.getElementById("qunit-fixture")
.getElementsByTagName("a");
assert.equal(links[0].innerHTML, "January 28th, 2008");
assert.equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update("2008/01/29 22:25:00");
assert.equal(links[0].innerHTML, "Yesterday");
assert.equal(links[2].innerHTML, "2 days ago");
});
</script>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture">
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z"
>January 28th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-27T22:24:17Z"
>January 27th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
</ul>
</div>
</body>
</html>
prettydate2.js
內容如下:
var prettyDate = {
format: function(now, time){
var date = new Date(time || ""),
diff = (((new Date(now)).getTime() - date.getTime()) / 1000),
day_diff = Math.floor(diff / 86400);
if ( isNaN(day_diff) || day_diff < 0 || day_diff >= 31 )
return;
return day_diff === 0 && (
diff < 60 && "just now" ||
diff < 120 && "1 minute ago" ||
diff < 3600 && Math.floor( diff / 60 ) +
" minutes ago" ||
diff < 7200 && "1 hour ago" ||
diff < 86400 && Math.floor( diff / 3600 ) +
" hours ago") ||
day_diff === 1 && "Yesterday" ||
day_diff < 7 && day_diff + " days ago" ||
day_diff < 31 && Math.ceil( day_diff / 7 ) +
" weeks ago";
},
update: function(now) {
var links = document.getElementsByTagName("a");
for ( var i = 0; i < links.length; i++ ) {
if ( links[i].title ) {
var date = prettyDate.format(now, links[i].title);
if ( date ) {
links[i].innerHTML = date;
}
}
}
}
};
新函數 prettyDate.update
是對原例的提取,並帶了一個參數 now
以供內部 prettyDate.format
調用。這個基於 QUnit 的測試用例先選取了 #qunit-fixture
元素內所有 a
元素。執行更新後,body
元素內的 <div id="qunit-fixture">…</div>
也更新了,裏面包含了抽取自原例的內容,以便進行更多有用的測試。將其放入 #qunit-fixture
元素後,不必擔心某次測試操作完 DOM 引起的變動對另一次測試的影響,因爲 QUnit 會在每次測試完成後自動重置標記中的內容。
考察對 prettyDate.update
的第一次測試。選中錨點後執行的兩個斷言用於驗證它們的初始文本值,然後調用 prettyDate.update
,傳入一個固定的日期(同前例)。接着又執行了兩次斷言,此時驗證這些元素中的 innerHTML
屬性值分別是變更後的格式化日期:“2 hours ago
” 與“Yesterday
”。
重構,階段2
另一個對 prettyDate.update, one day later
的測試,大同小異,只是傳入了一個不同於 prettyDate.update
的日期,由此得到兩個不同的結果。讓我們看看是否可以重構這些測試用例來消除代碼上的重複。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Refactored date examples</title>
<link rel="stylesheet" href="https://code.jquery.com/qunit/qunit-2.9.2.css">
<script src="https://code.jquery.com/qunit/qunit-2.9.2.js"></script>
<script src="prettydate2.js"></script>
<script>
QUnit.test("prettydate.format", function( assert ) {
function date(then, expected) {
assert.equal(prettyDate.format("2008/01/28 22:25:00", then),
expected);
}
date("2008/01/28 22:24:30", "just now");
date("2008/01/28 22:23:30", "1 minute ago");
date("2008/01/28 21:23:30", "1 hour ago");
date("2008/01/27 22:23:30", "Yesterday");
date("2008/01/26 22:23:30", "2 days ago");
date("2007/01/26 22:23:30", undefined);
});
function domtest(name, now, first, second) {
QUnit.test(name, function( assert ) {
var links = document.getElementById("qunit-fixture")
.getElementsByTagName("a");
assert.equal(links[0].innerHTML, "January 28th, 2008");
assert.equal(links[2].innerHTML, "January 27th, 2008");
prettyDate.update(now);
assert.equal(links[0].innerHTML, first);
assert.equal(links[2].innerHTML, second);
});
}
domtest("prettyDate.update",
"2008-01-28T22:25:00Z", "2 hours ago", "Yesterday");
domtest("prettyDate.update, one day later",
"2008/01/29 22:25:00", "Yesterday", "2 days ago");
</script>
</head>
<body>
<div id="qunit"></div>
<div id="qunit-fixture">
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
January 28th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-27T22:24:17Z"
>January 27th, 2008</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
</ul>
</div>
</body>
</html>
至此,出現一個新函數 domtest
,它封裝了前兩次 test
方法的調用,引入了測試名稱、日期字符串,以及兩個期望值等參數,然後被調用了兩次。
回到最初的例子
回到最開始介紹的源代碼中,看看重構之後的樣子。
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Final date examples</title>
<script src="prettydate2.js"></script>
<script>
window.onload = function() {
prettyDate.update("2008-01-28T22:25:00Z");
};
</script>
</head>
<body>
<ul>
<li class="entry">
<p>blah blah blah...</p>
<small class="extra">
Posted <span class="time">
<a href="#2008/01/blah/57/" title="2008-01-28T20:24:17Z">
<span>January 28th, 2008</span>
</a>
</span>
by <span class="author"><a href="#john/">John Resig</a></span>
</small>
</li>
<!-- more list items -->
</ul>
</body>
</html>
作爲非靜態的示例,還應該移除 prettyDate.update
方法的參數。總而言之,重構較最初的示例有了很大的改進。藉助引入的 prettyDate
模塊,可以在不破壞全局命名空間的情況下添加更多的測試功能。
結語
測試 JavaScript
代碼不僅僅是使用某個測試程序和編寫幾個測試用例的問題。在對以往手動測試的源代碼執行單元測試時,通常需要作一些重大的結構性變更。我們已經介紹了一個示例,演示瞭如何變更現有模塊的代碼結構,以便利用一個隨機測試框架來運行一些測試用例,然後將其替換爲更全面的框架,以期更實用的視覺呈現。
QUnit 還有更多功能有待發掘,如支持對超時、AJAX 及事件處理等異步代碼的測試。其可視化的測試程序有助於代碼調試,以便重新運行特定的測試,併爲失敗的斷言和捕獲的異常提供堆棧跟蹤信息。更多詳情,參考 QUnit Cookbook.