[vue]如何寫一個可測試的組件(vue單元測試系列-2)

探討一下怎麼樣的組件才是好的,並且易於測試的,耦合性低的

前言

之前有寫過單元測試這個議題(https://dotblogs.com.tw/kinanson/2017/07/20/075338),不過之前是一個很單純的環境,沒有相依於外在環境,頂多只是需要spy一些dom物件,並且觀察這個物件的變化,不過實際開發來說,除非你是在開發一個純工具類的元件,不然你第一個優先會遇到的就是使用超級多的ajax,這時候你的測試就完全跑不動且測試不了囉,而這篇想討論的就是在真實開發的世界裡面,我們又要如何做單元測試呢?

導覽

  1. 單元測試需知
  2. 一個耦合性高的組件
  3. 如何寫一個可測試的組件
  4. 結論

單元測試需知

所謂的單元測試,定義很多,有說是測試一個function,而對我來說其實是測試一個需求,而且是有些有邏輯的需求,而這段複雜邏輯應該放哪邊在vue裡面卻很難說,如果我們是一個工具類的元件,那盡量就是只放在這個元件裡面,如果是一個有相依於ajax的元件,很多時候就有特定的邏輯是很難做成工具類的元件,那這段邏輯有可能放在元件裡面,也有可能放在外面的js裡面,如果我們使用了vuex的話,這段邏輯就又有可能會是放在mutation裡面,不過如果你的行為是從元件往外擴散的話,那就是只要測試元件裡面某個行為,當然也會連同測試了外部js或vuex的部份,不過只要我們想做單元測試,我們就得確保這個測試在任何環境任何人的電腦都能順利執行,所以第一件最重要也最難搞的事情,就是把有可能導致會失敗的因素排除,而在vue裡面佔了最大成份的就是ajax的呼叫,或者是一些時間延遲的部份,也就是副作用的程式碼,不管在任何程式語言,只要稱做單元測試,就一定是要快速並且無副作用,不管任何時間任何電腦跑了都能正常,否則的話都稱為整合測試。

一個耦合性高的組件

我們要做一個好的組件,就是在元件裡面有關於ajax的部份,都要能夠替換,而如何叫做是能夠替換呢?什麼又是無法替換很難測試的呢,如果你把任何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 _ from 'lodash'
import axios from 'axiosB'

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.lenght <= 1) {
        axios.post('localhost:1111/test', this.scores).then(x => { // 這邊直接寫死了,無法替換
          alert('新增成功')
        })
      }
    },
    async get () {
      this.scores = await axios.get('localhost:1111/test') // 這邊直接寫死了,無法替換掉
    }
  },
  async created () {
    this.scores = await this.get()
    this.cloneScores = { ...this.scores }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>

</style>

這樣子我們是無法測試單元測試的,可以看一下目前的測試程式碼

// 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('只能增加或減少一分')
  })
})

結果圖示可以看到第一個成功,第二個是失敗的,而且有提示了promise相關的錯誤訊息,這邊必須得理解一下,就算我們程式碼有寫async await了,筆者實際上用fiddler去監察封包,卻完全沒有看到發出任何request,所以筆者猜測vue的單元測試部份只要遇到ajax的程式碼,是完全不會有運作的

如何寫一個可測試的組件

那我們的組件應該怎麼寫才能很好抽換,並且可測試呢?如果我們是經由props或emit的方式,因為決定的邏輯是在調用方,所以我們可以很方便的決定這次使用的邏輯,這樣就能達成很容易測試的條件,所以我們應該有一個page只負責在處理商業邏輯和引用什麼元件,但是這邊必須說明一下,有些時候一些特定的邏輯放在元件裡面是會比較好的,所以一切都得視實際狀況,但是只要是一些副作用的部份,最好都是在調用方來實做會比較好,以下則是一個可測試的元件的寫法,供參考。

Hello.vue

<template>
  <div>
    <compare-score :scores="scores" @on-submit="onSubmit"></compare-score>
  </div>
</template>

<script>
import axios from 'axios'
import CompareScore from './CompareScore.vue'

export default {
  name: 'hello',
  components: {
    CompareScore
  },
  data () {
    return {
      scores: {}
    }
  },
  methods: {
    onSubmit (localScore) {
      this.scores = localScore
      axios.post('localhost:1111/test', this.scores).then(x => {
        alert('新增成功')
      })
    },
    async get () {
      this.scores = await axios.get('localhost:1111/test')
    }
  },
  created () {
    this.get()
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>

</style>

CompareScore.vue

<template>
  <div>
    <table>
      <tr>
        <td>1st</td>
        <td>
          <input class="input__one" type="number" v-model.number="localScores.firstSection">
        </td>
      </tr>
      <tr>
        <td>2st</td>
        <td>
          <input class="input__two" type="number" v-model.number="localScores.twoSection">
        </td>
      </tr>
      <tr>
        <td>3st</td>
        <td>
          <input class="input__three" type="number" v-model.number="localScores.threeSection">
        </td>
      </tr>
      <tr>
        <td>4st</td>
        <td>
          <input class="input__four" type="number" v-model.number="localScores.fourSection">
        </td>
      </tr>
      </tr>
      <tr>
        <td>extend</td>
        <td>
          <input class="input__extend" type="number" v-model.number="localScores.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: 'compareScore',
  props: {
    scores: Object // 怎麼樣的資料從呼叫端決定
  },
  data () {
    return {
      cloneScores: {},
      localScores: { ...this.scores }
    }
  },
  computed: {
    total () {
      return this.localScores.firstSection + this.localScores.twoSection + this.localScores.threeSection +
        this.localScores.fourSection + this.localScores.extendSection
    }
  },
  methods: {
    submit () {
      let scores = this.localScores
      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.$emit('on-submit', this.localScores) // 把依賴於外部的邏輯交給呼叫層處理
    }
  },
  created () {
    this.cloneScores = { ...this.scores }
  }
}
</script>

<style>

</style>

結果

底下則是單元測試的代碼,實際上我們真正要測試的元件則是CompareScore這個元件

// import Vue from 'vue'
import { destroyVM, createTest } from '../util'
import CompareScore from '@/components/CompareScore'

describe('CompareScore', () => {
  let vm, alert

  beforeEach(() => {
    alert = sinon.spy(window, 'alert') // spy一個alert,以便訪問這個方法
    const scores = {
      firstSection: 0,
      twoSection: 0,
      threeSection: 0,
      fourSection: 0,
      extendSection: 0
    }
    vm = createTest(CompareScore, { scores }) // scores則是改由呼叫端傳進去的
  })

  afterEach(() => {
    alert.restore() // 每次案例測試完得銷毀
    destroyVM(vm)
  })

  it('總分為所有節數加起來的分數', () => {
    vm.localScores = {
      firstSection: 1,
      twoSection: 2,
      threeSection: 3,
      fourSection: 5,
      extendSection: 0
    }
    expect(vm.total).to.be.equal(11)
  })

  it('不可以加或減超過一分', () => {
    vm.localScores.firstSection = 2
    vm.submit()
    expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
  })

  it('只能更新一節的分數', () => {
    vm.localScores.firstSection = 1
    vm.localScores.twoSection = 1
    vm.submit()
    expect(alert.args[0][0]).to.be.equal('只能更新一節的分數')
  })

  it('不可以加或減超過一分且只能更新一節的數分', () => {
    vm.localScores.firstSection = 2
    vm.localScores.twoSection = 1
    vm.submit()
    expect(alert.args[0][0]).to.be.equal('只能增加或減少一分')
    expect(alert.args[1][0]).to.be.equal('只能更新一節的分數')
  })
})

測試結果

結論

這篇主要在介紹如何寫一個好測試的組件,但是實務上我們只是為了測試,難道就一定要在抽象一層專門來處理商業邏輯嗎,如果我們的頁面就是簡單的頁面而已呢?那就待之後再來介紹一下吧,如何就算在元件裡面寫死了,也還是能把相依於外部的環境給隔離掉吧,可能讀者會覺得寫單元測試好像很複雜,太多東西得懂和要學了,其實單元測試的學問確實挺多的,不管是寫後端語言或javascript的部份,到底怎麼測或應該要測什麼,每個大師心中都有一把尺,但不管如何為了以後的維護或當成可閱讀的文件,還有可以自動驗證我們有沒有把code改壞,甚至是以後重構的定心丸,對於一個有極大利益和長久歷史的產品,單元測試肯定是必要的,如果讀者有任何想討論或疑問的再請留言回覆囉。