你是否曾經(jīng)嘗試過謝蓋代碼后,導(dǎo)致其它地方出現(xiàn)問題嗎老赤?
我相信很多人都遇到過。因?yàn)檫@是幾乎不可避免的制市,特別在龐大的代碼面前抬旺。由于代碼間可能是環(huán)環(huán)相扣的,改變一處會(huì)影響另一處祥楣。
但如果這種情況不會(huì)發(fā)生呢开财?如果有一種方法能讓你知道改變后會(huì)出現(xiàn)的結(jié)果呢?這無疑是極好的误褪。因?yàn)樾薷拇a后無需擔(dān)心會(huì)破壞什么東西责鳍,從而程序出現(xiàn) bug 的概率更低,在 debug 上話費(fèi)時(shí)間更少兽间。
這就是單元測試的魅力历葛。它能自動(dòng)檢測代碼中的任何問題。在修改代碼后進(jìn)行相應(yīng)測試嘀略,若有問題恤溶,能立刻知道問題是什么,問題在哪和正確的做法是什么帜羊。這完全可以消除任何猜測咒程!
在本文,我會(huì)讓你了解如何對 JavaScript 代碼進(jìn)行單元測試讼育。而且孵坚,在本文出現(xiàn)的案例和技術(shù)可同時(shí)應(yīng)用到基于瀏覽器的代碼和 Node.js 的代碼。
[阮一峰 測試框架 Mocha 實(shí)例教程](測試框架 Mocha 實(shí)例教程)
什么是單元測試
當(dāng)你對代碼庫進(jìn)行測試時(shí)窥淆,可先取一段代碼(通常是一個(gè)函數(shù)),然后在特定情況下巍杈,驗(yàn)證其行為是否正確忧饭。而單元測試就是這方面的一種結(jié)構(gòu)化和自動(dòng)化的方法。當(dāng)然筷畦,寫的測試越多词裤,獲得的益處也更大刺洒。這也會(huì)讓你在開發(fā)時(shí)更加自信。
單元測試的核心思想是給函數(shù)特定的輸入值蒙畴,測試其行為悄泥。也就是說怀大,以特定的參數(shù)調(diào)用函數(shù),然后檢查是否得到正確的結(jié)果因俐。
// 輸入 1 和 10...
var result = Math.max(1, 10);
// ...應(yīng)該輸出 10
if(result !== 10) {
throw new Error('Failed');
}
在實(shí)際中,測試有時(shí)會(huì)更復(fù)雜周偎。例如抹剩,如果你的函數(shù)含有一個(gè) Ajax 請求,那么測試就需要設(shè)定更多的東西蓉坎。當(dāng)然澳眷,“根據(jù)特定的輸入值得到特定的輸出值”原理仍然適用。
設(shè)置工具
在本文蛉艾,我們選擇 Mocha钳踊。它入門簡單,能同時(shí)適用于基于瀏覽器的測試和 Node.js 的測試勿侯,而且與其它測試工具配合同樣運(yùn)行良好拓瞪。
安裝好 Node.js 后,在你的項(xiàng)目目錄下打開 terminal 或 command line罐监。
- 如果你想在瀏覽器上測試代碼吴藻,執(zhí)行 npm install mocha chai --save-dev。
- 如果你想測試 Node.js 代碼弓柱,除了執(zhí)行上面那行命令沟堡,也要執(zhí)行 npm install -g mocha。
此時(shí)已經(jīng)安裝了 mocha 和 chai 包(package)矢空。Mocha 是一個(gè)運(yùn)行測試的庫航罗,而 Chai 包含一些有用的功能,我們能利用這些功能對我們的測試結(jié)果進(jìn)行驗(yàn)證屁药。
Node.js vs Browser 測試對比
下面的案例是在瀏覽器上運(yùn)行測試的粥血。如果想為你的 Node.js 應(yīng)用進(jìn)行單元測試,要遵循以下步驟酿箭。
- 對于 Node复亏,無需測試運(yùn)行文件(test runner file)。
- 為了引入 Chari缭嫡,需在測試文件頂部添加語句 var chai = require('chai');缔御。
- 用 mocha 命令執(zhí)行單元測試,而不是打開瀏覽器妇蛀。
設(shè)置目錄結(jié)構(gòu)
為了讓文件結(jié)構(gòu)更清晰耕突,應(yīng)將測試文件放在主代碼文件的一個(gè)獨(dú)立目錄下笤成。這是為了方便以后添加其它類型的測試(如集成測試(integration tests) 和 功能測試(functional tests))。
對于 JavaScript眷茁,最流行的實(shí)踐方案是在項(xiàng)目根目錄下創(chuàng)建一個(gè) test/ 文件夾炕泳。然后,將每個(gè)測試文件放置在該文件夾下上祈,如 test/someModuleTest.js培遵。另一種方案是,在 test/ 目錄下雇逞,再創(chuàng)建文件夾荤懂。但我建議盡量保持簡單——這樣能保證在后面必要時(shí)進(jìn)行(快速)修改。
設(shè)置測試運(yùn)行器(Test Runner)
為了能在瀏覽器上進(jìn)行測試塘砸,我們需要?jiǎng)?chuàng)建一個(gè)簡單的 HTML 頁面作為測試運(yùn)行頁(test runner page)节仿。該頁面會(huì)加載 Mocha、測試庫文件和實(shí)際測試文件掉蔬。為了運(yùn)行這些測試廊宪,我們只需在瀏覽器打開運(yùn)行器(runner)。
如果你使用 Node.js女轿,你可跳過這一步箭启。Node.js 的單元測試能通過命令 mocha 運(yùn)行,前提是按照我推薦的目錄結(jié)構(gòu)蛉迹。
下面是我們用于測試運(yùn)行器(test runner)的代碼傅寡。我將其存為 testrunner.html。
<!DOCTYPE html>
<html>
<head>
<title>Mocha Tests</title>
<link rel="stylesheet" href="node_modules/mocha/mocha.css">
</head>
<body>
<div id="mocha"></div>
<script src="node_modules/mocha/mocha.js"></script>
<script src="node_modules/chai/chai.js"></script>
<script>mocha.setup('bdd')</script>
<!-- load code you want to test here -->
<!-- load your test files here -->
<script>
mocha.run();
</script>
</body>
</html>
該測試運(yùn)行器的幾個(gè)重要點(diǎn):
- 為了讓測試結(jié)果擁有漂亮的樣式北救,我們加載了 Mocha 的 CSS 文件荐操。
- 創(chuàng)建了一個(gè) ID 為 mochat 的 div 標(biāo)簽。測試結(jié)果將放在該標(biāo)簽內(nèi)珍策。
- 加載 Mocha 和 Chai 腳本文件托启。由于這兩個(gè)文件是通過 npm 安裝的,它們被放在 node_modules 目錄的子文件夾下攘宙。
- 通過調(diào)用 mocha.setup屯耸,開啟 Mocha 的測試功能(testing helpers)。
- 然后蹭劈,加載需要的測試項(xiàng)和相應(yīng)測試的文件疗绣。盡管我們還沒在這放置任何代碼。
- 最后铺韧,調(diào)用了 mocha.run 執(zhí)行相應(yīng)測試持痰。當(dāng)然,要確保在資源和測試文件加載完成后再調(diào)用該函數(shù)祟蚀。
基本的測試骨架
現(xiàn)在我們可以運(yùn)行測試了工窍,下面就開始寫點(diǎn)測試相關(guān)的東西吧。
首先前酿,我們創(chuàng)建一個(gè) test/arrayTest.js, 每個(gè)文件名都有其具體含義患雏,顯然它是個(gè)測試文件,并會(huì)測試 array 的基本功能罢维。
每個(gè)測試案例文件都會(huì)遵循以下基本模式淹仑,首先,有個(gè) describe 塊:
describe('this is a Array', function(){
// Further code dor tests goes here
})
describe 用于把單獨(dú)的測試聚合在一起肺孵。其第一個(gè)參數(shù)用于指示測試什么匀借,在本例中,由于我們打算測試 array 功能平窘,我傳入一個(gè) ‘Array’ 字符串吓肋。
然后,在 describe 內(nèi)需有 it 塊:
describe('Array', function() {
it('should start empty', function() {
// Test implementation goes here
});
// We can have more its here
});
it 用于創(chuàng)建實(shí)際的測試瑰艘。其第一個(gè)參數(shù)是對該測試的描述是鬼,且該描述的語言應(yīng)該是人類可讀的(而非編程語言)。如在本例中紫新,“it should empty” 能很好地描述了 array 的行為均蜜。實(shí)現(xiàn)該測試的具體代碼則寫在 it 的第二個(gè)參數(shù) function 內(nèi)。
所有 Mocha 測試都以同樣的骨架編寫芒率,而且它們遵循相同的基本模式囤耳。
- 首先,使用 describe 表明我們測試什么偶芍,如 “描述 array 該如何運(yùn)行”充择。
- 然后,使用多個(gè) it 函數(shù)創(chuàng)建獨(dú)立的測試腋寨,每個(gè) it 應(yīng)該描述一個(gè)特定的行為聪铺,如上述的案例 “it should start empty(array 運(yùn)行前應(yīng)為空)”
編寫測試代碼
現(xiàn)在我們已經(jīng)知道如何構(gòu)造測試案例了,下面就開始更有趣的部分--實(shí)現(xiàn)測試萄窜。
由于我們的測試是 array 初始值應(yīng)為空铃剔,即我們需要?jiǎng)?chuàng)建一個(gè)數(shù)組,并確保它為空查刻。實(shí)現(xiàn)該測試非常簡單:
var assert = chai.assert;
describe("測試 array 是怎么工作", function(){
it("應(yīng)該是一個(gè) empty"键兜, function(){
var arr = [];
assert.equal(arr.length, 0);
})
});
請注意首行代碼,我們設(shè)置了 assert 變量穗泵。這樣就不用每次都輸入 chai.assert 了普气。
在 it 函數(shù)里,我們創(chuàng)建了一個(gè)數(shù)組并檢查其長度佃延。盡管簡單现诀,但很好地展示了測試是如何工作的夷磕。
首先,你有東西需要被測試——這叫 被測系統(tǒng)(System Under Test仔沿,SUT)坐桩。若有需要,則對被測系統(tǒng)進(jìn)行相應(yīng)操作封锉。對于上述案例绵跷,由于檢查數(shù)組初始值是否為空,我們沒做任何操作成福。
測試的最后步驟應(yīng)該是驗(yàn)證——對結(jié)果進(jìn)行斷言(assertion)檢查碾局。對于上述案例,我們對此使用 assert.equal奴艾。大多數(shù)斷言函數(shù)的參數(shù)順序是一致的:首先是“實(shí)際”值净当,然后是“期待”值。
實(shí)際值是測試代碼的結(jié)果握侧,因此蚯瞧,在該案例中是 arr.length。
期待值是預(yù)想的結(jié)果品擎。由于數(shù)組的初始值應(yīng)為空埋合,因此,在該案例中的期待值是 0萄传。
雖然 Chai 提供了兩種不同的斷言(assertion)編寫方式甚颂,但現(xiàn)在為了保持簡單,我們使用了 assert秀菱。當(dāng)你能熟練編寫測試時(shí)振诬,你可能更想用 expect assertions ,因?yàn)樗峁┝烁`活的操作衍菱。
運(yùn)行測試
為了運(yùn)行該測試赶么,我們需要將其添加到先前創(chuàng)建的測試運(yùn)行器文件內(nèi)。
對于 Node.js脊串,我們可以跳過此步驟辫呻,然后使用命令 mocha 執(zhí)行測試。你會(huì)在 terminal 里看到測試結(jié)果琼锋。
向運(yùn)行器添加該測試(針對瀏覽器端):
<!-- load your test files here -->
<script src="test/arrayTest.js"></script>
你一旦添加了腳本放闺,就可以加載測試運(yùn)行器頁面了(若選擇在瀏覽器進(jìn)行測試)。
測試結(jié)果
當(dāng)你運(yùn)行這些測試缕坎,其測試結(jié)果看起來和下圖類似:
注意:在 describe 和 it 函數(shù)的描述語句都在頁面展示出來了——測試項(xiàng)(如:should start empty)都分組放在描述(如:Array)下怖侦。當(dāng)然,也可以對 describe 塊再嵌套,以創(chuàng)建更深的子分組匾寝。
下面看看測試失敗會(huì)顯示什么搬葬。
將測試的該行代碼進(jìn)行修改:
assert.equal(arr.length, 0);
將 0 改為 1。這無疑會(huì)導(dǎo)致測試失敗旗吁,因?yàn)閿?shù)組長度不再匹配期待值踩萎。
如果你再次運(yùn)行測試,那么在測試結(jié)果中很钓,運(yùn)行錯(cuò)誤的描述將以紅色顯示。
測試的一項(xiàng)好處是能幫助你更快地找到 bug董栽,盡管錯(cuò)誤信息在這并不是非常詳細(xì)码倦。但是我們可以解決這個(gè)問題。
大多數(shù)斷言函數(shù)都帶有一個(gè)可選的 message 參數(shù)锭碳。該信息參數(shù)會(huì)在斷言失敗時(shí)顯示袁稽。因此我們可以利用該參數(shù),讓錯(cuò)誤信息更容易理解擒抛。
我們能像下面那樣向斷言添加 message 參數(shù):
assert.equal(arr.length, 1, 'Array length was not 0');
如果你再次運(yùn)行測試推汽,那么自定義的信息會(huì)取代默認(rèn)的信息而顯示出來。
OK歧沪,讓我們將 1 改回 0歹撒,確保測試通過。
綜合案例
到目前為止诊胞,案例都是相當(dāng)簡單的暖夭。那么下面就讓我們將學(xué)到的知識(shí)付諸實(shí)踐,看看如何測試將一段實(shí)際當(dāng)中所用到的代碼撵孤。
下面是一個(gè)將 CSS 類名添加到元素的函數(shù)迈着。我們將該函數(shù)放進(jìn)新文件 js/className.js。
function addClass(el, newClass) {
if(el.className.indexOf(newClass) === -1) {
el.className += newClass;
}
}
當(dāng)元素的 className 屬性不含有新類名時(shí)邪码,才向元素添加新類名--畢竟誰想看到 <div class="hello hello hello hello">裕菠。
在最好的情況下,我們要在編寫代碼前先為該函數(shù)編寫測試闭专。但 測試驅(qū)動(dòng)開發(fā)(test-driven development) 是一個(gè)復(fù)雜的主題奴潘,因此我們現(xiàn)在僅專注于編寫測試。
開始前喻圃,讓我們重溫單元測試的基本思想:賦予函數(shù)特定的輸入值萤彩,然后驗(yàn)證函數(shù)的行為是否符合預(yù)期。所以斧拍,該函數(shù)的輸入值和行為是什么呢雀扶?
給定一個(gè)元素和一個(gè)類名:
若元素的 className 屬性未含有該類名,則應(yīng)添加。
若元素的 className 屬性已含有該類名愚墓,則不應(yīng)添加予权。
將這兩種情況轉(zhuǎn)化為兩個(gè)測試。在 test 目錄下浪册,創(chuàng)建新文件 classNameTest.js 并添加以下內(nèi)容:
describe('addClass', function() {
it('should add class to element');
it('should not add a class which already exists');
});
我們也可以將措詞稍微地改成 “it should do X”扫腺,雖然可讀性更強(qiáng)一點(diǎn),但本質(zhì)上仍然與我們上述語句的可讀性一致村象。根據(jù)原來的措詞聯(lián)想到相應(yīng)的測試也不難笆环。
等等,測試函數(shù)跑去哪了厚者?當(dāng)我們省略 it 的第二個(gè)參數(shù)躁劣,Mocha 會(huì)在測試結(jié)果中標(biāo)記這些測試為待測試項(xiàng)。這讓設(shè)置多個(gè)測試變得更方便——就像一個(gè)備忘錄库菲,列著打算編寫的測試账忘。
接著實(shí)現(xiàn)第一個(gè)測試。
describe('addClass', function() {
it('should add class to element', function() {
var element = { className: '' };
addClass(element, 'test-class');
assert.equal(element.className, 'test-class');
});
it('should not add a class which already exists');
});
在該測試中熙宇,我們創(chuàng)建了 element 變量鳖擒,并將其與字符串 test-class(作為元素的新類名) 作為參數(shù)傳入 addClass 函數(shù)。然后烫止,使用斷言檢查該類名是否已包含在值(element.className)里蒋荚。
再一次,我們從初始的想法出發(fā)——給定一個(gè)元素和一個(gè)類名烈拒,將類名添加到 class 列表圆裕,然后以簡單的方式將其轉(zhuǎn)化為代碼。
盡管該函數(shù)(addClass)是針對 DOM 元素的荆几,但我們在此使用了一個(gè)簡單 JS 對象(plain JS object吓妆,根據(jù) jQuery 官方定義:含有零個(gè)或多個(gè)鍵值對的對象)。是的吨铸,有時(shí)我們可以利用 JavaScript 的動(dòng)態(tài)特性行拢,以上述方式簡化測試。如果不這樣做诞吱,我們就要?jiǎng)?chuàng)建一個(gè)實(shí)際的元素舟奠,這無疑會(huì)使測試代碼變復(fù)雜。當(dāng)然房维,這還有另一個(gè)好處沼瘫,由于沒使用 DOM,該測試也能在 Node.js 運(yùn)行咙俩。
在瀏覽器運(yùn)行測試
為了在瀏覽器運(yùn)行測試耿戚,你需要在運(yùn)行器添加 className.js 和 classNameTest.js湿故。
<!-- load code you want to test here -->
<script src="js/className.js"></script>
<!-- load your test files here -->
<script src="test/classNameTest.js"></script>
正如下面 CodePen 中所顯示的:一個(gè)測試通過,而另一個(gè)顯示待測試膜蛔。注意:為了讓代碼運(yùn)行在 CodePen 環(huán)境下坛猪,代碼需稍作調(diào)整。
接著皂股,實(shí)現(xiàn)第二個(gè)測試…
it('should not add a class which already exists', function() {
var element = { className: 'exists' };
addClass(element, 'exists');
var numClasses = element.className.split(' ').length;
assert.equal(numClasses, 1);
});
經(jīng)常運(yùn)行測試是一種好習(xí)慣墅茉。因此,讓我們現(xiàn)在運(yùn)行測試看看會(huì)發(fā)生什么呜呐。
不出所料就斤,兩者均通過。
下面是在 CodePen 中實(shí)現(xiàn)第二個(gè)測試的例子蘑辑。
但事情沒那么簡單战转!該函數(shù)的第三種情況我們并沒有考慮到,這也是該函數(shù)的一個(gè)非常嚴(yán)重的 Bug以躯。雖然該函數(shù)只有三行代碼,但你注意到了嗎啄踊?
下面為第三種情況編寫多一個(gè)案例忧设,讓這個(gè) Bug 暴露出來。
it('should append new class after existing one', function() {
var element = { className: 'exists' };
addClass(element, 'new-class');
var classes = element.className.split(' ');
assert.equal(classes[1], 'new-class');
});
你可在下面的 CodePen 中看到颠通,這次測試失敗了址晕。導(dǎo)致該問題的原因很簡單:元素上的 CSS 類名應(yīng)以空格隔開。然而顿锰,現(xiàn)在實(shí)現(xiàn)的 addClass 并未加空格谨垃!
修復(fù)該函數(shù),讓測試通過硼控。
function addClass(el, newClass) {
if(el.className.indexOf(newClass) !== -1) {
return;
}
if(el.className !== '') {
//ensure class names are separated by a space
newClass = ' ' + newClass;
}
el.className += newClass;
}
修復(fù)后刘陶,最終在 CodePen 測試通過。
在 Node 中運(yùn)行測試
在 Node 中牢撼,只有同一文件中的內(nèi)容是可見的匙隔。由于 className.js 和 classNameTest.js 在不同文件下,我們需要一種方式將一個(gè)文件導(dǎo)出到另一個(gè)文件內(nèi)熏版。而標(biāo)準(zhǔn)的方式是通過 module.exports纷责。如果你需要復(fù)習(xí)相關(guān)知識(shí),你可以看看 Understanding module.exports and exports in Node.js撼短。
代碼本質(zhì)不變再膳,只是結(jié)構(gòu)稍微不同:
// className.js
module.exports = {
addClass: function(el, newClass) {
if(el.className.indexOf(newClass) !== -1) {
return;
}
if(el.className !== '') {
//ensure class names are separated by a space
newClass = ' ' + newClass;
}
el.className += newClass;
}
}
// classNameTest.js
var chai = require('chai');
var assert = chai.assert;
var className = require('../js/className.js');
var addClass = className.addClass;
// 文件其它部分保持不變
describe('addClass', function() {
...
});
正如你所看到的,測試通過曲横。
下一步呢喂柒?
正如你所看到的,測試不復(fù)雜也不難。與編寫 JavaScript 應(yīng)用的其它方面一樣胳喷,有一些重復(fù)的基本模式湃番。一旦你熟悉了這些,你可以一次又一次的使用它們吭露。
但這些只是單元測試的皮毛吠撮,還有很多相關(guān)知識(shí)需要學(xué)習(xí)。
- 測試更復(fù)雜的系統(tǒng)
- 如何處理Ajax讲竿、數(shù)據(jù)庫和其它“外部”的東西泥兰。
- 測試驅(qū)動(dòng)開發(fā)