Vue 測試速成班

  • 2020 年 2 月 11 日
  • 筆記

原文地址:https://dev.to/blacksonic/vue-testing-crash-course-59kl 原文作者:Gábor Soós 譯者:馬雪琴 聲明:本翻譯僅做學習交流使用,轉載請註明來源。

在你快要完成一個項目時,突然工程里的很多地方都出現了 bug,你修完一個又冒出新的一個,就像在玩打地鼠遊戲一樣……幾輪下來,你會感到一團糟。

此時有一個可以讓你的項目再次發光的解救方案,那就是為將要開發的和已經存在的特性編寫測試。編寫測試可以保證功能特性沒有 bug。

在本教程中,我將向你展示如何為 Vue 應用程式編寫單元、集成和端到端測試。

有關更多測試示例,可以查看我的 Vue TodoApp 實現[1]。

1. 類型

我們可以編寫三種類型的測試:單元測試、集成測試和端到端測試。下面這個金字塔可以幫助我們理解這些測試類型。

在金字塔下端的測試寫起來更容易,運行起來更快,也更容易維護。但是,為什麼我們不能只寫單元測試呢?因為金字塔上端的測試可以幫助我們檢查系統里的各個組件之間是否能很好地協同工作,使我們對系統更有把握。

單元測試只能被單獨使用在單個程式碼單元(類、函數)里;集成測試可以檢查多個單元是否能按預期協同工作(組件層次結構、組件 + 存儲);端到端測試則是從外部世界觀察應用程式:瀏覽器及其交互。

2. 測試運行器

對於新的 Vue 項目,添加測試的最簡單方法是使用 Vue CLI[2]。在生成項目(執行 vue create myapp)時,你必須手動選擇單元測試和 E2E 測試。

安裝完成後,package.json 中將出現下面幾個附加依賴項:

•@vue/cli-plugin-unit-mocha: 使用 Mocha[3] 進行單元/集成測試的插件•@vue/test-utils: 單元/集成測試的工具庫[4]•chai: 斷言庫 Chai[5]

從現在開始,單元/集成測試文件可以使用 *.spec.js 後綴寫在 tests/unit 目錄中。測試的目錄不是硬連線的,你可以用下面的命令行參數來修改它:

vue-cli-service test:unit --recursive 'src/**/*.spec.js'

recursive 參數告訴測試運行器依據後面的通配符模式來搜索測試文件。

3. 單元測試

到目前為止,一切順利,但是我們還沒有編寫任何測試。接下來我們將編寫第一個單元測試!

describe('toUpperCase', () => {    it('should convert string to upper case', () => {      // 準備      const toUpperCase = info => info.toUpperCase();        // 操作      const result = toUpperCase('Click to modify');        // 斷言      expect(result).to.eql('CLICK TO MODIFY');    });  });

上面的例子驗證了 toUpperCase 函數是否將傳入的字元串轉換為了大寫字母。

首先是準備工作,導入函數、實例化對象並設置其參數,讓目標對象(這裡是一個函數)進入一個可測試的狀態。然後操作該功能/方法。最後我們對函數返回的結果進行斷言。

Mocha 提供了 describeit 兩個方法。describe 函數表示圍繞測試單元組織測試用例:測試單元可以是類、函數、組件等。Mocha 沒有內置的斷言庫,所以我們必須使用 Chai :它可以設置對結果的期望。Chai 有許多不同的內置斷言,但沒有涵蓋所有用例,缺失的斷言可以通過 Chai 的插件系統導入。

大多數時候,你還將為組件層次結構之外的業務邏輯編寫單元測試,例如,狀態管理或後端 API 處理。

4. 組件展示

下一步是為組件編寫集成測試。集成測試不只是測試 Javascript 程式碼,還會測試 DOM 和相應組件邏輯之間的交互。

// src/components/Footer.vue  <template>    <p class="info">{{ info }}</p>    <button @click="modify">Modify</button>  </template>  <script>    export default {      data: () => ({ info: 'Click to modify' }),      methods: {        modify() {          this.info = 'Modified by click';        }      }    };  </script>

我們測試的第一個組件是一個渲染其狀態並在單擊按鈕時修改狀態的組件。

// test/unit/components/Footer.spec.js  import { expect } from 'chai';  import { shallowMount } from '@vue/test-utils';  import Footer from '@/components/Footer.vue';    describe('Footer', () => {    it('should render component', () => {      const wrapper = shallowMount(Footer);        const text = wrapper.find('.info').text();      const html = wrapper.find('.info').html();      const classes = wrapper.find('.info').classes();      const element = wrapper.find('.info').element;        expect(text).to.eql('Click to modify');      expect(html).to.eql('<p class="info">Click to modify</p>');      expect(classes).to.eql(['info']);      expect(element).to.be.an.instanceOf(HTMLParagraphElement);    });  });

要在測試中渲染組件,我們必須使用 Vue 測試工具庫中的 shallowMountmount。這兩個方法都會渲染組件,但是 shallowMount 不會渲染子組件(子元素將是空元素)。當需要引入某個組件進行測試時,我們可以以相對路徑引用 ../../../src/components/Footer.vue 或使用別名 @,路徑開頭的 @ 符號表示對源文件夾 src 的引用。

我們可以使用 find 選擇器在渲染的 DOM 中搜索並獲取它的 HTML、文本、類名或原生 DOM 元素。如果搜索的是一個可能不存在的片段,我們可以使用 exists 方法判斷它是否存在。上述各種斷言只是為了示意各種情況,實際在測試用例中寫其中一個斷言就夠了。

5. 組件交互

我們已經測試了 DOM 的渲染,但還沒有與組件進行任何交互。我們可以通過 DOM 或組件實例與組件交互:

it('should modify the text after calling modify', () => {    const wrapper = shallowMount(Footer);      wrapper.vm.modify();      expect(wrapper.vm.info).to.eql('Modified by click');  });

上面的例子展示了如何使用組件實例來實現交互。我們可以使用 vm 屬性訪問組件實例,還可以通過組件實例訪問到組件 method 中的方法和 data 對象(狀態)里的屬性。

另一種方法是通過 DOM 與組件交互,我們可以觸發按鈕上的單擊事件並觀察是否顯示文本:

it('should modify the text after clicking the button', () => {    const wrapper = shallowMount(Footer);      wrapper.find('button').trigger('click');    const text = wrapper.find('.info').text();      expect(text).to.eql('Modified by click');  });

觸發 buttonclick 事件等同於在組件實例上調用 modify 方法。

6. 父子組件交互

上面我們單獨測試了組件,但實際應用程式由多個部分組成。父組件通過 props 與子組件通訊,子組件通過觸發事件與父組件通訊。

我們可以通過修改傳入組件的 props 來更新組件的展示文案,並通過事件將改動通知給父組件。

export default {    props: ['info'],    methods: {      modify() {        this.$emit('modify', 'Modified by click');      }    }  };

在接下來的測試中,我們需要把 props 作為輸入,並監聽觸發的事件。

it('should handle interactions', () => {    const wrapper = shallowMount(Footer, {      propsData: { info: 'Click to modify' }    });      wrapper.vm.modify();      expect(wrapper.vm.info).to.eql('Click to modify');    expect(wrapper.emitted().modify).to.eql([      ['Modified by click']    ]);  });

shallowMountmount 方法的第二個參數是一個可選參數,我們可以使用 propsData 設置輸入的 props。觸發的事件可以通過調用 emitted 方法獲得,得到的結果是一個對象,key 是事件的名稱,value 是事件參數數組。

6. store 集成

在前面的例子中,狀態都在組件內部。而在複雜的應用程式中,我們需要在不同的位置訪問和改變相同的狀態。Vuex[6] 是 Vue 的狀態管理庫,它可以幫助你在一個地方組織狀態管理,並確保其可預測地發生變化。

const store = {    state: {      info: 'Click to modify'    },    actions: {      onModify: ({ commit }, info) => commit('modify', { info })    },    mutations: {      modify: (state, { info }) => state.info = info    }  };  const vuexStore = new Vuex.Store(store);

上面的 store 有一個單一的狀態屬性,它與我們在上面的組件中設置的一樣。我們可以使用 onModify 操作修改狀態,該操作將輸入參數傳遞給名為 modify 的 mutation 來改變狀態。

首先,我們可以給 store 里的每個方法單獨編寫單元測試:

it('should modify state', () => {    const state = {};      store.mutations.modify(state, { info: 'Modified by click' });      expect(state.info).to.eql('Modified by click');  });

我們也可以構建 store 來編寫集成測試,從而檢查整體是否能不拋出錯誤,正常運行:

import Vuex from 'vuex';  import { createLocalVue } from '@vue/test-utils';    it('should modify state', () => {    const localVue = createLocalVue();    localVue.use(Vuex);    const vuexStore = new Vuex.Store(store);      vuexStore.dispatch('onModify', 'Modified by click');      expect(vuexStore.state.info).to.eql('Modified by click');  });

首先,我們必須創建一個 Vue 的局部實例,然後使用 use 語句。如果我們不調用 use 方法,將會拋出一個錯誤。通過創建 Vue 的局部副本,我們還可以避免污染全局對象。

我們可以通過 dispatch 方法改變 store。第一個參數表示調用哪個 action;第二個參數作為參數傳遞給 action。我們可以隨時通過 state 屬性檢查當前狀態。

當使用組件的 store 時,我們必須將局部 Vue 實例和 store 實例傳遞給 mount 函數。

const wrapper = shallowMount(Footer, { localVue, store: vuexStore });

8. 路由

測試路由的設置與測試 store 有點類似,必須創建 Vue 實例的局部副本和路由實例,使用路由實例作為插件,然後創建組件。

<div class="route">{{ $router.path }}</div>

上面這行組件模板將渲染當前路由路徑。在測試中,我們可以斷言這個元素的內容。

import VueRouter from 'vue-router';  import { createLocalVue } from '@vue/test-utils';    it('should display route', () => {    const localVue = createLocalVue();    localVue.use(VueRouter);    const router = new VueRouter({      routes: [        { path: '*', component: Footer }      ]    });      const wrapper = shallowMount(Footer, { localVue, router });    router.push('/modify');    const text = wrapper.find('.route').text();      expect(text).to.eql('/modify');  });

我們用 * 路徑為組件添加了一個全匹配路由。有了 router 實例後,我們還需要使用路由器的 push 方法為應用程式設置導航。

創建所有路由可能會是一項耗時的任務,我們可以實現一個偽路由器,將其作為一個 mock 數據傳遞:

it('should display route', () => {    const wrapper = shallowMount(Footer, {      mocks: {        $router: {          path: '/modify'        }      }    });    const text = wrapper.find('.route').text();      expect(text).to.eql('/modify');  });

我們也可以在 mocks 中定義一個 $store 屬性來 mock store。

9. HTTP 請求

初始狀態通常是通過 HTTP 請求得到的。我們很容易在測試中完成真實的請求,但這會使得測試變得脆弱,並且對外部形成依賴。為了避免這種情況,我們可以在運行時更改請求的實現。在運行時更改實現稱為 mocking,我們將使用 Sinon[7] 這一 mocking 框架來實現。

const store = {    actions: {      async onModify({ commit }, info) {        const response = await axios.post('https://example.com/api', { info });        commit('modify', { info: response.body });      }    }  };

我們在上面這段程式碼中修改了 store 的實現:首先輸入參數通過 POST 請求被發送,然後將該請求得到的結果傳遞給 mutation。程式碼變成了非同步,並有了一個外部依賴項,外部依賴項將是我們在運行測試之前必須更改(mock)的項。

import chai from 'chai';  import sinon from 'sinon';  import sinonChai from 'sinon-chai';  chai.use(sinonChai);    it('should set info coming from endpoint', async () => {    const commit = sinon.stub();    sinon.stub(axios, 'post').resolves({      body: 'Modified by post'    });      await store.actions.onModify({ commit }, 'Modified by click');      expect(commit).to.have.been.calledWith('modify', { info: 'Modified by post' });  });

我們為 commit 方法創建了一個偽實現,並更改了 axios.post 的原始實現。這些偽實現可以捕獲傳遞給它們的參數,並用我們要求它們返回的內容進行響應。我們沒有為 commit 方法指定返回值,所以它將返回一個空值。axios.post 將返回一個 promise,該 promise 被解析為帶有 body 屬性的對象。

我們必須將 sinonChai 作為一個插件添加到 Chai 中,以便能夠對調用簽名進行斷言。這個插件擴展了 Chaito.have.been 屬性和 to.have.been.calledWith 方法。

如果我們返回一個 Promise,測試函數將變成非同步的。Mocha 可以檢測並等待非同步函數完成。在函數內部,我們等待 onModify 方法完成,然後斷言偽 commit 方法是否被調用並傳入了 post 調用返回的參數。

10. 瀏覽器

從程式碼的角度來看,我們已經測試到了應用程式的各個方面。但有一個問題我們仍然不能回答:應用程式可以在瀏覽器中運行嗎?使用 Cypress 編寫的端到端測試可以告訴我們答案。

Vue CLI 提供如下功能:啟動應用程式並在瀏覽器中運行 Cypress 測試,然後關閉應用程式。如果你想在 headless 模式[8]下運行 Cypress 測試,你必須將 headless 標記添加到命令中。

describe('New todo', () => {    it('it should change info', () => {      cy.visit('/');        cy.contains('.info', 'Click to modify');        cy.get('button').click();        cy.contains('.info', 'Modified by click');    });  });

上述測試程式碼的組織結構與單元測試相同:describe 代表測試分組,it 代表測試運行。全局變數 cy 表示 Cypress 運行器。我們可以同步地命令運行程式在瀏覽器中執行什麼操作。

在訪問了主頁(visit)之後,我們可以通過 CSS 選擇器訪問頁面中的 HTML。我們可以使用 contains 來斷言元素的內容。頁面交互也是相同的方式:首先,選擇元素(get),然後進行交互(click)。在測試的最後,我們檢查內容是否更改。

總結

我們已經介紹完了所有的測試用例,從一個函數的基本單元測試到在實際瀏覽器中運行的端到端測試。在本文中,我們為 Vue 應用程式的構建塊(組件、存儲、路由)創建了集成測試,並介紹了 mocking 實現的一些基礎。你可以在現有的或未來的項目中使用這些技術來避免程式上的 bug。希望本文能降低大家為 Vue 應用程式編寫測試的門檻。

本文中的示例闡明了測試相關的許多事情,希望你們喜歡!

References

[1] Vue TodoApp 實現: https://github.com/blacksonic/todoapp-vue [2] Vue CLI: https://cli.vuejs.org/ [3] Mocha: https://mochajs.org/ [4] 工具庫: https://vue-test-utils.vuejs.org/ [5] Chai: https://chaijs.com/ [6] Vuex: https://vuex.vuejs.org/ [7] Sinon: https://sinonjs.org/ [8] headless 模式: https://developer.mozilla.org/zh-CN/docs/Mozilla/Firefox/Headless_mode