介紹如何在vue這個前端框架寫單元測試,還有一些前端測試的相關名詞。
前言
其實筆者本身也是前端測試的小白,但是為了給團隊帶入前端的測試,先後研究了ui test和unit test,最後感覺上還是把心力花在unit test上面會比較符合效益,單元測試的議題也很大,包含了副作用隔離的部份,而且相關文章參考量也很少,這篇主要想先講一下怎麼寫單元測試,而單元測試能帶來的好處也非常多,除了反覆測試我們的邏輯,以免我們遺漏了每個測試,最重要的還是重構的議題上面,畢竟我們今天都已經寫javascript了,在型別的處理上就簡單方便許多,那把這些功夫花來處理單元測試上面,對以後的維護和修改或驗證測試,都是方便許多了,如果團隊走的是tdd開發的話,那測試代碼即是文件,如果不是走tdd的話,至少為了複雜一點的邏輯加上單元測試,這樣子絕對能幫助到以後的自己或接手維護的人。
導覽
前端測試工具其實非常多種,這邊也只會針對vue cli整合的部份來做說明,而且是只針對單元測試的部份,vue有使用到屬於單元測試會使用到的package有下面幾種
mocha:也就是一套測試框架,可以寫在node.js也可以寫在所有js裡面,定義方式挺像rails的方式,也有提供一些斷言(assert)的api,當然我們還有其他選擇可以使用,比如cucumber.js或jasmine等等,前往此察看(https://mochajs.org/)
chai:主要也是用在斷言的部份,提供了更多種使用說人話的方式來斷言,提供的api非常的多,有興趣者可以自行前往去查看(http://chaijs.com/)
sinon:提供了非常多種方式來替換依賴,比如我們要使用ajax的話,如果我們想替換掉的話,就可以使用sinon,有一些相關名詞需要了解,比如spies,stub,mock,fake等等,如果你對後端測試理解的話,stub,mock,fake的名詞想必就不陌生了,有興趣請前往官方查看(http://sinonjs.org/releases/v2.3.8/)
sinon-chai:擴展了chai與sinon的部份,請前往此察看(https://github.com/domenic/sinon-chai)
karma:提供了瀏覽器的測試環境,也就是test runner的意思,主要就是給前端框架用的話,如果我們今天寫的是node.js的話,就不需要這個了。
我們先從package.json看起,當我們要執行單元測試的時候,需要在cmd下npm run unit,所以我們看一下unit的部份
"unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run",
很明顯的,會直接去執行karma.conf.js,那接著我們就來看一下karma.conf有些什麼東西
// This is a karma config file. For more details see
// http://karma-runner.github.io/0.13/config/configuration-file.html
// we are also using it with karma-webpack
// https://github.com/webpack/karma-webpack
// 吃進webpack.test設定的部份,這代表了我們可以在webpackConfig設定一些東西讓測試腳本使用
var webpackConfig = require('../../build/webpack.test.conf')
module.exports = function (config) {
config.set({
// to run in additional browsers:
// 1. install corresponding karma launcher
// http://karma-runner.github.io/0.13/config/browsers.html
// 2. add it to the `browsers` array below.
browsers: ['PhantomJS'], // 這裡可以替換成使用chrome或firefox等等,這邊我保持不變,依照官方的
frameworks: ['mocha', 'sinon-chai', 'phantomjs-shim'], // 使用到的framework
reporters: ['spec', 'coverage'], // 產出報表的部份
files: ['./index.js'], // 吃進同目錄下的index.js
preprocessors: { // 執行測試前先預處理什麼東西
'./index.js': ['webpack', 'sourcemap']
},
webpack: webpackConfig,
webpackMiddleware: {
noInfo: true
},
coverageReporter: { //產出報表的部份設定
dir: './coverage',
reporters: [
{ type: 'lcov', subdir: '.' },
{ type: 'text-summary' }
]
}
})
}
至於webpack.test的部份沒什麼特別的,就不多做說明了,有趣的是全局的eslint規則和測試這邊的不一樣,所以如果你想設定什麼規則,需要特別的去單元測試的根目錄層修改.eslintrc的部份
現在我想模擬一種情境,我有一個調比分的畫面,有四節加上延長賽,總分必須要等於四節+延長賽,而每格分數只能加一或減一,超過就會警告錯誤訊息,而且每次只能調一節的分數,接下來則看一下程式碼示例
<template>
<div>
<table>
<tr>
<td>1st</td>
<td>
<input class="input__one" type="number" v-model.number="scores.firstSection">
</td>
</tr>
<tr>
<td>2st</td>
<td>
<input class="input__two" type="number" v-model.number="scores.twoSection">
</td>
</tr>
<tr>
<td>3st</td>
<td>
<input class="input__three" type="number" v-model.number="scores.threeSection">
</td>
</tr>
<tr>
<td>4st</td>
<td>
<input class="input__four" type="number" v-model.number="scores.fourSection">
</td>
</tr>
</tr>
<tr>
<td>extend</td>
<td>
<input class="input__extend" type="number" v-model.number="scores.extendSection">
</td>
</tr>
<tr>
<td>total</td>
<td>
<input class="input__total" type="number" v-model.number="total">
</td>
</tr>
</table>
<input class="input__submit" type="button" value="submit" @click="submit">
</div>
</template>
<script>
import _ from 'lodash'
function mathDistance (num1, num2) {
return num2 > num1 ? num2 - num1 : num1 - num2
}
export default {
name: 'hello',
data () {
return {
scores: {
firstSection: 0,
twoSection: 0,
threeSection: 0,
fourSection: 0,
extendSection: 0
},
cloneScores: {}
}
},
computed: {
total () {
return this.scores.firstSection + this.scores.twoSection + this.scores.threeSection +
this.scores.fourSection + this.scores.extendSection
}
},
methods: {
submit () {
let scores = this.scores
let modifedObject = _.reduce(this.cloneScores, function (result, value, key) {
return _.isEqual(value, scores[key]) ? result : result.concat(key)
}, [])
let isDistanceScore = false
modifedObject.forEach(property => {
if (mathDistance(this.cloneScores[property], scores[property]) > 1) {
isDistanceScore = true
}
})
if (isDistanceScore) alert('只能增加或減少一分')
if (modifedObject.length > 1) alert('只能更新一節的分數')
}
},
created () {
this.cloneScores = { ...this.scores }
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
畫面示意如下
element應該是寫vue的人,幾乎都會知道的framework,也很多人推他們的單元測試的工具腳本,不然我們光是每次建立vue的實體和銷毀都得自行搞一次,而且因為我們參考了他們的腳本,也就可以參考一下他們是怎麼寫測試的,接著就先來複製一下element.js的工具腳本吧
複製一個一模一樣的在自己的單元測試的根目錄底下,並稍稍修改一下他的原始碼部份,主要是刪除了他原本有參考element的部份
import Vue from 'vue'
let id = 0
const createElm = function() {
const elm = document.createElement('div')
elm.id = 'app' + ++id
document.body.appendChild(elm)
return elm
}
/**
* 回收 vm
* @param {Object} vm
*/
exports.destroyVM = function(vm) {
vm.$el &&
vm.$el.parentNode &&
vm.$el.parentNode.removeChild(vm.$el)
}
/**
* 创建一个 Vue 的实例对象
* @param {Object|String} Compo 组件配置,可直接传 template
* @param {Boolean=false} mounted 是否添加到 DOM 上
* @return {Object} vm
*/
exports.createVue = function(Compo, mounted = false) {
if (Object.prototype.toString.call(Compo) === '[object String]') {
Compo = { template: Compo }
}
return new Vue(Compo).$mount(mounted === false ? null : createElm())
}
/**
* 创建一个测试组件实例
* @link http://vuejs.org/guide/unit-testing.html#Writing-Testable-Components
* @param {Object} Compo - 组件对象
* @param {Object} propsData - props 数据
* @param {Boolean=false} mounted - 是否添加到 DOM 上
* @return {Object} vm
*/
exports.createTest = function(Compo, propsData = {}, mounted = false) {
if (propsData === true || propsData === false) {
mounted = propsData
propsData = {}
}
const elm = createElm()
const Ctor = Vue.extend(Compo)
return new Ctor({ propsData }).$mount(mounted === false ? null : elm)
}
/**
* 触发一个事件
* mouseenter, mouseleave, mouseover, keyup, change, click 等
* @param {Element} elm
* @param {String} name
* @param {*} opts
*/
exports.triggerEvent = function(elm, name, ...opts) {
let eventName
if (/^mouse|click/.test(name)) {
eventName = 'MouseEvents'
} else if (/^key/.test(name)) {
eventName = 'KeyboardEvent'
} else {
eventName = 'HTMLEvents'
}
const evt = document.createEvent(eventName)
evt.initEvent(name, ...opts)
elm.dispatchEvent
? elm.dispatchEvent(evt)
: elm.fireEvent('on' + name, evt)
return elm
}
/**
* 触发 “mouseup” 和 “mousedown” 事件
* @param {Element} elm
* @param {*} opts
*/
exports.triggerClick = function(elm, ...opts) {
exports.triggerEvent(elm, 'mousedown', ...opts)
exports.triggerEvent(elm, 'mouseup', ...opts)
return elm
}
接著我們就可以來寫測試了,首先測試一下總分會等於所有分數加起來的部份,element主要有提供我們兩種方式來測試vue的元件,一種名稱為createTest就是直接傳入component,然後另一個參數則是丟入props,最後一個參數則是指定是否要mounted,請看下方程式碼示例
// import Vue from 'vue'
import { destroyVM, createTest } from '../util'
import Hello from '@/components/Hello'
describe('Hello', () => {
let vm
/**
* 每個測試之前都會跑
*/
beforeEach(() => {
// 因為裡面已經寫好預設值為空,所以第二個參數props我沒有,第三個參數預設為true,所以也就不需要傳了
vm = createTest(Hello)
})
/**
* 每個測試之後都會跑
*/
afterEach(() => {
destroyVM(vm) // 每次測試完都要destory Vue
})
it('總分為所有節數加起來的分數', () => {
vm.scores = {
firstSection: 1,
twoSection: 2,
threeSection: 3,
fourSection: 5,
extendSection: 0
}
expect(vm.total).to.be.equal(11)// 下面的用法也可以,chai用法超級多,甚至連mocha和sinon都有assert的api可以用
// assert.equal(vm.total, 11)
})
})
而另一種測試方法叫做CreateVue,則是new起一個vue,就可以像用一個原始的vue一樣的寫單元測試,不過本人比較中意使用第一種的方式,測試起來單純很多,所以實在不建議使用第二種方式,這邊也不想示例了,有興趣的讀者可以自行觀看element的測試程式碼做學習。
接下來故意把總分改成1分,看看是不是真的有測試,以達到我們想要的效果
確認測試已經成功了,到此我需要針對觀念詳細的說明一下,vm指的是我們create出來的component,這個component則是真實的Hello裡面的東西,打個比方來說,如果我們想要呼叫Hello裡面的submit,我們只要呼叫vm.submit()就可以了,我們也可以直接看一下Hello.vue裡面目前擁有的所有東西,比如說我把測試程式碼改成如下的方式,也就是原本component的值,接著則是我把vm.scores改掉之後的值
it('總分為所有節數加起來的分數', () => {
// 原本的值
console.log(vm.scores)
vm.scores = {
firstSection: 1,
twoSection: 2,
threeSection: 3,
fourSection: 5,
extendSection: 0
}
// 修改後的值
console.log(vm.scores)
expect(vm.total).to.be.equal(11)
})
結果如下
也就是說如果我們想要修改值的話,只要針對data去修改,ui就會跟著變了,我們其實完全不需要去改dom,就能測試我們元件裡的邏輯,但是如果我們想要改ui的話,也是可行的,從原本hello的html裡面,應該可以看到我刻意模仿element為dom命名的方式,把真實的class和想要測試的class命名區隔開,想要使用測試的class改成以_的方式來命名,接著看一下如果我是使用dom的方式來操作的話,程式碼又會長怎樣呢
it('總分為所有節數加起來的分數', () => {
vm.$el.querySelector('.input__one').value = 1
vm.$el.querySelector('.input__two').value = 2
vm.$el.querySelector('.input__three').value = 3
vm.$el.querySelector('.input__four').value = 5
vm.$el.querySelector('.input__extend').value = 0
//因為操作dom的問題,所以響應式原理我們必須調用此function在來取dom的值,以確保dom已反應(https://cn.vuejs.org/v2/guide/reactivity.html)
vm.$nextTick(() => {
// 注意看一下11為字串,並為我們之前預期的數字,因為只要用dom來操作,就沒辦法分辨型別了
expect(vm.$el.querySelector('.input__total')).to.be.equal('11')
})
})
結果
測試結果雖然正確,但是html綁定的dom卻出問題了,因為我們預定要綁的是number,但使用dom不分型別,導致綁定的時候出現error了,所以如果我們vue的程式碼寫得好的話,照理說對dom的操作會幾乎沒有,這樣子我們單元測試的話,只要能操作資料就不會有很多奇奇怪怪的問題發生,觀念講完之後,接下來想測試的案例是新增超過一分的話,應該要秀警告訊息,而關於秀警告訊息這點也需要特別說明一下。
因為我們使用了alert,而alert是屬於window這個物件裡面的方式,我們又依賴於dom了,根本就沒辦法測試,好佳在我們有sinon可以把我們spy這個dom的方法,以使得我們可以取得這個物件的實際互動結果,接下來看一下我們該如何實做吧
// import Vue from 'vue'
import { destroyVM, createTest } from '../util'
import Hello from '@/components/Hello'
describe('Hello', () => {
let vm, alert
beforeEach(() => {
alert = sinon.spy(window, 'alert') // spy一個alert,以便訪問這個方法
vm = createTest(Hello)
})
afterEach(() => {
alert.restore() // 每次案例測試完得銷毀
destroyVM(vm)
})
it('總分為所有節數加起來的分數', () => {
vm.scores = {
firstSection: 1,
twoSection: 2,
threeSection: 3,
fourSection: 5,
extendSection: 0
}
expect(vm.total).to.be.equal(11)
})
it('不可以加或減超過一分', () => {
vm.scores.firstSection = 2
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
})
})
結果
最後把我想測試的所有案例全部加上去,再做測試
// import Vue from 'vue'
import { destroyVM, createTest } from '../util'
import Hello from '@/components/Hello'
describe('Hello', () => {
let vm, alert
beforeEach(() => {
alert = sinon.spy(window, 'alert') // spy一個alert,以便訪問這個方法
vm = createTest(Hello)
})
afterEach(() => {
alert.restore() // 每次案例測試完得銷毀
destroyVM(vm)
})
it('總分為所有節數加起來的分數', () => {
vm.scores = {
firstSection: 1,
twoSection: 2,
threeSection: 3,
fourSection: 5,
extendSection: 0
}
expect(vm.total).to.be.equal(11)
})
it('不可以加或減超過一分', () => {
vm.scores.firstSection = 2
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
})
it('只能更新一節的分數', () => {
vm.scores.firstSection = 1
vm.scores.twoSection = 1
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能更新一節的分數')
})
it('不可以加或減超過一分且只能更新一節的數分', () => {
vm.scores.firstSection = 2
vm.scores.twoSection = 1
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
expect(alert.args[1][0]).to.be.equal('只能更新一節的分數')
})
})
結果
如果我們今天在專案使用vuex的時候,只要我們有使用到getters或者import的元件有用到,當我們測試單元測試的時候,就會發生了一個錯叫"ERROR LOG: '[Vue warn]: Error in render function: "TypeError: undefined is not an object (evaluating 'this.$store.getters')"",請見下圖
這個時候原有的element方法就不夠我們使用了,不過其實我們可以為util.js新增一個測試方法,或直接修改原有的來達成我們的需求就行了,而我這邊的做法則是直接新增一個方法來使用,名稱為createTesting
import Vue from 'vue'
import store from '@/store'
let id = 0
const createElm = function () {
const elm = document.createElement('div')
elm.id = 'app' + ++id
document.body.appendChild(elm)
return elm
}
/**
* 建立一個預設有注入store的測試方法
* @param {Object} Compo 元件
* @param {Object} propsData props
*/
exports.createTesting = function (Compo, propsData = {}) {
const elm = createElm()
Vue.prototype.$store = store
const Ctor = Vue.extend(Compo)
return new Ctor({ propsData }).$mount(elm)
}
/**
* 回收 vm
* @param {Object} vm
*/
exports.destroyVM = function (vm) {
vm.$el &&
vm.$el.parentNode &&
vm.$el.parentNode.removeChild(vm.$el)
}
/**
* 创建一个 Vue 的实例对象
* @param {Object|String} Compo 组件配置,可直接传 template
* @param {Boolean=false} mounted 是否添加到 DOM 上
* @return {Object} vm
*/
exports.createVue = function (Compo, mounted = false) {
if (Object.prototype.toString.call(Compo) === '[object String]') {
Compo = { template: Compo }
}
return new Vue(Compo).$mount(mounted === false ? null : createElm())
}
/**
* 创建一个测试组件实例
* @link http://vuejs.org/guide/unit-testing.html#Writing-Testable-Components
* @param {Object} Compo - 组件对象
* @param {Object} propsData - props 数据
* @param {Boolean=false} mounted - 是否添加到 DOM 上
* @return {Object} vm
*/
exports.createTest = function (Compo, propsData = {}, mounted = false) {
if (propsData === true || propsData === false) {
mounted = propsData
propsData = {}
}
const elm = createElm()
const Ctor = Vue.extend(Compo)
// Vue.prototype.$store = store
return new Ctor({ propsData }).$mount(mounted === false ? null : elm)
}
/**
* 触发一个事件
* mouseenter, mouseleave, mouseover, keyup, change, click 等
* @param {Element} elm
* @param {String} name
* @param {*} opts
*/
exports.triggerEvent = function (elm, name, ...opts) {
let eventName
if (/^mouse|click/.test(name)) {
eventName = 'MouseEvents'
} else if (/^key/.test(name)) {
eventName = 'KeyboardEvent'
} else {
eventName = 'HTMLEvents'
}
const evt = document.createEvent(eventName)
evt.initEvent(name, ...opts)
elm.dispatchEvent
? elm.dispatchEvent(evt)
: elm.fireEvent('on' + name, evt)
return elm
}
/**
* 触发 “mouseup” 和 “mousedown” 事件
* @param {Element} elm
* @param {*} opts
*/
exports.triggerClick = function (elm, ...opts) {
exports.triggerEvent(elm, 'mousedown', ...opts)
exports.triggerEvent(elm, 'mouseup', ...opts)
return elm
}
之後如果我們需要預設注入store的,我們就直接改成用createTesting就可以了
// import Vue from 'vue'
import { destroyVM, createTesting } from '../util'
import Hello from '@/components/Hello'
describe('Hello', () => {
let vm
beforeEach(() => {
vm = createTesting(Hello) // 這邊把原本的createTest改成createTesting,就可以直接注入store了
})
afterEach(() => {
destroyVM(vm)
})
it('總分為所有節數加起來的分數', () => {
vm.scores = {
firstSection: 1,
twoSection: 2,
threeSection: 3,
fourSection: 5,
extendSection: 0
}
expect(vm.total).to.be.equal(11)
})
})
其實測試這個議題很大,而且相關的文章非常的少,就連官網針對vue的測試也只是簡單帶過,看完之後想動手寫還是有一大堆疑問,所以花了筆者好幾天的時間去研究和踩坑,不過其實光sinon就可以花很多時間來研究了,而這邊也都還沒講到如果我們依賴了ajax這種外部環境因素,我們又該怎麼寫測試,怎麼樣寫組件才是一個好的組件,比較方便做單元測試,而這些內容筆者之後再做分享,不然文章真的會變得非常的長,如果有任何讀者有更好的建議和做法,都請指導或分享給筆者哦。