Vue 應用單元測試的策略與實踐 04 - Vuex 單元測試

weixin_34148340發表於2018-11-02

本文首發於 Vue 應用單元測試的策略與實踐 04 - Vuex 單元測試 | 呂立青的部落格

歡迎關注知乎專欄 —— 前端的逆襲(凡可 JavaScript,終將 JavaScript。)

歡迎關注我的部落格知乎GitHub掘金


本文的目標

2.2 在Vue應用的單元測試中,對 Vuex store 該如何測試?如何測試與 Vue 元件之間的互動?

// Given
一個有基本的UT知識和Vue元件單元測試經驗的開發者?
// When
當他?閱讀和練習本文的Vuex單元測試的部分
// Then
他能夠對Vuex概念的理解更加深入,且知道 `Redux-like` 架構的好處
他能夠合理測試vuex store的mutation、getter中的業務邏輯和非同步action
他能夠測試元件如何正確讀取store中的state以及dispatch action
複製程式碼

如何理解 Vuex 模式?

Vuex 的前車之鑑

Vuex 是一個專為 Vue.js 應用程式開發的狀態管理模式。它採用集中式儲存管理應用的所有元件的狀態,並以相應的規則保證狀態以一種可預測的方式發生變化。

Vue 應用單元測試的策略與實踐 04 - Vuex 單元測試

古人說「讀史讓人明智」,學習歷史是為了更好得前行,為了能夠認識現在,看清未來。讓我們來看看 Vuex 的歷史,Vuex 借鑑於 Redux,而 Redux 的實現構想則最初出身於 Flux ,這是一個由 Facebook 為其應用所設計的應用程式架構。Flux 模式在 JavaScript 應用裡像是找到了新家一樣,但其實只是借鑑了領域驅動設計 (DDD) 和命令-查詢職責分離 (CQRS)。

CQRS 與 Flux 架構

描述 Flux 最普遍的一種的方式就是將其與 Model-View-Controller (MVC) 架構進行對比。

在 MVC 當中,一個 Model 可以被多個 Views 讀取,並且可以被多個 Controllers 進行更新。在大型應用當中,單個 Model 會導致多個 Views 去通知 Controllers,並可能觸發更多的 Model 更新,這樣結果就會變得非常複雜。

mvc-diagram

而 Flux 以及我們要學習的 Vuex 則是試圖通過強制單向資料流來解決這個複雜度。在這種架構當中,Views 查詢 Stores(而不是 Models),並且使用者互動將會觸發 Actions,Actions 則會被提交到一個集中的 Dispatcher 當中。當 Actions 被派發之後,Stores 將會隨之更新自己並且通知 Views 進行修改。這些 Store 當中的修改會進一步促使 Views 查詢新的資料。

flux-diagram

MVC 和 Flux 最大的不同就是查詢和更新的分離。在 MVC 中,Model 同時可以被 Controller 更新並且被 View 所查詢。在 Flux 裡,View 從 Store 獲取的資料是隻讀的。而 Stores 只能通過 Actions 被更新,這就會影響 Store 本身而不是那些只讀的資料。

以上所描述的模式非常接近於由 Greg Young 第一次所提出的 CQRS:

  1. 如果一個方法修改了這個物件的狀態,那就是一個 command(命令),並且一定不能返回值。
  2. 如果一個方法返回了一些值,那就是一個 query(查詢),並且一定不能修改狀態。

Vuex 背後的基本思想

所以說, Vuex 就是把元件的共享狀態 “state” 抽取出來,以一個全域性 “store” 的單例模式統一管理。在這種模式下,我們的元件樹構成了一個巨大的“檢視”,不管在樹的哪個位置,任何元件都能獲取狀態或者觸發行為。

另外,隔離狀態管理能夠獲得很多好處,當然也需要強制遵守一定的規則:

  1. Vuex 的狀態儲存是響應式的。當 Vue 元件從 store 中讀取狀態的時候,若 store 中的狀態發生變化,那麼相應的元件也會相應地得到高效更新。這也就是 CQRS 中 query(查詢)的一種實現。
  2. 你不能直接改變 store 中的狀態。改變 store 中的狀態的唯一途徑就是顯式地提交 (commit) mutation,這樣使得我們可以方便地跟蹤每一個狀態的變化。這也就是 CQRS 中 command(命令)的一種實現。

如何對 Vuex 進行單元測試

得益於 Vuex 能夠將 Vue 應用的共享狀態進行隔離,我們的程式碼也因此變得更加結構化且易於維護,Vuex 中的 mutation、action 和 getter 都被放在了合理的位置,承擔不同的職責 ,這也使得對它們進行單元測試變得容易很多。

mutations 測試

Mutation 很容易被測試,因為它們僅僅是一些完全依賴引數的函式。最為簡單的 mutation 測試,僅一一對應儲存資料切片。此種 mutation 可以不需要測試覆蓋,因為基本由架構簡單和邏輯簡單保證,不需要靠讀測試用例來理解。而一個較為複雜、具備測試價值的 mutation 在儲存資料的同時,還可能進行了合併、去重等操作。

// count.js
const state = { ... }
const actions = { ... }
export const mutations = {
  increment: state => state.count++
}
// count.test.js
import { mutations } from './store'

// 解構 `mutations`
const { increment } = mutations

describe('mutations', () => {
  it('INCREMENT', () => {
    // 模擬狀態
    const state = { count: 0 }
    // 應用 mutation
    increment(state)
    // 斷言結果
    expect(state.count).toEqual(1)
  })
})
複製程式碼

actions 測試

Action 應對起來略微棘手,因為它們可能需要呼叫外部的 API。當測試 action 的時候,我們需要增加一個 mocking 服務層——例如,我們可以把 API 呼叫抽象成服務,然後在測試檔案中用 mock 服務響應所期望的 API 呼叫。

// product.js
import shop from '../api/shop'

export const actions = {
  getAllProducts({ commit }) {
    commit('REQUEST_PRODUCTS')
    shop.getProducts(products => {
      commit('RECEIVE_PRODUCTS', products)
    })
  }
}
複製程式碼
// product.test.js
jest.mock('../api/shop', () => ({
  getProducts: jest.fn(() => /* mocked response */),
}))

describe('actions', () => {
  it('getAllProducts', () => {
    const commit = jest.spy()
    const state = {}
    
    actions.getAllProducts({ commit, state })
    
    expect(commit.args).toEqual([
      ['REQUEST_PRODUCTS'],
      ['RECEIVE_PRODUCTS', { /* mocked response */ }]
    ])
  })
})
複製程式碼

getters 測試

getter 的測試與 mutation 一樣直截了當。getters 也是比較重邏輯的地方,並且它也是一個純函式,與 mutations 測試享受同樣待遇:純淨的輸入輸出,簡易的測試準備。下面來看一個稍微簡單點的 getters 測試用例:

// product.js
export const getters = {
  filteredProducts (state, { filterCategory }) {
    return state.products.filter(product => {
      return product.category === filterCategory
    })
  }
}
複製程式碼
// product.test.js
import { expect } from 'chai'
import { getters } from './getters'

describe('getters', () => {
  it('filteredProducts', () => {
    // 模擬狀態
    const state = {
      products: [
        { id: 1, title: 'Apple', category: 'fruit' },
        { id: 2, title: 'Orange', category: 'fruit' },
        { id: 3, title: 'Carrot', category: 'vegetable' }
      ]
    }
    // 模擬 getter
    const filterCategory = 'fruit'

    // 獲取 getter 的結果
    const result = getters.filteredProducts(state, { filterCategory })

    // 斷言結果
    expect(result).to.deep.equal([
      { id: 1, title: 'Apple', category: 'fruit' },
      { id: 2, title: 'Orange', category: 'fruit' }
    ])
  })
})
複製程式碼

Vue 元件和 Vuex store 的互動

前面我們講完了 Vuex 單元測試所需要的基本知識,而 Vue 元件需要從 Vuex store 讀取狀態或者是傳送 action 改變 store 狀態的時候,又該如何測試他們之間的互動呢?接下來就來聊聊如何用 Vue Test Utils 測試 Vue 元件中的 Vuex。

站在單元測試的角度,其實我們在測試 Vue 元件(單元)的時候不需要關心 Vuex store 長什麼樣子,我們只需要知道 Vuex store 當中的這些 action 將會在適當的時機觸發,以及它們觸發時的預期行為是什麼。

<template>
  <div class="app">
    <div class="price">amount: ${{$store.state.price}}</div>
    <button @click="actionClick()">Buy</button>
  </div>
</template>

<script>
import { mapActions } from 'vuex'
export default {
  methods: {
    ...mapActions([
      'actionClick'
    ]),
  }
}
</script>
複製程式碼

在單元測試的時候,shallowMount(淺渲染)方法接受一個掛載 options,可以用來給 Vue 元件傳遞一個偽造的 store。然後我們就可以使用 Jest 模擬一個 action 的行為再傳給 store,而 actionClick 這個偽造函式能夠讓我們去斷言該 action 是否被呼叫過。所以我們在測試 action 的時候就可以只關心 action 的觸發,而至於觸發之後對 store 做了什麼事情我們就不需要再關心了,因為 Vuex 的單元測試會涵蓋相關的程式碼邏輯。

import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'

const fakeStore = new Vuex.Store({
  state: {},
  actions: {
    actionClick: jest.fn()
  }
})

const localVue = createLocalVue()
localVue.use(Vuex)

it('當按鈕被點選時候呼叫“actionClick”的 action', () => {
    const wrapper = shallowMount(Actions, { store: fakeStore, localVue })
    wrapper.find('button').trigger('click')
    expect(actions.actionClick).toHaveBeenCalled()
})
複製程式碼

需要注意的是,在這裡我們是把 Vuex store 傳遞給一個 localVue,而不是傳遞給基礎的 Vue 建構函式。這是因為我們不想影響到全域性的 Vue 建構函式,如果直接使用 Vue.use(Vuex) 會讓Vue 的原型上會增加 $store 屬性從而影響到其他的單元測試。而 localVue 則是一個獨立作用域的 Vue 建構函式,我們可以對其進行任意的改動。

當然咯,除了 mock 掉 actions,Vuex store 裡面的任何內容我們都可以將其模擬出來,比如 state 或者 getters:

import { shallowMount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'

const fakeStore = new Vuex.Store({
  state: {
    price: '998'
  },
  getters: {
    clicks: () => 2,
    inputValue: () => 'input'
  }
})

const localVue = createLocalVue()
localVue.use(Vuex)

it('在app中渲染價格和“state.inputValue”', () => {
  const wrapper = shallowMount(Components, { store: fakeStore, localVue })
  expect(wrapper.find('p').text()).toBe('input')  
  expect(wrapper.find('.price').text()).stringContaining('$998')
})
複製程式碼

總結一下

總之呢,不要測試 Vue 元件和 Vuex store 互動的時候引入一個真實的 Store,那樣就不再是單元測試了,還記得我們在第二篇單元測試基礎中所提到的社交型(Social Tests)還是獨立型(Solitary Tests)測試單元嗎?Vuex 等 Redux-like 架構在前端應用中的 “狀態管理模式” ,已經將 View 檢視層和 State 資料層儘可能合理得拆分與隔離,那麼單元測試就只需要分別測試 Vue 和 Vuex,從而就能保證 Vue 元件和資料流按照預期那樣工作。

未完待續……

## 單元測試基礎

  • [x] ### 單元測試與自動化的意義
  • [x] ### 為什麼選擇 Jest
  • [x] ### Jest 的基本用法
  • [x] ### 該如何測試非同步程式碼?

## Vue 單元測試

  • [x] ### Vue 元件的渲染方式
  • [x] ### Wrapper find() 方法與選擇器
  • [x] ### UI 元件互動行為的測試

## Vuex 單元測試

  • [x] ### CQRS 與 Redux-like 架構
  • [x] ### 如何對 Vuex 進行單元測試
  • [x] ### Vue元件和Vuex store的互動

## Vue應用測試策略

  • [ ] ### 單元測試的特點及其位置
  • [ ] ### 單元測試的關注點
  • [ ] ### 應用測試的測試策略

相關文章