[vue]如何測試依賴於真實環境的組件(vue單元測試系列-3)

說明如何針對寫死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是直接寫死在組件裡的話,這個方法來隔離環境就會非常有用了,而如果對今天文章有任何建議,或覺得我的方式是不對的話,再請指教和指正,畢竟這些都是筆者自己測試和想像出來的,或許是很笨的方法,所以有更好的方法再請一定得告知。