說明如何針對寫死ajax連線的程式碼順便fake並做測試
前言
在寫單元測試的時候,我們一定會碰到直接把http寫死在組件裡,或者我們要測試的程式碼裡面直接依賴於副作用的部份,其實之前已經有寫過應該怎麼寫一個好測試的組件,但如果我們遇到了寫死的部份,我們應該如何來做測試呢?那就讓我們看下去吧。
把寫死ajax的部份,利用生命週期的特性重寫
一樣先來看一下我們的測試範例,一樣是調比分的ui,這次改成在此元件裡面,把ajax連線的部份,寫死在元件裡面。
Hello.vue
<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 axios from 'axios'
import _ from 'lodash'
function mathDistance (num1, num2) {
return num2 > num1 ? num2 - num1 : num1 - num2
}
export default {
name: 'hello',
data () {
return {
scores: {},
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('只能更新一節的分數')
if (!isDistanceScore && modifedObject.length <= 1) alert('修改成功')
},
async get () {
this.scores = await axios.get('http://localhost:55206/api/book').then(x => x.data)
}
},
async created () {
await this.get()
this.cloneScores = { ...this.scores }
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>
接下來直接測試一下的話,如預期的肯定會失敗,因為我們直接依賴了axios了,先看一下目前的測試程式碼
// import Vue from 'vue'
import { destroyVM, createTest, createElm } from '../util'
import Hello from '@/components/Hello'
describe('CompareScore', () => {
let vm, alert
beforeEach(() => {
alert = sinon.spy(window, '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('只能更新一節的分數')
})
})
結果
因為我們每次new一個vue總是會跑created,所以我們無法控制讓created這個hook會發生的邏輯,所以我必須先把created的hook改成beforeMount
// async created () {
// await this.get()
// this.cloneScores = { ...this.scores }
// }
// 改成如下
async beforeMount () {
await this.get()
this.cloneScores = { ...this.scores }
}
接下來看一下util.js(參考 element的測試公用方法)的部份,因為預設全部都會$mount了,所以我另外建立一個沒有mount的方法,名為CreateComponentTest,如下範例
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
}
exports.createElm = createElm
/**
* 回收 vm
* @param {Object} vm
*/
exports.destroyVM = function (vm) {
vm.$el &&
vm.$el.parentNode &&
vm.$el.parentNode.removeChild(vm.$el)
}
/**
* 建立一個尚未mount的測試組件
* @link http://vuejs.org/guide/unit-testing.html#Writing-Testable-Components
* @param {Object} Compo - 组件对象
* @param {Object} propsData - props 数据
* @return {Object} vm
*/
exports.createComponentTest = function (Compo, propsData = {}) {
if (propsData === true || propsData === false) {
propsData = {}
}
const Ctor = Vue.extend(Compo)
return new Ctor({ propsData })
}
/**
* 创建一个 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
}
最後再看一下單元測試的程式碼,相關說明皆寫在註解裡面
// import Vue from 'vue'
import { destroyVM, createComponentTest } from '../util'
import Hello from '@/components/Hello'
describe('Hello', () => {
let vm, alert
beforeEach(() => {
alert = sinon.spy(window, 'alert')
vm = createComponentTest(Hello)
vm.get = function () { // 將依賴於外部環境的方法重寫
vm.scores = {
firstSection: 0,
twoSection: 0,
threeSection: 0,
fourSection: 0,
extendSection: 0
}
}
vm.$mount() // 最後再為vue mount
})
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('不可以加或減超過一分', async () => {
await vm.beforeMount // 必須確保生命週期已跑完,所以手動執行此生命週期,並且因為此生命週期有非同步,所以需使用非同步的方式
vm.scores.firstSection = 2
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
})
it('只能更新一節的分數', async () => {
await vm.beforeMount
vm.scores.firstSection = 1
vm.scores.twoSection = 1
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能更新一節的分數')
})
it('不可以加或減超過一分且只能更新一節的數分', async () => {
await vm.beforeMount
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('只能更新一節的分數')
})
it('修改成功', async () => {
await vm.beforeMount
vm.scores.firstSection = 1
vm.submit()
expect(alert.args[0][0]).to.be.equal('修改成功')
})
})
結果
問題尚未解決
雖然我們目前測試都ok了,但是如果有認真思考的讀者應該能發現,我們如果修改成功之後,直接發一個ajax的話呢?先來把組件的程式碼改成需要發ajax確認成功,才能秀出修改成功的訊息
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('只能更新一節的分數')
if (!isDistanceScore && modifedObject.length <= 1) {
axios.post('http://localhost:55206/api/book', this.scores).then(x => {
alert('修改成功')
})
}
}
這樣子測試因為依賴於ajax,肯定是失敗收場的
如果我們能在生命週期之前,重寫一個方法,那我不如就把post的method也另外建立一個方法來重寫吧
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('只能更新一節的分數')
if (!isDistanceScore && modifedObject.length <= 1) {
//改成呼叫另一個方法的方式
this.post().then(x => {
alert('修改成功')
})
}
},
// 將原本相依在submit方法裡面呼叫ajax的重構出來
post () {
return axios.post('http://localhost:55206/api/book', this.scores)
},
async get () {
this.scores = await axios.get('http://localhost:55206/api/book').then(x => x.data)
}
}
接著再來看一下測試碼如何改吧,我已再新增或修改的部份加上程式碼註解
// import Vue from 'vue'
import { destroyVM, createComponentTest } from '../util'
import Hello from '@/components/Hello'
describe('Hello', () => {
let vm, alert
beforeEach(() => {
alert = sinon.spy(window, 'alert')
vm = createComponentTest(Hello)
vm.get = function () {
vm.scores = {
firstSection: 0,
twoSection: 0,
threeSection: 0,
fourSection: 0,
extendSection: 0
}
}
vm.post = function () { // 將post重寫,並回傳一個空的promise resolve
return new Promise(resolve => resolve())
}
vm.$mount()
})
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('不可以加或減超過一分', async () => {
await vm.beforeMount // 必須確保生命週期已跑完,所以手動執行此生命週期,並且因為此生命週期有非同步,所以需使用非同步的方式
vm.scores.firstSection = 2
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
})
it('只能更新一節的分數', async () => {
await vm.beforeMount
vm.scores.firstSection = 1
vm.scores.twoSection = 1
vm.submit()
expect(alert.args[0][0]).to.be.equal('只能更新一節的分數')
})
it('不可以加或減超過一分且只能更新一節的數分', async () => {
await vm.beforeMount
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('只能更新一節的分數')
})
it('修改成功', async () => {
await vm.beforeMount
vm.scores.firstSection = 1
vm.submit()
vm.$nextTick(() => { // 因為promise的原因,按下submit之後,調用nextTick確保alert已被調用
expect(alert.args[0][0]).to.be.equal('修改成功')
})
})
})
測試結果
結論
這一篇也是我自己想出來的方法,可能很不正統,在練習如何寫單元測試的時候,筆者其實也遇到了不少坑,而我也並未把遇到坑的過程全部寫出來,只是補上最後我測試可行的方式,不過實際在寫元件的時候,不太可能把ajax寫死在元件裡面,而且如果我們今天要測試的是vuex,那不就更慘了,所以實際上我在專案還有用另一種可以把依賴給換掉的方法,之後再找時間分享出來,不過如果今天真的遇到了ajax是直接寫死在組件裡的話,這個方法來隔離環境就會非常有用了,而如果對今天文章有任何建議,或覺得我的方式是不對的話,再請指教和指正,畢竟這些都是筆者自己測試和想像出來的,或許是很笨的方法,所以有更好的方法再請一定得告知。