前端與單元測試
先來幾個專業詞彙,這樣顯得高大上一點(不存在的=。=)
BDD : Behavior-Driven Development (行為驅動開發)
TDD : Test-Driven Development (測試驅動開發)
ATDD : Acceptance Test Driven Development(驗收測試驅動開發)
好,說完了,然後我們廢話不多說,直接進入正題。我會從多個測試框架入手,結合各種 斷言庫 ,用程式碼方式說明。
單元測試(Unit Testing) ,是指對軟體中的最小可測試單元進行檢查和驗證。
當今所有著名的框架都要進行單元測試,經過測試的框架,它的信任度顯然高於未測試的框架。
這裡,我們介紹一下 karma 這個前端的單元測試框架。

首先我們來安裝一波:
新建一個 空 資料夾,然後在空資料夾中 開啟終端 輸入
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這個倒是說得過去,可是這個 jasmine 是啥,這個 chrome-launcher 和 phantomjs-launcher 又是啥?
沒錯,單說測試框架是不完整的,必須要有斷言庫與之相配合,這裡的 jasmine 就是斷言庫。

啥是 斷言(assert) ?
根據概念:
斷言是程式設計術語,表示為一些布林表示式,程式設計師相信在程式中的某個特定點該表示式值為真,可以在任何時候啟用和禁用斷言驗證,因此可以在測試時啟用斷言而在部署時禁用斷言。
一言以蔽之, 老子/老孃說啥就是啥! 聽起來好像挺霸道的。那麼具體呢?
順著 karma 的正常流程向下走,我們來寫一個簡單的單元測試。在終端輸入:
karma init
你會發現,需要做一個調查問卷了,問題如下:
> 請問你要用哪種測試框架呢? > 按tab鍵選擇,按回車鍵進入下一個問題。 > jasmine (因為我們安裝的是jasmine,選什麼斷言庫都別忘了安裝一下) > 您想要使用Require.js麼? > 選擇yes的話,會安裝Require.js外掛。 > 按tab鍵選擇,按回車鍵進入下一個問題。 > no (這裡我們選擇no) > 你想要在什麼瀏覽器中測試呢? > 按tab鍵選擇,輸入空字串進入下一個問題。 > Chrome > PhantomJS > 注:上面的選擇這兩個瀏覽器的原因是我們之前安裝了這兩個瀏覽器的啟動器(launcher) > 需要測試的原始檔和測試命令檔案放在哪呢? 你可以使用萬用字元(glob patterns)來匹配檔案,比如:"js/*.js" 或 "test/**/*Spec.js" 輸入空字串進入下一個問題。 > (這裡先留空,可根據測試情況靈活配置) >在符合匹配的檔案中有哪些檔案可以排除在外呢? 你可以使用萬用字元來匹配檔案,比如:"**/*.swp" 輸入空字串進入下一個問題。 > > 你想要Karma根據檔案的變化立即做出響應麼? > yes
之後,你就會發現你的資料夾裡多了一個檔案:

開啟這個檔案,你會發現裡面是一個配置項函式:
module.exports = function(config) { basePath: '', // 根路徑將會同files和excluede項中的相對路徑相關聯 frameworks: ['jasmine'], // 所使用的測試框架 files: [], // 這裡是需要測試的檔案列表,有多種配置方式 exclude: [], // 測試過程中排除在外的檔案列表 reporters: ['progress'], // 測試結果的彙報方式, port: 9876, // web伺服器介面 colors: true, // 是否使用彩色報告 logLevel: config.LOG_INFO, // 日誌級別,可配置的值有: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG autoWatch: true, // 是否自動觀測文件改變並執行測試命令 browsers: ["Chrome", "PhantomJS"], // 用哪些瀏覽器測試呢 singleRun: false, // 持續整合模式,如果設定成true,Karma將自行捕獲瀏覽器,執行測試並根據結果退出, concurrency: Infinity // 併發數,同時跑多少個瀏覽器進行測試,預設無上限 }
預設會生成的配置項就是上面這些,更完整的配置請點我
這裡稍微提一下browsers配置項,它可以配置高達8種瀏覽器:

每一種都需要安裝對應的 launcher 。其中有兩個需要注意 chromeHeadless 和 PhantomJS 。這兩個是無頭瀏覽器。 所謂無頭瀏覽器就是沒有腦袋的瀏覽器 。

無頭瀏覽器即headless browser,是一種沒有介面的瀏覽器。既然是瀏覽器那麼瀏覽器該有的東西它都應該有,只是看不到介面而已。因此這種瀏覽器沒有渲染UI的過程,用於測試時的速度很快。
這就回答了上文launcher是啥的問題。畢竟,沒有瀏覽器靠腦補可沒法測試啊(真實)
言歸正傳。我們回到karma測試本身。接下來,我們修改一下配置:
files: ["src/srcTest/**/*.js", "test/unit/**/*.js"]
注意,上述寫法只是配置寫法中的一種, 配置的檔案位置也是隨您自己指定,更詳細的配置請點我
採用上文寫法的話,我們在files數組裡面配置的第一項是 需要測試的檔案 ,第二項就是 用什麼方法去測試它的檔案 。
因此,我們也在檔案裡建立對應的資料夾:

這裡有一個要注意的點。我們的需要測試的檔案和測試驅動檔案的名字是 一一對應的 ,區別就在於測試驅動檔案的名字後要加上 .spec
那麼我們就在srcTest的檔案裡面寫點什麼吧....
newBee.js
// 減法函式 function minus(x) { return function(y) { return x - y; }; }
testKarma.js
// 加法函式 function add(x) { return function(y) { return x + y; }; } // 乘法函式 function multi(x) { return function(y) { return x * y; }; } //if函式測試 function ifTest(boolean) { if (boolean) { return "熱熱"; } else { return "涼涼"; } } // 反轉字串 function reverseStr (string) { return string.split("").reverse().join(""); }

那麼接下來,就在.spec檔案裡寫入對應的 測試斷言 。我滴個龜龜,終於說到斷言了。
因為我們這裡使用的是Jasmine,因此就先放一下它的官網。

我們結合例項來說文件
newBee.spec.js
describe("newBee單元測試", function() { it("減法函式測試", function() { var minus7 = minus(7); expect(minus7(6)).toBe(0); }); });
testKarma.spec.js
describe("testKarma單元測試", function() { it("如果函式測試", function() { expect(ifTest(true)).toBe(true); expect(ifTest(false)).toBe("涼涼"); }); it("迴文函式測試", function() { expect(reverseStr('abc')).toEqual('cba'); }) });
基本的格式就是這樣的,下面來解釋一下
// 分組describe(), 這個是可以巢狀的,並且每個單獨的測試都有beforeAll, afterAll, beforeEach和afterEach describe("這裡寫測試群組的名稱", function(){ // 具體的測試,it(), 當其中所有的斷言都為true時,則通過;否則失效。 it('這裡寫具體測試的名稱', function(){ var a = true; // 期望, expect()。 匹配,to*() // 每個匹配方法在期望值和實際值之間執行邏輯比較 // 它負責告訴jasmine斷言的真假,從而決定測試的成功或失敗 // 木有錯,老子/老孃說啥就是啥 expect(a).toBe(true); // 這是肯定斷言 expect(!a).not.toBe(true); // 這是否定斷言 // jasmine內建的匹配方法有很多,亦可自定義匹配方法 // toBe() // toEqual() // toMatch() // toBeUndefined() // toBeNull() // toBeTruthy() // toContain() // toBeLessThan() // toBeCloseTo() // toThrowError() // 等等等等 }) })
那麼,測試方法寫完了,我們來實際執行一下測試吧。開啟終端,輸入:
karma start
就會在終端看到

可以看到,我們的測試在Chrome和PhantomJS瀏覽器中分別測試了的5個方法,都有2個沒有通過測試,沒錯,我們當初在寫測試的時候 故意 寫錯了(這是真的)。
那麼我們把測試修改成真值。
newBee.spec.js
describe("newBee單元測試", function() { it("減法函式測試", function() { var minus7 = minus(7); expect(minus7(6)).toBe(1); }); });
testKarma.spec.js
it("如果函式測試", function() { expect(ifTest(true)).toBe("熱熱"); expect(ifTest(false)).toBe("涼涼"); });
結果是:

全部SUCCESS, 撒花。
到這裡,一個基本的測試流程就走完了。然而,這並非終點。
其實,還能更進一步的。我們開啟終端:
npm install karma-coverage --save-dev
然後開啟karma.conf.js, 新增一些配置項
// 這裡配置哪些檔案需要統計測試覆蓋率,例如,如果你的所有程式碼檔案都在src資料夾中,你就需要如下配置 preprocessors: { "src/srcTest/*.js": "coverage" }, // 新增coverageReporter選項 // 配置覆蓋率報告的檢視方式,type檢視型別,可以取值html、text等等,dir輸出目錄 coverageReporter: { dir: "docs/unit", reporters: [ { type: "html", subdir: "report-html" } ] }, reporters: ['progress', "coverage"] // 沒錯,reporters裡面新增了一個coverage
然後儲存,再執行一次karma start
接著會發現你的專案裡多了一個資料夾

用瀏覽器開啟index.html。就會看到

這就是你所寫的js的測試覆蓋率。
這樣看起來是不是高大上了一些呢?
這裡就有一個問題了。普通的js可以測試,可是我是寫Vue的啊,Vue元件怎麼測試呢?很簡單,Vue官網有非常詳細的測試教程。甚至還有專用的測試工具和 測試說明

彳亍口巴,你說的這些個單元測試看起來花裡胡哨的,實際作用是什麼呢?
單元測試的好處
- 單元測試不但會使你的工作完成得更輕鬆。而且會令你的設計會變得更好,甚至大大減少你花在除錯上面的時間。
- 提高程式碼質量
- 減少bug, 快速定位bug
- 使修改和重構可以更放心
- 顯得專業
單元測試的缺點
開發人員要花費時間在寫測試程式碼上,然而又不會給你加工資...
小專案寫測試只能單純的增加開發時間和成本,然而又不會給你加工資...
我寫了測試除了懂測試的人能看懂,別人又不知道,然而還不會給你加工資...

別別別,別打我...你先聽我道(hu)理(jiao)講(man)完(chan)。
- 對於所編寫的程式碼,你在除錯上面畫了多少時間?
- 對於以前你自認為正確的程式碼,而實際上這些程式碼卻存在重大的bug,你花了多少時間在重新確認這些程式碼上面?
- 對於一個別人報告的bug,你花了多少時間才找出導致這個bug的原始碼位置?
對於那些沒有使用單元測試的程式設計師而言,上面這些問題所耗費的時間的是逐漸增加的,而且專案越深入,花費的時間越多;另一方面,適當的單元測試卻可以很大程度地減少這些時間,從而為你騰出足夠的時間來編寫所有的單元測試——甚至可能還有剩餘的空閒時間。
更加真實的是,主流的框架 必須 要寫測試
不想當程式設計師的設計師不是好運維。 ----魯迅
作為一個程式設計師,如果你想要讓自己寫的框架放到github和npm上能夠為世界上的其他人所用。那麼一個最基本的前提就是————程式碼沒有BUG。可是,你的怎麼向語言不通思維不同的人解釋你的JavaScript庫確實足夠健壯呢。這個時候就需要單元測試出場了。
主流前端框架雖然在所使用的測試庫(karma、jest、QUnit)和斷言庫(assert、jasmine、 chai)上略有差別,但Vue、React、Angular、Underscore甚至是jQuery都寫了單元測試。
來個石錘

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

舉例:v-show的測試
// 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) }) })
只要你的測試覆蓋率足夠高,你就可以在著名的GitHub裝逼網站Codecov搞一個覆蓋率標籤了。就像下面這個:

怎麼樣,這樣你所寫的框架,是不是就逼格滿滿?

所以你還在等什麼,測不了吃虧,測不了上當,趕緊在自己的程式碼中加入測試吧,~~只要998~~,程式碼逼格帶回家!