聊聊前端單元測(cè)試
點(diǎn)上方藍(lán)字關(guān)注公眾號(hào)「程序員成長(zhǎng)指北」
作者:大笑
來(lái)源:https://zhuanlan.zhihu.com/p/55887740
先來(lái)幾個(gè)專業(yè)詞匯,這樣顯得高大上一點(diǎn)(不存在的=。=)
BDD: Behavior-Driven Development (行為驅(qū)動(dòng)開(kāi)發(fā))
TDD: Test-Driven Development (測(cè)試驅(qū)動(dòng)開(kāi)發(fā))
ATDD: Acceptance Test Driven Development(驗(yàn)收測(cè)試驅(qū)動(dòng)開(kāi)發(fā))
好,說(shuō)完了,然后我們廢話不多說(shuō),直接進(jìn)入正題。我會(huì)從多個(gè)測(cè)試框架入手,結(jié)合各種斷言庫(kù),用代碼方式說(shuō)明。
單元測(cè)試(Unit Testing),是指對(duì)軟件中的最小可測(cè)試單元進(jìn)行檢查和驗(yàn)證。
當(dāng)今所有著名的框架都要進(jìn)行單元測(cè)試,經(jīng)過(guò)測(cè)試的框架,它的信任度顯然高于未測(cè)試的框架。
這里,我們介紹一下karma這個(gè)前端的單元測(cè)試框架。

首先我們來(lái)安裝一波:
新建一個(gè)空文件夾,然后在空文件夾中打開(kāi)終端輸入
npm init -y
(sudo) npm install karma-cli -g
npm install karma karma-jasmine karma-chrome-launcher jasmine-core --save-dev
npm install karma-phantomjs-launcher --save-dev
你安裝karma-cli這個(gè)倒是說(shuō)得過(guò)去,可是這個(gè)jasmine是啥,這個(gè)chrome-launcher和phantomjs-launcher又是啥?
沒(méi)錯(cuò),單說(shuō)測(cè)試框架是不完整的,必須要有斷言庫(kù)與之相配合,這里的jasmine就是斷言庫(kù)。

啥是斷言(assert)?
根據(jù)概念:
斷言是編程術(shù)語(yǔ),表示為一些布爾表達(dá)式,程序員相信在程序中的某個(gè)特定點(diǎn)該表達(dá)式值為真,可以在任何時(shí)候啟用和禁用斷言驗(yàn)證,因此可以在測(cè)試時(shí)啟用斷言而在部署時(shí)禁用斷言。
一言以蔽之,老子/老娘說(shuō)啥就是啥!聽(tīng)起來(lái)好像挺霸道的。那么具體呢?
順著karma的正常流程向下走,我們來(lái)寫(xiě)一個(gè)簡(jiǎn)單的單元測(cè)試。在終端輸入:
karma init你會(huì)發(fā)現(xiàn),需要做一個(gè)調(diào)查問(wèn)卷了,問(wèn)題如下:
> 請(qǐng)問(wèn)你要用哪種測(cè)試框架呢?
> 按tab鍵選擇,按回車(chē)鍵進(jìn)入下一個(gè)問(wèn)題。
> jasmine
(因?yàn)槲覀儼惭b的是jasmine,選什么斷言庫(kù)都別忘了安裝一下)
> 您想要使用Require.js么?
> 選擇yes的話,會(huì)安裝Require.js插件。
> 按tab鍵選擇,按回車(chē)鍵進(jìn)入下一個(gè)問(wèn)題。
> no
(這里我們選擇no)
> 你想要在什么瀏覽器中測(cè)試呢?
> 按tab鍵選擇,輸入空字符串進(jìn)入下一個(gè)問(wèn)題。
> Chrome
> PhantomJS
>
注:上面的選擇這兩個(gè)瀏覽器的原因是我們之前安裝了這兩個(gè)瀏覽器的啟動(dòng)器(launcher)
> 需要測(cè)試的源文件和測(cè)試命令文件放在哪呢?
你可以使用通配符(glob patterns)來(lái)匹配文件,比如:"js/*.js" 或 "test/**/*Spec.js"
輸入空字符串進(jìn)入下一個(gè)問(wèn)題。
>
(這里先留空,可根據(jù)測(cè)試情況靈活配置)
>在符合匹配的文件中有哪些文件可以排除在外呢?
你可以使用通配符來(lái)匹配文件,比如:"**/*.swp"
輸入空字符串進(jìn)入下一個(gè)問(wèn)題。
>
> 你想要Karma根據(jù)文件的變化立即做出響應(yīng)么?
> yes之后,你就會(huì)發(fā)現(xiàn)你的文件夾里多了一個(gè)文件:

打開(kāi)這個(gè)文件,你會(huì)發(fā)現(xiàn)里面是一個(gè)配置項(xiàng)函數(shù):
module.exports = function(config) {
basePath: '', // 根路徑將會(huì)同files和excluede項(xiàng)中的相對(duì)路徑相關(guān)聯(lián)
frameworks: ['jasmine'], // 所使用的測(cè)試框架
files: [], // 這里是需要測(cè)試的文件列表,有多種配置方式
exclude: [], // 測(cè)試過(guò)程中排除在外的文件列表
reporters: ['progress'], // 測(cè)試結(jié)果的匯報(bào)方式,
port: 9876, // web服務(wù)器接口
colors: true, // 是否使用彩色報(bào)告
logLevel: config.LOG_INFO, // 日志級(jí)別,可配置的值有: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
autoWatch: true, // 是否自動(dòng)觀測(cè)文檔改變并執(zhí)行測(cè)試命令
browsers: ["Chrome", "PhantomJS"], // 用哪些瀏覽器測(cè)試呢
singleRun: false, // 持續(xù)集成模式,如果設(shè)置成true,Karma將自行捕獲瀏覽器,運(yùn)行測(cè)試并根據(jù)結(jié)果退出,
concurrency: Infinity // 并發(fā)數(shù),同時(shí)跑多少個(gè)瀏覽器進(jìn)行測(cè)試,默認(rèn)無(wú)上限
}默認(rèn)會(huì)生成的配置項(xiàng)就是上面這些,更完整的配置請(qǐng)點(diǎn)我
這里稍微提一下browsers配置項(xiàng),它可以配置高達(dá)8種瀏覽器:

每一種都需要安裝對(duì)應(yīng)的launcher。其中有兩個(gè)需要注意chromeHeadless和PhantomJS。這兩個(gè)是無(wú)頭瀏覽器。所謂無(wú)頭瀏覽器就是沒(méi)有腦袋的瀏覽器。

無(wú)頭瀏覽器即headless browser,是一種沒(méi)有界面的瀏覽器。既然是瀏覽器那么瀏覽器該有的東西它都應(yīng)該有,只是看不到界面而已。因此這種瀏覽器沒(méi)有渲染UI的過(guò)程,用于測(cè)試時(shí)的速度很快。
這就回答了上文launcher是啥的問(wèn)題。畢竟,沒(méi)有瀏覽器靠腦補(bǔ)可沒(méi)法測(cè)試啊(真實(shí))
言歸正傳。我們回到karma測(cè)試本身。接下來(lái),我們修改一下配置:
files: ["src/srcTest/**/*.js", "test/unit/**/*.js"]注意,上述寫(xiě)法只是配置寫(xiě)法中的一種, 配置的文件位置也是隨您自己指定,更詳細(xì)的配置請(qǐng)點(diǎn)我
采用上文寫(xiě)法的話,我們?cè)趂iles數(shù)組里面配置的第一項(xiàng)是需要測(cè)試的文件,第二項(xiàng)就是用什么方法去測(cè)試它的文件。
因此,我們也在文件里創(chuàng)建對(duì)應(yīng)的文件夾:

這里有一個(gè)要注意的點(diǎn)。我們的需要測(cè)試的文件和測(cè)試驅(qū)動(dòng)文件的名字是一一對(duì)應(yīng)的,區(qū)別就在于測(cè)試驅(qū)動(dòng)文件的名字后要加上.spec
那么我們就在srcTest的文件里面寫(xiě)點(diǎn)什么吧....
newBee.js
// 減法函數(shù)
function minus(x) {
return function(y) {
return x - y;
};
}testKarma.js
// 加法函數(shù)
function add(x) {
return function(y) {
return x + y;
};
}
// 乘法函數(shù)
function multi(x) {
return function(y) {
return x * y;
};
}
//if函數(shù)測(cè)試
function ifTest(boolean) {
if (boolean) {
return "熱熱";
} else {
return "涼涼";
}
}
// 反轉(zhuǎn)字符串
function reverseStr (string) {
return string.split("").reverse().join("");
}
那么接下來(lái),就在.spec文件里寫(xiě)入對(duì)應(yīng)的測(cè)試斷言。我滴個(gè)龜龜,終于說(shuō)到斷言了。
因?yàn)槲覀冞@里使用的是Jasmine,因此就先放一下它的官網(wǎng)。

我們結(jié)合實(shí)例來(lái)說(shuō)文檔
newBee.spec.js
describe("newBee單元測(cè)試", function() {
it("減法函數(shù)測(cè)試", function() {
var minus7 = minus(7);
expect(minus7(6)).toBe(0);
});
});testKarma.spec.js
describe("testKarma單元測(cè)試", function() {
it("如果函數(shù)測(cè)試", function() {
expect(ifTest(true)).toBe(true);
expect(ifTest(false)).toBe("涼涼");
});
it("回文函數(shù)測(cè)試", function() {
expect(reverseStr('abc')).toEqual('cba');
})
});基本的格式就是這樣的,下面來(lái)解釋一下
// 分組describe(), 這個(gè)是可以嵌套的,并且每個(gè)單獨(dú)的測(cè)試都有beforeAll, afterAll, beforeEach和afterEach
describe("這里寫(xiě)測(cè)試群組的名稱", function(){
// 具體的測(cè)試,it(), 當(dāng)其中所有的斷言都為true時(shí),則通過(guò);否則失效。
it('這里寫(xiě)具體測(cè)試的名稱', function(){
var a = true;
// 期望, expect()。匹配,to*()
// 每個(gè)匹配方法在期望值和實(shí)際值之間執(zhí)行邏輯比較
// 它負(fù)責(zé)告訴jasmine斷言的真假,從而決定測(cè)試的成功或失敗
// 木有錯(cuò),老子/老娘說(shuō)啥就是啥
expect(a).toBe(true); // 這是肯定斷言
expect(!a).not.toBe(true); // 這是否定斷言
// jasmine內(nèi)置的匹配方法有很多,亦可自定義匹配方法
// toBe()
// toEqual()
// toMatch()
// toBeUndefined()
// toBeNull()
// toBeTruthy()
// toContain()
// toBeLessThan()
// toBeCloseTo()
// toThrowError()
// 等等等等
})
})那么,測(cè)試方法寫(xiě)完了,我們來(lái)實(shí)際運(yùn)行一下測(cè)試吧。打開(kāi)終端,輸入:
karma start就會(huì)在終端看到

可以看到,我們的測(cè)試在Chrome和PhantomJS瀏覽器中分別測(cè)試了的5個(gè)方法,都有2個(gè)沒(méi)有通過(guò)測(cè)試,沒(méi)錯(cuò),我們當(dāng)初在寫(xiě)測(cè)試的時(shí)候故意寫(xiě)錯(cuò)了(這是真的)。
那么我們把測(cè)試修改成真值。
newBee.spec.js
describe("newBee單元測(cè)試", function() {
it("減法函數(shù)測(cè)試", function() {
var minus7 = minus(7);
expect(minus7(6)).toBe(1);
});
});testKarma.spec.js
it("如果函數(shù)測(cè)試", function() {
expect(ifTest(true)).toBe("熱熱");
expect(ifTest(false)).toBe("涼涼");
});結(jié)果是:

全部SUCCESS, 撒花。
到這里,一個(gè)基本的測(cè)試流程就走完了。然而,這并非終點(diǎn)。
其實(shí),還能更進(jìn)一步的。我們打開(kāi)終端:
npm install karma-coverage --save-dev然后打開(kāi)karma.conf.js, 添加一些配置項(xiàng)
// 這里配置哪些文件需要統(tǒng)計(jì)測(cè)試覆蓋率,例如,如果你的所有代碼文件都在src文件夾中,你就需要如下配置
preprocessors: {
"src/srcTest/*.js": "coverage"
},
// 新增coverageReporter選項(xiàng)
// 配置覆蓋率報(bào)告的查看方式,type查看類型,可以取值html、text等等,dir輸出目錄
coverageReporter: {
dir: "docs/unit",
reporters: [
{
type: "html",
subdir: "report-html"
}
]
},
reporters: ['progress', "coverage"] // 沒(méi)錯(cuò),reporters里面新增了一個(gè)coverage然后保存,再運(yùn)行一次karma start
接著會(huì)發(fā)現(xiàn)你的項(xiàng)目里多了一個(gè)文件夾

用瀏覽器打開(kāi)index.html。就會(huì)看到

這就是你所寫(xiě)的js的測(cè)試覆蓋率。
這樣看起來(lái)是不是高大上了一些呢?
這里就有一個(gè)問(wèn)題了。普通的js可以測(cè)試,可是我是寫(xiě)Vue的啊,Vue組件怎么測(cè)試呢?很簡(jiǎn)單,Vue官網(wǎng)有非常詳細(xì)的測(cè)試教程。甚至還有專用的測(cè)試工具和測(cè)試說(shuō)明

彳亍口巴,你說(shuō)的這些個(gè)單元測(cè)試看起來(lái)花里胡哨的,實(shí)際作用是什么呢?
單元測(cè)試的好處
單元測(cè)試不但會(huì)使你的工作完成得更輕松。而且會(huì)令你的設(shè)計(jì)會(huì)變得更好,甚至大大減少你花在調(diào)試上面的時(shí)間。
提高代碼質(zhì)量
減少bug, 快速定位bug
使修改和重構(gòu)可以更放心
顯得專業(yè)
單元測(cè)試的缺點(diǎn)
開(kāi)發(fā)人員要花費(fèi)時(shí)間在寫(xiě)測(cè)試代碼上,然而又不會(huì)給你加工資...
小項(xiàng)目寫(xiě)測(cè)試只能單純的增加開(kāi)發(fā)時(shí)間和成本,然而又不會(huì)給你加工資...
我寫(xiě)了測(cè)試除了懂測(cè)試的人能看懂,別人又不知道,然而還不會(huì)給你加工資...

別別別,別打我...你先聽(tīng)我道(hu)理(jiao)講(man)完(chan)。
對(duì)于所編寫(xiě)的代碼,你在調(diào)試上面畫(huà)了多少時(shí)間?
對(duì)于以前你自認(rèn)為正確的代碼,而實(shí)際上這些代碼卻存在重大的bug,你花了多少時(shí)間在重新確認(rèn)這些代碼上面?
對(duì)于一個(gè)別人報(bào)告的bug,你花了多少時(shí)間才找出導(dǎo)致這個(gè)bug的源碼位置?
對(duì)于那些沒(méi)有使用單元測(cè)試的程序員而言,上面這些問(wèn)題所耗費(fèi)的時(shí)間的是逐漸增加的,而且項(xiàng)目越深入,花費(fèi)的時(shí)間越多;另一方面,適當(dāng)?shù)膯卧獪y(cè)試卻可以很大程度地減少這些時(shí)間,從而為你騰出足夠的時(shí)間來(lái)編寫(xiě)所有的單元測(cè)試——甚至可能還有剩余的空閑時(shí)間。
更加真實(shí)的是,主流的框架必須要寫(xiě)測(cè)試
不想當(dāng)程序員的設(shè)計(jì)師不是好運(yùn)維。----魯迅
作為一個(gè)程序員,如果你想要讓自己寫(xiě)的框架放到github和npm上能夠?yàn)槭澜缟系钠渌怂?。那么一個(gè)最基本的前提就是————代碼沒(méi)有BUG??墒?,你的怎么向語(yǔ)言不通思維不同的人解釋你的JavaScript庫(kù)確實(shí)足夠健壯呢。這個(gè)時(shí)候就需要單元測(cè)試出場(chǎng)了。
主流前端框架雖然在所使用的測(cè)試庫(kù)(karma、jest、QUnit)和斷言庫(kù)(assert、jasmine、 chai)上略有差別,但Vue、React、Angular、Underscore甚至是jQuery都寫(xiě)了單元測(cè)試。
來(lái)個(gè)石錘

下面我們看一看Vue的測(cè)試是怎么寫(xiě)的:
git clone https://github.com/vuejs/vue.git
npm install
npm run test unit // 這里可以看到單元測(cè)試
npm run test // 這里就看全部的測(cè)試Vue的測(cè)試覆蓋率為

舉例:v-show的測(cè)試
// import Vue from 'vue'
describe('Directive v-show', () => {
it('should check show value is truthy', () => {
const vm = new Vue({
template: '<div><span v-show="foo">hello</span></div>',
data: { foo: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('')
})
it('should check show value is falsy', () => {
const vm = new Vue({
template: '<div><span v-show="foo">hello</span></div>',
data: { foo: false }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('none')
})
it('should update show value changed', done => {
const vm = new Vue({
template: '<div><span v-show="foo">hello</span></div>',
data: { foo: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = false
waitForUpdate(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = {}
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = 0
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = []
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = null
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = '0'
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.foo = undefined
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = 1
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('')
}).then(done)
})
it('should respect display value in style attribute', done => {
const vm = new Vue({
template: '<div><span v-show="foo" style="display:block">hello</span></div>',
data: { foo: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('block')
vm.foo = false
waitForUpdate(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
vm.foo = true
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('block')
}).then(done)
})
it('should support unbind when reused', done => {
const vm = new Vue({
template:
'<div v-if="tester"><span v-show="false"></span></div>' +
'<div v-else><span @click="tester=!tester">show</span></div>',
data: { tester: true }
}).$mount()
expect(vm.$el.firstChild.style.display).toBe('none')
vm.tester = false
waitForUpdate(() => {
expect(vm.$el.firstChild.style.display).toBe('')
vm.tester = true
}).then(() => {
expect(vm.$el.firstChild.style.display).toBe('none')
}).then(done)
})
})只要你的測(cè)試覆蓋率足夠高,你就可以在著名的GitHub裝逼網(wǎng)站Codecov搞一個(gè)覆蓋率標(biāo)簽了。就像下面這個(gè):

怎么樣,這樣你所寫(xiě)的框架,是不是就逼格滿滿?

所以你還在等什么,測(cè)不了吃虧,測(cè)不了上當(dāng),趕緊在自己的代碼中加入測(cè)試吧,~~只要998~~,代碼逼格帶回家!
1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的「點(diǎn)贊,在看」是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)
程序員成長(zhǎng)指北,回復(fù)「1」加入高級(jí)前端交流群!「在這里有好多 前端 開(kāi)發(fā)者,會(huì)討論 前端 Node 知識(shí),互相學(xué)習(xí)」!3.也可添加微信【ikoala520】,一起成長(zhǎng)。
“在看轉(zhuǎn)發(fā)”是最大的支持
