作者:江敏熙 貝聊前端開發工程師
本文同時釋出於個人部落格
為什麼要單元測試?
專案的現狀
當前我在公司裡負責的專案,可以分為兩類:
-
一類是相似度很高的專案,比如管理後臺,這類專案的頁面通過各種公共元件搭建而成。公共元件的複用性很高,所以質量尤為重要。如果開發人員在修改了公共元件之後留下了bug,那麼將會直接降低了整個專案的質量。我希望讓程式去測試這些公共元件,保證每一個公共元件是可用的。
-
另一類是公司的核心專案,這些專案特點是維護週期長,並且會不斷加入新的功能。在專案版本迭代的過程中,當一些原來通過了測試的舊功能發生了bug,一般只能到了測試階段才能被測試人員發現。我希望由程式去保證部分核心功能的正常運作,當核心功能發生了bug能快速的察覺到,而不是到了測試階段才發現。
為了解決上面的問題,我嘗試引入單元測試。
單元測試的作用
-
降低bug發生機率,快速定位bug,減少重複的手工測試。
-
提高程式碼質量,為專案帶來更高的程式碼可維護性。
-
方便專案的交接工作,測試指令碼就是最好的需求描述。
接下來談談如何進行單元測試。
搭建測試框架
測試工具一覽
Mocha
Mocha(發音"摩卡")誕生於2011年,是現在最流行的JavaScript測試框架之一,在瀏覽器和Node環境都可以使用。
Karma
Karma是由Google團隊開發的一個測試工具, 它不是一個測試框架, 只是一個跑測試的驅動. 你可以通過karma的配置檔案整合你喜歡的框架, 斷言庫和瀏覽器.
Vue Test Utils
Vue的官方的單元測試框架,它提供了一系列非常方便的工具,使我們可以更輕鬆地為Vue應用編寫單元測試。主流的 JavaScript 測試執行器有很多,但 Vue Test Utils 都能夠支援。它是測試執行器無關的。
Chai斷言庫
Chai是一個斷言庫,用於Node和瀏覽器,它可以與任何JavaScript測試框架相結合
搭建方法:
本文選擇的測試框架由Karma + Mocha + Chai + Vue Test Utils搭配,自己手動配置過程比較繁瑣,在這裡強烈推薦大家使用vue-cli,vue-cli有現成的模板可以生成專案,執行vue init webpack [專案名],'Pick a test runner'時選擇'Karma + Mocha' 。vue-cli會自動生成Karma + Mocha + Chai的配置,我們只需要額外安裝Vue Test Utils,執行npm install @vue/test-utils。
如果想自己動手配置的同學,可以參考這篇文章。
配置完成以後,下圖是專案目錄結構:
test資料夾下是unit資料夾,裡面放的是單元測試相關的檔案。
specs裡存放的是測試指令碼,這部分是由開發人員編寫的。
coverage資料夾裡存放的是測試報告,開啟裡面的index.html可以直觀地看到測試的程式碼覆蓋率。
Karma.conf.js是karma的配置檔案。
怎樣寫單元測試
舉個例子
被測試的元件HelloWorld.vue(path:E:\study\demo\src\components)
程式碼如下:
<template>
<div class="hello">
<h1>Welcome to Your Vue.js App</h1>
</div>
</template>
複製程式碼
測試指令碼HelloWorld.spec.js(path:E:\study\demo\test\unit\specs)
程式碼如下:
import HelloWorld from '@/components/HelloWorld';
import { mount, createLocalVue, shallowMount } from '@vue/test-utils'
describe('HelloWorld.vue', () => {
it('should render correct contents', () => {
const wrapper = shallowMount(HelloWorld);
let content = wrapper.vm.$el.querySelector('.hello h1').textContent;
expect(content).to.equal('Welcome to Your Vue.js App');
});
});
複製程式碼
1.測試指令碼的寫法
describe是"測試套件"(test suite),表示一組相關的測試。它是一個函式,第一個引數是測試套件的名稱("加法函式的測試"),第二個引數是一個實際執行的函式。
it是"測試用例"(test case),表示一個單獨的測試,是測試的最小單位。它也是一個函式,第一個引數是測試用例的名稱,第二個引數是一個實際執行的函式。
2.斷言庫的用法
上面的測試指令碼里面,有一句斷言:
expect(content).to.equal('Welcome to Your Vue.js App');
複製程式碼
所謂"斷言",就是判斷原始碼的實際執行結果與預期結果是否一致,如果不一致就丟擲一個錯誤。上面這句斷言的意思是,變數content應等於'Welcome to Your Vue.js App'。
所有的測試用例(it塊)都應該含有一句或多句的斷言。它是編寫測試用例的關鍵。
3.檢視測試結果
最後執行一下npm run unit,來看結果:
結果顯示測試通過。開啟coverage下的index.vue檢視程式碼覆蓋率:
因為這是一個剛新建的專案,程式碼非常簡單,所以覆蓋率是100%。程式碼覆蓋率是一個客觀的資料,不能完全真實表示專案的測試情況,但是具有不錯的參考價值。在多人開發的團隊中,覆蓋率可以作為一個硬性的標準。
這就是一個簡單的單元測試編寫過程,是不是很簡單呢?大家都動手自己試試吧。
友情提示
1.用createLocalVue安裝外掛
我們在給實際專案寫單元測試的時候,專案程式碼會比上面的demo元件複雜很多。如果你要測試的單個元件裡使用了vue-router或者Vuex的話,就要使用createLocalVue。 比如,有這樣一段程式碼:
data() {
return {
brandId: this.$route.query.id,
}
}
複製程式碼
$route物件需要用createLocalVue注入router才能使用,否則執行測試指令碼會出錯。使用createLocalVue解決這個問題,具體程式碼:
import { shallowMount, createLocalVue } from '@vue/test-utils'
import VueRouter from 'vue-router'
const localVue = createLocalVue()
localVue.use(VueRouter)
const router = new VueRouter()
shallowMount(Component, {
localVue,
router
})
複製程式碼
Vuex也是同理,關於createLocalVue詳細用法就不做贅述了,大家可以去翻閱官方文件。
2.nextTick怎麼辦
如果你需要在自己的測試檔案中使用 nextTick,注意任何在其內部被丟擲的錯誤可能都不會被測試執行器捕獲,因為其內部使用了 Promise。關於這個問題有兩個建議:要麼你可以在測試的一開始將 Vue 的全域性錯誤處理器設定為 done 回撥,要麼你可以在呼叫 nextTick 時不帶引數讓其作為一個 Promise 返回:
// 這不會被捕獲
it('will time out', (done) => {
Vue.nextTick(() => {
expect(true).toBe(false)
done()
})
})
// 接下來的兩項測試都會如預期工作
it('will catch the error using done', (done) => {
Vue.config.errorHandler = done
Vue.nextTick(() => {
expect(true).toBe(false)
done()
})
})
it('will catch the error using a promise', () => {
return Vue.nextTick()
.then(function () {
expect(true).toBe(false)
})
})
複製程式碼
在下面的專案實戰中,有使用到nextTick的例子,大家可以當做參考。
3. 修改預設測試瀏覽器
測試在配置檔案karma.conf裡,browsers預設是'PhantomJS'.
module.exports = function karmaConfig (config) {
config.set({
// browsers: ['PhantomJS'],
browsers: ['Chrome'],
複製程式碼
但我在使用過程中發現PhantomJS環境的warning和error提示和平時在瀏覽器chrome看到的提示不太一樣,有點難懂,如圖:
Chrome:
PhantomJS:browsers設定為'Chrome',得到的報錯提示和真實Chrome瀏覽器上一致,並且可以使用console.log(),除錯起來和真實開發的體驗一樣。唯一缺點是每次執行npm run unit都會彈出一個Chrome瀏覽器,PhantomJS則不會,推薦大家除錯測試指令碼時候使用Chrome,等指令碼都跑通了不需要除錯的時候可以換回PhantomJS。
4. 加上--auto-watch
預設下auto-watch是關閉的,每次修改了測試指令碼,或者修改了專案程式碼之後都需要手動執行一次命令才能啟動測試,非常麻煩。我們可以加上--auto-watch,這樣在開發的過程中,如果某個功能沒有通過測試用例,開發人員可以立刻發現並修復。
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --auto-watch",
複製程式碼
專案實戰
例項1
場景:頁面上有一個textarea輸入框和提交按鈕,點選按鈕傳送請求。要求點選提交後前端先校驗一下內容是否符合json格式,如果不符合則提示不能提交。
測試的目標:校驗程式
測試用例:通過條件覆蓋,輸入數字,字串,錯誤的json字串,'null',正確的json字串去驗證所有的情況是否正常執行,期望只有最後一種情況才是返回結果才是通過的,其他都是不通過。
// form-setting.vue測試校驗功能
describe('form-setting.vue測試校驗功能', () => {
const wrapper = shallowMount(formSetting, {
localVue
});
let vm = wrapper.vm;
it('test form填入數字是否會不通過', () => {
vm.appType = 'ios'; // 選擇系統ios
vm.ios.schemeInfo = 1; // 輸入數字
expect(vm.isValid()).to.equal(false);
});
it('test form填入字串格式是否會不通過', () => {
vm.appType = 'ios';
vm.ios.schemeInfo = '1'; // 輸入字串
expect(vm.isValid()).to.equal(false);
});
it('test form填入錯誤json格式是否會不通過', () => {
vm.appType = 'ios';
vm.ios.schemeInfo = '{a:{a:}}'; // 輸入非法的類似json格式的字串
expect(vm.isValid()).to.equal(false);
});
it('test form填入空物件是否會不通過', () => {
vm.appType = 'ios';
vm.ios.schemeInfo = 'null'; // 輸入null物件字串
expect(vm.isValid()).to.equal(false);
});
it('test form填入正確JSON格式是否會通過', () => {
vm.appType = 'ios';
vm.ios.schemeInfo = '{"a": 111}'; // 輸入正確的json字串
expect(vm.isValid()).to.equal(true);
});
});
複製程式碼
例項2
場景:團隊開發了一個校驗外掛,其作用是校驗輸入框是否滿足相應規則,若不滿足在輸入框下會出現一個提示錯誤的dom節點。
測試用例:通過列舉所有的輸入操作,然後判斷是否存在類名為.error的錯誤提示節點。
在完成輸入操作後,如果內容不通過校驗,頁面會生成錯誤提示的dom節點。這個過程是非同步的,所以用到了nextTick。具體的用法是
return Vue.nextTick().then(() => {
...斷言
}
複製程式碼
關於這塊詳細的解釋,Vue Test Utils有相關篇幅
import { mount, createLocalVue } from '@vue/test-utils'
import ValidateDemo from '@/components/validate-demo'
import validate from '@/directive/validate/1.0/validate'
import Vue from 'Vue'
const localVue = createLocalVue() // 建立一個Vue例項
localVue.use(validate) // 掛載校驗外掛
describe('測試validate-demo.vue', () => {
it('沒發生輸入操作,[不顯示error]', () => {
const wrapper = mount(ValidateDemo, {
localVue
})
return Vue.nextTick().then(() => {
expect(wrapper.find('.error').exists()).to.equal(false)
})
})
it('聚焦輸入框然後失去焦點,[顯示error]', () => {
const wrapper = mount(ValidateDemo, {
localVue
})
let input = wrapper.find('input')
input.trigger('focus') // 聚焦
input.trigger('blur') // 失去焦點
return Vue.nextTick().then(() => {
expect(wrapper.find('.error').exists()).to.equal(true)
})
})
it('發生輸入操作,然後清空,[顯示error]', () => {
const wrapper = mount(ValidateDemo, {
localVue
})
let vm = wrapper.vm
let input = wrapper.find('input')
input.trigger('focus')
vm.name = '不為空'
vm.name = '' // 清空
input.trigger('blur')
return Vue.nextTick().then(() => {
expect(wrapper.find('.error').exists()).to.equal(true)
})
})
it('輸入內容後,[不顯示error]', () => {
const wrapper = mount(ValidateDemo, {
localVue
})
let vm = wrapper.vm
vm.name = '不為空' // 輸入內容
return Vue.nextTick().then(() => {
expect(wrapper.find('.error').exists()).to.equal(false)
})
})
})
複製程式碼
單元測試的侷限性
單元測試有許多優點,但不代表它就一定適合每個專案,在我看來它會有以下侷限性:
1.額外的時間花費
即使你願意花費開發的幾分之一的時間去寫單元測試,但是一旦功能有變更,就意味著測試邏輯也需要調整。對於一些經常變更的功能來說,這會導致很大的單元測試維護量。 所以我們要權衡好當中的利弊,可以考慮只針對穩定的功能(比如一些公用元件)和核心流程編寫單元測試。
2.並非全部程式碼都能單元測試
如果專案裡充斥著顆粒度低,方法間互相耦合的程式碼,你會發現無法進行單元測試。因為單元測試旨在從程式碼粒度上實現對應用質量的把握。面對這樣的情況,要麼重構已有程式碼,要麼放棄單元測試尋求其他測試方法,比如人工測試,e2e測試。
雖然這算是單元測試的一個缺點,但我認為同時也是優點,習慣編寫單元測試可以促使工程師提高程式碼的顆粒度,思維更加縝密。
3.無法保證一整個流程的運作
前端是一個非常複雜的測試環境,因為每個瀏覽器都有差異,需要的資料又依賴於後端。單元測試只能對功能每一個單元進行測試,對於一些依賴api的資料一般只能mock,無法真正的模擬使用者實際的使用場景。對於這種情況,建議採用其他測試方法,比如人工測試、e2e測試。
總結
通過這次對單元測試的探索,我覺得做單元測試最大的阻力是——時間。
手工測試最大的優勢在於:當一個功能程式碼寫好以後,只需要手動重新整理瀏覽器去實際操作一下,便能判斷程式是否正確。如果為此去編寫單元測試則會花費額外的開發時間。
但人不是機器,無論多麼簡單的事都有可能出錯。我們為系統加入了新功能的之後,一般不會去手動測試以前的舊功能。因為這耗費時間而又無趣,並且我們總會認為自己寫的程式碼是不會影響舊功能的。
然而我們可以換個角度去想,如果在開發舊功能的時候寫好了相應的單元測試,那麼每次進入測試階段之前,就可以用測試指令碼把舊功能都跑一遍。這樣既節省了測試舊功能的時間,自己也可以心安理得:無論怎麼樣,我都能確保我寫的程式碼是通過測試的。
最後,感謝大家的閱讀,本文是我關於對一個Vue專案做的比較淺顯的單元測試的探索,屬於拋磚引玉,如果有什麼不合理的地方或建議,歡迎大家來指正!