[vue]如何為vue補上單元測試來確保品質(vue單元測試系列-1)

介紹如何在vue這個前端框架寫單元測試,還有一些前端測試的相關名詞。

前言

其實筆者本身也是前端測試的小白,但是為了給團隊帶入前端的測試,先後研究了ui test和unit test,最後感覺上還是把心力花在unit test上面會比較符合效益,單元測試的議題也很大,包含了副作用隔離的部份,而且相關文章參考量也很少,這篇主要想先講一下怎麼寫單元測試,而單元測試能帶來的好處也非常多,除了反覆測試我們的邏輯,以免我們遺漏了每個測試,最重要的還是重構的議題上面,畢竟我們今天都已經寫javascript了,在型別的處理上就簡單方便許多,那把這些功夫花來處理單元測試上面,對以後的維護和修改或驗證測試,都是方便許多了,如果團隊走的是tdd開發的話,那測試代碼即是文件,如果不是走tdd的話,至少為了複雜一點的邏輯加上單元測試,這樣子絕對能幫助到以後的自己或接手維護的人。

導覽

  1. 關於前端測試工具的簡單介紹
  2. vue裡面的配置說明
  3. 情境模擬
  4. 參考element的方式來實做單元測試
  5. 當遇到dom元件的時候,該怎麼做測試
  6. 解決測試時候使用vuex而發生的錯誤訊息
  7. 結論

關於前端測試工具的簡單介紹

前端測試工具其實非常多種,這邊也只會針對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的話,就不需要這個了。

vue裡面的配置說明

我們先從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的方式來實做單元測試

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的操作會幾乎沒有,這樣子我們單元測試的話,只要能操作資料就不會有很多奇奇怪怪的問題發生,觀念講完之後,接下來想測試的案例是新增超過一分的話,應該要秀警告訊息,而關於秀警告訊息這點也需要特別說明一下。

當遇到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而發生的錯誤訊息

如果我們今天在專案使用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這種外部環境因素,我們又該怎麼寫測試,怎麼樣寫組件才是一個好的組件,比較方便做單元測試,而這些內容筆者之後再做分享,不然文章真的會變得非常的長,如果有任何讀者有更好的建議和做法,都請指導或分享給筆者哦。