[vue]使用jest和官方的vue test utils來寫單元測試(vue單元測試系列-5)

介紹為何要改使用jest,而不是使用官方cli預設提供的karma and mocha

前言

之前有寫過一系列的單元測試的文章,而其實筆者一直在等待官方提到的test utils,因為之前的測試除了是使用element的方法,還有很多則是筆者自己思考研究來的,而在這段時間裡面,jest也是越來越火紅了,追技術是很辛苦的,尤其在業務需求繁重的情境下,不過即然想嘗試著用官方提供的測試工具,順理成章的當然也得來研究一下jest了,接下來就看一下筆者的分析說明和如何使用吧,如果對jest有興趣的可以訪問(https://github.com/facebook/jest),對vue test utils有興趣請至(https://github.com/vuejs/vue-test-utils),而有一系列的文章筆者覺得有心想研究的可以去觀摩一下(https://alexjoverm.github.io/2017/08/21/Write-the-first-Vue-js-Component-Unit-Test-in-Jest/)

導覽

  1. jest和cli預設的karma和mocha差異
  2. 起手vue專案和情境模擬
  3. 安裝jest來做測試
  4. 使用vue test utils
  5. 使用jest來隔離vuex,以順利的單元測試
  6. 自動跑測試和開啟測試狀況提示
  7. 結論

jest和cli預設的karma和mocha差異

大家如果有用cli去裝上單元測試的工具,會發現裝了非常多的東西,比如說sinon and chai and karama etc....但是其實如果你只要裝jest的話,其餘東西就都不用裝了,而且自己來的手續也挺簡單的,jest也提供了類似sinon的stub and mock....的功能,而筆者個人覺得測試的說明還有反應速度,還有配置方面都比原本cli裝的那一堆工具好多了,所以筆者就很堅決的把手上的專案的單元測試改成jest的版本。

而官方提供的utils,個人倒是沒有感覺比element的測試腳本方便到哪去,但是愛用官方提供的一直是筆者的堅持,再加上utils把你處理掉了nextick的問題,不用再自己手動去處理這件事情,也是蠻讓筆者心動的,所以雖然翻過來花了比較多的精神,但是望向未來的態勢,還是跟著官方的腳步走比較保險。

起手vue專案和情境模擬

因為單元測試的部份我們要自己來,所以當我在建立cli的時候,單元測試和end to end我都選擇不安裝了,這樣完成一個空白專案,就不會有test的資料夾和一些測試package的安裝,應該會如下樣子

在之前的測試情境中,我有一個key分數的表單,而且依賴於ajax提供的數據,在此我也想延用這個範例來說明,只是把工具改成jest and vue test utils,先來看看這個元件的程式碼長什麼樣子吧。

<template>
  <div>
    <table>
      <tr>
        <td>1st</td>
        <td>
          <input type="number" v-model.number="scores.firstSection">
        </td>
      </tr>
      <tr>
        <td>2st</td>
        <td>
          <input type="number" v-model.number="scores.twoSection">
        </td>
      </tr>
      <tr>
        <td>3st</td>
        <td>
          <input type="number" v-model.number="scores.threeSection">
        </td>
      </tr>
      <tr>
        <td>4st</td>
        <td>
          <input type="number" v-model.number="scores.fourSection">
        </td>
      </tr>
      </tr>
      <tr>
        <td>extend</td>
        <td>
          <input type="number" v-model.number="scores.extendSection">
        </td>
      </tr>
      <tr>
        <td>total</td>
        <td>
          <input type="number" v-model.number="total">
        </td>
      </tr>
    </table>
    <input 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>

當我們新增超過1分的話會alert警告,一次key兩個分數的話也會顯示alert,看一下畫面示意的效果如下

安裝jest來做測試

我們使用npm來安裝jest相關package

npm i jest babel-jest jest-vue --D

接著需要在package.json加入jest的一些設定。

 "jest": {
    "moduleFileExtensions": [
      "js",
      "vue"
    ],
    "transform": {
      "^.+\\.js$": "<rootDir>/node_modules/babel-jest",
      ".*\\.(vue)$": "<rootDir>/node_modules/jest-vue"
    },
    "moduleNameMapper": {
      "@/(.*)$": "<rootDir>/src/$1"
    }
  }

moduleFileExtensions是指要jest去尋找的測試檔案,而transform則是去做轉換的,moduleNameMapper則是會map到webpack的alias的區塊,接著直接在package.json的scripts區塊jest來啟動測試

"test": "jest"

這樣子就行了,接著我們就可以直接下npm run test來執行測試,執行完後沒有任何事情發生,但jest確實有執行了

因為jest預設會去認檔名有test的,因為我個人喜歡把測試的檔案和被測試的程式放在一起,用了jest要怎麼配置就隨便你了,所以我就新增一支同名的test.js,如下示例

HelloWorld..test.js

import Component from './HelloWorld.vue'

describe('Component', () => {
  test('總分為所有節數加起來的分數', () => {
    console.log('test')
  })
})

再執行一下測試,可以看到如下結果

使用vue test utils

之前的動作都只是為了要確認jest已經成功安裝和跑起來了,但我們的測試當然不會只是為了印一個hello world就結束,緊接著我們來安裝一下官方建議的vue test utils吧

npm i vue-test-utils --D

緊接著我們就把測試的程式碼改成如下

import { shallow } from 'vue-test-utils' // 使用shallow可以只foucs在要測試的元件,保証隔離了子元件,如果要一併測試子元件的話,需要使用mount
import Component from './HelloWorld.vue'

describe('Component', () => {
  let wrapper, vm, alert

  beforeEach(() => {
    wrapper = shallow(Component)
    vm = wrapper.vm // 這個可以取到元件裡面的東西,比如說data裡的變數或直接調用方法
    alert = jest.spyOn(window, 'alert') // 這邊則是使用jest來spy alert這個物件,有了jest我們就不需要sinon來spy了
  })

  afterEach(() => {
    alert.mockRestore() // 每次測試完都得restore
  })

  test('總分為所有節數加起來的分數', () => {
    // vm.scores = {
    //   firstSection: 1,
    //   twoSection: 2,
    //   threeSection: 3,
    //   fourSection: 5,
    //   extendSection: 0
    // }
    // 你也可以用上面的方式來改變,但官方建議是用如下的方式,wrapper有很多方法可以使用,詳請可以參考(https://vue-test-utils.vuejs.org/en/)

    wrapper.setData({
      scores: {
        firstSection: 1,
        twoSection: 2,
        threeSection: 3,
        fourSection: 5,
        extendSection: 0
      }
    })
    expect(vm.total).toEqual(11) // 這邊的expect是使用jest的,對我來說很夠用了
  })

  test('不可以加或減超過一分', () => {
    vm.scores.firstSection = 2
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能增加或減少一分')
  })

  test('只能更新一節的分數', () => {
    vm.scores.firstSection = 1
    vm.scores.twoSection = 1
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能更新一節的分數')
  })

  test('不可以加或減超過一分且只能更新一節的數分', () => {
    vm.scores.firstSection = 2
    vm.scores.twoSection = 1
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能增加或減少一分')
    expect(alert.mock.calls[1][0]).toEqual('只能更新一節的分數')
  })
})

以上都是簡單的測試情境,但有時候我們會有依賴於真實環境的狀況,就會導致我們的測試會受到很大的阻礙,現在我把component的程式碼改成如下,也就是呼叫完ajax之後會return一個promise的做法

<template>
  <div>
    <table>
      <tr>
        <td>1st</td>
        <td>
          <input type="number" v-model.number="scores.firstSection">
        </td>
      </tr>
      <tr>
        <td>2st</td>
        <td>
          <input type="number" v-model.number="scores.twoSection">
        </td>
      </tr>
      <tr>
        <td>3st</td>
        <td>
          <input type="number" v-model.number="scores.threeSection">
        </td>
      </tr>
      <tr>
        <td>4st</td>
        <td>
          <input type="number" v-model.number="scores.fourSection">
        </td>
      </tr>
      </tr>
      <tr>
        <td>extend</td>
        <td>
          <input type="number" v-model.number="scores.extendSection">
        </td>
      </tr>
      <tr>
        <td>total</td>
        <td>
          <input type="number" v-model.number="total">
        </td>
      </tr>
    </table>
    <input type="button" value="submit" @click="submit">
  </div>
</template>

<script>
import service from 'services/helloService'
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) {
        // 相依於axios的部份,抽到service裡面去
        service.post().then(x => {
          alert('修改成功')
        })
      }
    },
    async get () {
      // 相依於axios的部份,抽到service裡面去
      this.scores = await service.get()
    }
  },
  async created () {
    await this.get()
    this.cloneScores = { ...this.scores }
  }
}
</script>

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

</style>

service的部份我只是隨便寫,主要目的只是為了模擬ajax的部份

import axios from 'axios'
export default {
  get () {
    return axios.get('ddd').then(x => x.data)
  },
  post () {
    return axios.post('ddd')
  }
}

特別注意一下,因為我在import service的部份,使用的是alias的方式,所以在jest的部份也得設定,這樣子jest才會認得這個路徑

 "moduleNameMapper": {
   "@/(.*)$": "<rootDir>/src/$1",
   "services/(.*)$": "<rootDir>/src/services/$1"
 }

接著我們要隔離其實非常的簡單,只要import同樣的路徑,並且直接覆寫就可以了

import { shallow } from 'vue-test-utils'
import Component from './HelloWorld.vue'
import service from 'services/helloService' // 與元件一樣import檔案進來

describe('Component', () => {
  let wrapper = shallow(Component)
  let vm = wrapper.vm
  let alret = null

  service.get = () => Promise.resolve({ // 直接stub掉我們在元件引用到的方法
    firstSection: 0,
    twoSection: 0,
    threeSection: 0,
    fourSection: 0,
    extendSection: 1
  })

  service.post = () => Promise.resolve() // 直接stub掉我們在元件引用到的方法

  beforeEach(() => {
    wrapper = shallow(Component)
    vm = wrapper.vm
    alert = jest.spyOn(window, 'alert')
  })

  afterEach(() => {
    alert.mockRestore()
  })

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

  test('不可以加或減超過一分', () => {
    vm.scores.firstSection = 2
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能增加或減少一分')
  })

  test('只能更新一節的分數', () => {
    vm.scores.firstSection = 1
    vm.scores.twoSection = 1
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能更新一節的分數')
  })

  test('不可以加或減超過一分且只能更新一節的數分', () => {
    vm.scores.firstSection = 2
    vm.scores.twoSection = 1
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能增加或減少一分')
    expect(alert.mock.calls[1][0]).toEqual('只能更新一節的分數')
  })
})

使用jest來隔離vuex,以順利的單元測試

這方面官方有講到,官方使用的是sinon來stub actions,其實我們只要使用jest.fn就可以了,舉個例子如果我們要假造我們元件使用的action和getter的話,可以如下方式去寫,注意一下註解的部份是我新加的程式碼

import Vue from 'vue'
import Vuex from 'vuex'
import { shallow } from 'vue-test-utils'
import Component from './HelloWorld.vue'
import service from 'services/helloService'

Vue.use(Vuex) //自行註冊一個vuex

describe('Component', () => {
  let wrapper = shallow(Component)
  let vm = wrapper.vm
  let alret = null

  service.get = () => Promise.resolve({
    firstSection: 0,
    twoSection: 0,
    threeSection: 0,
    fourSection: 0,
    extendSection: 1
  })

  service.post = () => Promise.resolve()

  beforeEach(() => {
    // 下面模擬一個假的store,只需要模擬我們元件內部有用到的就行了
    const store = new Vuex.Store({
      state: {},
      actions: {
        updateModelStateError: jest.fn(), // 這邊使用的是jest.fn,所以我在專案裡面就不安裝sinon了
        reset: jest.fn(),
        setViewName: jest.fn()
      },
      getters: {
        'getSave': () => 'save'
      }
    })

    wrapper = shallow(Component, {
      store // 注入我們模擬的store
    })
    vm = wrapper.vm
    alert = jest.spyOn(window, 'alert')
  })

  afterEach(() => {
    alert.mockRestore()
  })

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

  test('不可以加或減超過一分', () => {
    vm.scores.firstSection = 2
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能增加或減少一分')
  })

  test('只能更新一節的分數', () => {
    vm.scores.firstSection = 1
    vm.scores.twoSection = 1
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能更新一節的分數')
  })

  test('不可以加或減超過一分且只能更新一節的數分', () => {
    vm.scores.firstSection = 2
    vm.scores.twoSection = 1
    vm.submit()
    expect(alert.mock.calls[0][0]).toEqual('只能增加或減少一分')
    expect(alert.mock.calls[1][0]).toEqual('只能更新一節的分數')
  })
})

自動跑測試和開啟測試狀況提示

如果我們每次執行都要手動去做,那就失去自動測試的好處了,而jest提供了很多command line的指令讓我們使用,我想要監控程式碼的變化,並且在右下角跳出一個提示,我們只要把package.json的指令改成如下,就可以有自動監控等的功能,非常簡單

"test": "jest --watchAll --notify"

接著來看一下這樣子的話是怎麼樣的效果吧。

結論

基本上jest還有vue test utils的功能非常的多,所以有興趣可以再自行去官網看一下,以上如果有什麼誤論或更好做法,再提醒和告知筆者。