實例入門 Vue.js 單元測試

做爲一個以 文檔豐富 而廣爲人知的前端開發框架, Vue.js 的官方文檔中分別在《教程-工具-單元測試》、《Cookbook-Vue組件的單元測試》裏對 Vue 組件的單元測試方法作出了介紹,並提供了官方的單元測試實用工具庫 Vue Test Utils;甚至在狀態管理工具 Vuex 的文檔裏也不忘留出《測試》一章。javascript

那是什麼緣由讓 Vue.js 的開發團隊如此重視單元測試,要在這個一樣以 易於上手 爲賣點的框架中大力科普呢?html

官方文檔中給出了很是清楚的說法:前端

組件的單元測試有不少好處:

- 提供描述組件行爲的文檔
- 節省手動測試的時間
- 減小研發新特性時產生的 bug
- 改進設計
- 促進重構

自動化測試使得大團隊中的開發者能夠維護複雜的基礎代碼。
複製代碼

本文做爲《對 React 組件進行單元測試》一文的姊妹篇,將照貓畫虎式的嘗試面對初學和向中級進階的開發者,對單元測試在 Vue.js 技術棧 中的應用作出入門介紹。vue

I. 單元測試簡介

單元測試(unit testing),是指對軟件中的最小可測試單元進行檢查和驗證。java

簡單來講,單元就是人爲規定的最小的被測功能模塊。單元測試是在軟件開發過程當中要進行的最低級別的測試活動,軟件的獨立單元將在與程序的其餘部分相隔離的狀況下進行測試。git

對於開發活動中的各類測試,上圖是一種最多見的劃分方法:從下至上依次爲 單元測試->集成測試->端到端測試 ,隨着其集成度的遞增,對應的自動化程度遞減。ajax

端到端(在瀏覽器等真實場景中走通功能而把程序當成黑盒子的測試)與集成測試(集合多個測試過的單元一塊兒測試)的反饋與修復的週期比較長、運行速度慢,測試運行不穩定,因爲不少時候還要靠人工手動進行,維護成本也很高。而單元測試只針對具體一個方法或API,定位準確,採用 mock 機制,運行速度很是快(毫秒級),又是開發人員在本地執行,反饋修復及時,成本較低。vuex

咱們把絕大部分能在單元測試裏覆蓋的用例都放在單元測試覆蓋,只有單元測試測不了的,纔會經過端到端與集成測試來覆蓋。npm

講解單元測試的具體概念以前,先 咀個栗子 直觀瞭解下:

example

好比咱們有這樣一個模塊,暴露兩個方法用以對菜單路徑進行一些處理:json

// src/menuChecker.js

export function getRoutePath(str) {
  let to = ""
  //...
  return to;
}

export function getHighlight(str) {
  let hl = "";
  //...
  return hl;
}
複製代碼

編寫對應的測試文件:

import {
  getRoutePath,
  getHighlight
} from "@/menuChecker";

describe("檢查菜單路徑相關函數", ()=>{

  it("應該得到正確高亮值", ()=>{
    expect( getHighlight("/myworksheet/(.*)") ).toBe("myTickets");
  });

  it("應該爲未知路徑取得默認的高亮值", ()=>{
    expect( getHighlight("/myworksheet/ccc/aaa") ).toBe("mydefaulthl111");
  });

  it("應該補齊開頭的斜槓", ()=>{
    expect( getRoutePath("/worksheet/list") ).toBe('/worksheet/list');
  });

  it("應該能修正非法的路徑", ()=>{
    expect( getRoutePath("/myworksheet/(.*)") ).toBe("/myworksheet/list");
  });
});
複製代碼

運行該測試文件,獲得以下輸出:

運行結果能夠說很是友好了,雖然醒目的提示了 FAIL,可是哪條判斷錯了、錯在哪一行、實際的返回值與預期的區別,甚至代碼覆蓋率的表格,都分別展現了出來;尤爲是最重要的對錯結果,分別用綠色紅色加以展現。

真相只有一個,要麼是目標模塊寫的有問題,要麼是測試條件寫錯了 -- 總之咱們對其修正後從新運行:





由此,咱們對一次單元測試的過程有了基本的瞭解。

首先,對所謂「單元」的定義是靈活的,能夠是一個函數,能夠是一個模塊,也能夠是一個 Vue Component。

其次,因爲測試結果中,成功的用例會用綠色表示,而失敗的部分會顯示爲紅色,因此單元測試也經常被稱爲 「Red/Green Testing」 或 「Red/Green Refactoring」,其通常步驟能夠概括爲:

  1. 添加一個測試
  2. 運行全部測試,看看新加的這個測試是否是失敗了;若是能成功則重複步驟1
  3. 根據失敗報錯,有針對性的編寫或改寫代碼;這一步的惟一目的就是經過測試,先沒必要糾結細節
  4. 再次運行測試;若是能成功則跳到步驟5,不然重複步驟3
  5. 重構已經經過測試的代碼,使其更可讀、更易維護,且不影響經過測試
  6. 重複步驟1,直到全部功能測試完畢

1.1 測試框架

測試框架的做用是提供一些方便的語法來描述測試用例,以及對用例進行分組。

1.2 斷言(assertions)

斷言是單元測試框架中核心的部分,斷言失敗會致使測試不經過,或報告錯誤信息。

對於常見的斷言,舉一些例子以下:

  • 同等性斷言 Equality Asserts

    • expect(sth).toEqual(value)
    • expect(sth).not.toEqual(value)
  • 比較性斷言 Comparison Asserts

    • expect(sth).toBeGreaterThan(number)
    • expect(sth).toBeLessThanOrEqual(number)
  • 類型性斷言 Type Asserts

    • expect(sth).toBeInstanceOf(Class)
  • 條件性測試 Condition Test

    • expect(sth).toBeTruthy()
    • expect(sth).toBeFalsy()
    • expect(sth).toBeDefined()

1.3 斷言庫

斷言庫主要提供上述斷言的語義化方法,用於對參與測試的值作各類各樣的判斷。這些語義化方法會返回測試的結果,要麼成功、要麼失敗。常見的斷言庫有 Should.js, Chai.js 等。

1.4 測試用例 test case

爲某個特殊目標而編制的一組測試輸入、執行條件以及預期結果,以便測試某個程序路徑或覈實是否知足某個特定需求。

通常的形式爲:

it('should ...', function() {
	...
		
	expect(sth).toEqual(sth);
});
複製代碼

1.5 測試套件 test suite

一般把一組相關的測試稱爲一個測試套件

通常的形式爲:

describe('test ...', function() {
	
	it('should ...', function() { ... });
	
	it('should ...', function() { ... });
	
	...
	
});
複製代碼

1.6 spy

正如 spy 字面的意思同樣,咱們用這種「間諜」來「監視」函數的調用狀況

經過對監視的函數進行包裝,能夠經過它清楚的知道該函數被調用過幾回、傳入什麼參數、返回什麼結果,甚至是拋出的異常狀況。

var spy = sinon.spy(MyComp.prototype, 'someMethod');

...

expect(spy.callCount).toEqual(1);
複製代碼

1.7 stub

有時候會使用stub來嵌入或者直接替換掉一些代碼,來達到隔離的目的

一個stub可使用最少的依賴方法來模擬該單元測試。好比一個方法可能依賴另外一個方法的執行,然後者對咱們來講是透明的。好的作法是使用stub 對它進行隔離替換。這樣就實現了更準確的單元測試。

var myObj = {
	prop: function() {
		return 'foo';
	}
};

sinon.stub(myObj, 'prop').callsFake(function() {
    return 'bar';
});

myObj.prop(); // 'bar'
複製代碼

1.8 mock

mock通常指在測試過程當中,對於某些不容易構造或者不容易獲取的對象,用一個虛擬的對象來建立以便測試的測試方法

廣義的講,以上的 spy 和 stub 等,以及一些對模塊的模擬,對 ajax 返回值的模擬、對 timer 的模擬,都叫作 mock 。

1.9 測試覆蓋率(code coverage)

用於統計測試用例對代碼的測試狀況,生成相應的報表,好比 istanbul 是常見的測試覆蓋率統計工具。

istanbul 也就是土耳其首都 「伊斯坦布爾」,這樣命名是由於土耳其地毯世界聞名,而地毯是用來"覆蓋"的😷。

回顧一下上面的圖:

表格中的第2列至第5列,分別對應了四個衡量維度:

  • 語句覆蓋率(statement coverage):是否每一個語句都執行了
  • 分支覆蓋率(branch coverage):是否每一個if代碼塊都執行了
  • 函數覆蓋率(function coverage):是否每一個函數都調用了
  • 行覆蓋率(line coverage):是否每一行都執行了

測試結果根據覆蓋率被分爲「綠色、黃色、紅色」三種,應該關注這些指標,測試越全面,就能提供更高的保證。

同時也沒有必要一味追求行覆蓋率,由於它會致使咱們過度關注組件的內部實現細節,從而致使瑣碎的測試。

II. Vue.js 中的單元測試工具

2.1 Jest

jest

不一樣於"傳統的"(其實也沒出現幾年)的 jasmine / Mocha / Chai 等前端測試框架;Jest的使用更簡單(也許就是這個單詞的本意「俏皮話、玩笑話」的意思),而且提供了更高的集成度、更豐富的功能。

Jest 是一個由 Facebook 開發的測試運行器,相對其餘測試框架,其特色就是就是內置了經常使用的測試工具,好比自帶斷言、測試覆蓋率工具,實現了開箱即用。

此外, Jest 的測試用例是並行執行的,並且只執行發生改變的文件所對應的測試,提高了測試速度。

配置

Jest 號稱本身是一個 「Zero configuration testing platform」,只需在 npm scripts裏面配置了test: jest,便可運行npm test,自動識別並測試符合其規則的( Vue.js 項目中通常是 __tests__ 目錄下的)用例文件。

實際使用中,適當的在 package.json 的 jest 字段或獨立的 jest.config.js 裏自定義配置一下,會獲得更適合咱們的測試場景。

參考文檔 vue-test-utils.vuejs.org/zh/guides/t… ,能夠很快在 Vue.js 項目中配置好 Jest 測試環境。

四個基礎單詞

編寫單元測試的語法一般很是簡單;對於jest來講,因爲其內部使用了 Jasmine 2 來進行測試,故其用例語法與 Jasmine 相同。

實際上,只要先記這住四個單詞,就足以應付大多數測試狀況了:

  • describe: 定義一個測試套件
  • it:定義一個測試用例
  • expect:斷言的判斷條件
  • toEqual:斷言的比較結果
describe('test ...', function() {
	it('should ...', function() {
		expect(sth).toEqual(sth);
		expect(sth.length).toEqual(1);
		expect(sth > oth).toEqual(true);
	});
});
複製代碼

2.2 sinon

sinon

圖中這位「我牽着馬」的並非捲簾大將沙悟淨...其實圖中的故事正是人所皆知的「特洛伊木馬」;大概意思就是希臘人圍困了特洛伊人十多年,久攻不下,心生一計,把營盤都撤了,只留下一個巨大的木馬(裏面裝着士兵),以及這位被扒光還被打得夠嗆的人,也就是此處要談的主角 sinon,由他欺騙特洛伊人 --- 後面的劇情你們就都熟悉了。

因此這個命名的測試工具呢,也正是各類假裝滲透方法的合集,爲單元測試提供了獨立而豐富的 spy, stub 和 mock 方法,兼容各類測試框架。

雖然 Jest 自己也有一些實現 spy 等的手段,但 sinon 使用起來更加方便。

2.3 Vue Test Utils

Vue Test Utils 是 Vue.js 官方的單元測試實用工具庫;該工具庫使用起來和用以測試 React 組件的 Enzyme 工具庫很是類似

它模擬了一部分相似 jQuery 的 API,很是直觀而且易於使用和學習,提供了一些接口和幾個方法來減小測試的樣板代碼,方便判斷、操縱和遍歷 Vue Component 的輸出,而且減小了測試代碼和實現代碼之間的耦合。

通常使用其 mount()shallowMount() 方法,將目標組件轉化爲一個 Wrapper 對象,並在測試中調用其各類方法,例如:

import { mount } from '@vue/test-utils'
import Foo from './Foo.vue'

describe('Foo', () => {
  it('renders a div', () => {
    const wrapper = mount(Foo)
    expect(wrapper.contains('div')).toBe(true)
  })
})
複製代碼

III. 一個 Vue.js 的單元測試實例

3.1 又一個栗子

import { shallowMount } from "@vue/test-utils";
import Vue from 'vue';
import VueI18n from 'vue-i18n';
import i18nMessage from '@/i18n';
import Comp from "@/components/Device.vue";

const fakeData = { //假數據
  deviceNo: "abcdefg",
  deviceSpace: 45,
  deviceStatus: 2,
  devices: [
    {
      id: "test001",
      location: "12",
      status: 1
    },
    {
      id: "test002",
      location: "58",
      status: 3
    },
    {
      id: "test003",
      location: "199",
      status: 4
    }
  ]
};


Vue.use(VueI18n); //重現必要的依賴
const i18n = new VueI18n({
  locale: 'zh-CN',
  silentTranslationWarn: true,
  missing: (locale, key, vm) => key,
  messages: i18nMessage
});

let wrapper = null;
const makeWrapper = ()=>{
  wrapper = shallowMount( Comp, {
    i18n, //看這裏
    propsData: { //還有這裏
      unitHeight: 5,
      data: fakeData
    }
  } );
};

afterEach(()=>{ //也很常見的用法
	if (!wrapper) return;
	wrapper = null;
});

describe("test Device.vue", ()=>{

  it("should be a VUE instance", ()=>{
    makeWrapper();
    expect( wrapper.isVueInstance() ).toBeTruthy();
  });

  it("應該有正常的總高度", ()=>{
    makeWrapper();
    expect( wrapper.vm.totalHeight ).toBe( 1230 );
  });

  it("應該渲染正確的設備數量", ()=>{
    makeWrapper();
    expect( wrapper.findAll('.deviceitem').length ).toBe( 3 );
  });

  it("指定的設備應該在正確的位置", ()=>{
    makeWrapper();
    const sty = wrapper.findAll('.deviceitem').at(1).attributes('style');
    expect( sty ).toMatch( /height\:\s*20px/ );
    expect( sty ).toMatch( /bottom\:\s*20px/ );
  });

  it("應該渲染正確的tooltip", ()=>{
    makeWrapper();

	//這裏的用法值得注意
    const popper_ref = wrapper.find({ref: 'device_tooltip_test002'});
    expect( popper_ref.exists() ).toBeTruthy();

    const cont = popper_ref.find('.tooltip_cont');
    expect( cont.html() ).toMatch(/所在位置\:\s58/);
  });
  
  it("應該渲染正確的設備分類", ()=>{
    makeWrapper();

    const badge = wrapper.find('.badge');
    expect( badge.exists() ).toBeTruthy();

    expect( badge.findAll('li').length ).toBe(4);
    expect( badge.findAll('li').at(2).text() ).toBe('噴霧設備');
	});
	
  it("當點擊了關閉按鈕,應該再也不顯示", (done)=>{ //異步的用例
    makeWrapper();
    wrapper.vm.$nextTick(()=>{ //再看這裏
      expect( 
        wrapper.find('.devices_container').exists()
      ).toBeFalsy();
      done();
    });
  });

});
複製代碼

這裏無需逐條的解釋,主要的 API 在 JestVue Test Utils 的文檔裏都能找到。

其中值得注意的小經驗,一是一些異步更新(好比代碼中有延時)後正確使用 wrapper.vm.$nextTick;二是對於一些掛載到 document.body 等外部位置的組件元素,要靠 wrapper.find({ref: xxx}) 取得其引用。

3.2 整合到工做流中

寫好的單元測試,若是僅僅要靠每次 npm test 手動執行,必然會有日久忘記、逐漸過期,最後甚至沒法執行的狀況。

有多個時間點能夠做爲選擇,插入自動執行單元測試 -- 例如每次保存文件、每次執行 build 等;此處咱們選擇了一種很簡單的配置辦法:

首先在項目中安裝 pre-commit 依賴包;而後在 package.json 中配置 npm scripts :

"scripts": {
  ...
  "test": "jest"
},
"pre-commit": [
  "test"
],
複製代碼

這樣在每次 git commit 以前,項目中存在的單元測試就會自動執行一次,每每就避免了 「改一個 bug,送十個新 bug」 的窘況。

IV. 用單元測試改善 Vue.js 組件

單元測試除了減小錯誤,另外一個顯著的好處是能讓咱們組件化的思路愈來愈清晰,養成日益良好的習慣。

一個被驗證過針對給定的輸入會渲染出符合指望的輸出的組件,稱爲 測試經過的 組件;

一個 可測試的(testable) 組件意味着其易於測試

如何確保一個組件如指望的工做呢?

咱們可能習慣於依靠雙手和眼睛,一次次的驗證咱們寫過的組件;但若是你打算對每一個組件的每一個改動都手動驗證的話,或早或晚就會由於疲憊或懈怠,致使瑕疵留在代碼中。

這就是自動化的單元測試爲什麼重要的緣由。單元測試保證了每次對組件作出的更改後,組件都能正確工做。

單元測試並不僅與早期發現 bug 有關。另外一個重要的方面是用其檢驗組件架構化水平優劣的能力。

一個 沒法測試難以測試 的組件,基本上就等同於 設計得很拙劣 的組件.

組件之因此難以測試,是由於其有太多的 props、依賴、引用的模型和對全局變量的訪問 -- 這都是不良設計的標誌。

一個設計不佳的組件,就會變成沒法測試的,進而你就會簡單的跳過單元測試,又致使了其保持未測試狀態,變成一個惡性循環。

4.1 但願是最後一個栗子

假設要對 NumStepper.vue 組件進行測試

//NumStepper.vue

<template>
  <div>
    <button class="plus" v-on:click="updateNumber(+1)">加</button>
    <button class="minus" v-on:click="updateNumber(-1)">減</button>
    <button class="zero" v-on:click="clear">清</button>
  </div>
</template>

<script>
export default {
  props: {
    targetData: Object,
    clear: Function
  },
  methods: {
    updateNumber: function(n) {
      this.targetData.num += n;
    }
  }
}
</script>
複製代碼

該組件又依賴一個外層組件給其提供數據和方法:

//NumberDisplay.vue

<template>
  <div>
    <p>{{somedata.num}}</p>
    <NumStepper :targetData="somedata" :clear="clear" />
  </div>
</template>

<script>
import NumStepper from "./NumStepper"

export default {
  components: {
    NumStepper
  },
  data() {
    return {
      somedata: {
        num: 999
      },
      tgt: this
    }
  },
  methods: {
    clear: function() {
      this.somedata.num = 0;
    }
  }
}
</script>
複製代碼

這樣一來,咱們的測試就得這樣寫:

import { shallowMount } from "@vue/test-utils";
import Vue from 'vue';
import NumStepper from '@/components/NumStepper';
import NumberDisplay from '@/components/NumberDisplay';

describe("測試 NumStepper 組件", ()=>{
  it("應該可以影響外層組件的數據", ()=>{

    const display = shallowMount(NumberDisplay);

    const wrapper = shallowMount(NumStepper, {
      propsData: {
        targetData: display.vm.somedata,
        clear: display.vm.clear
      }
    });

    expect(display.vm.somedata.num).toBe(999);

    wrapper.find('.plus').trigger('click');
    wrapper.find('.plus').trigger('click');
    expect(display.vm.somedata.num).toBe(1001);

    wrapper.find('.minus').trigger('click');
    expect(display.vm.somedata.num).toBe(1000);

    wrapper.find('.zero').trigger('click');
    expect(display.vm.somedata.num).toBe(0);
  })
});
複製代碼

<NumStepper> 測試起來很是複雜,由於它關聯了外部組件的實現細節。

測試場景中須要一個額外的 <NumberDisplay> 組件,用來重現外部組件、向目標組件傳遞數據和方法,並檢驗目標組件是否正確修改了外部組件的狀態。

不難想象,假如 <NumberDisplay> 組件再依賴其餘組件或環境變量、全局方法等,事情將變得更糟糕,可能須要單獨實現若干測試專用組件,甚至根本沒法測試。

4.2 真正的最後一個栗子

<NumStepper> 獨立於外部組件的細節時,測試就簡單了。讓咱們實現並測試一下合理封裝版本的 <NumStepper> 組件:

//NumStepper2.vue

<template>
  <div>
    <button class="plus" v-on:click="updateFunc(+1)">加</button>
    <button class="minus" v-on:click="updateFunc(-1)">減</button>
    <button class="zero" v-on:click="clearFunc">清</button>
  </div>
</template>

<script>
export default {
  props: {
    updateFunc: Function,
    clearFunc: Function
  }
}
</script>
複製代碼

在測試中,就不用引入額外的組件了:

import { shallowMount } from "@vue/test-utils";
import Vue from 'vue';
import NumStepper from '@/components/NumStepper2';

describe("測試 NumStepper 組件", ()=>{
  it("應該可以影響外層組件的數據", ()=>{

    const obj = {
      func1: function(){},
      func2: function(){}
    };

    const spy1 = jest.spyOn(obj, "func1");
    const spy2 = jest.spyOn(obj, "func2");

    const wrapper = shallowMount(NumStepper, {
      propsData: {
        updateFunc: spy1,
        clearFunc: spy2
      }
    });

    wrapper.find('.plus').trigger('click');
    expect(spy1).toHaveBeenCalled();

    wrapper.find('.minus').trigger('click');
    expect(spy1).toHaveBeenCalled();

    wrapper.find('.zero').trigger('click');
    expect(spy2).toHaveBeenCalled();
  })
});
複製代碼

注:該示例中只是檢驗了是否被點擊,還能夠引入 sinon 的相關方法檢驗傳入的參數等,寫出更完備的測試。

V. 總結

單元測試做爲一種經典的開發和重構手段,在軟件開發領域被普遍承認和採用;前端領域也逐漸積累起了豐富的測試框架和方法。

單元測試能夠爲咱們的開發和維護提供基礎保障,使咱們在思路清晰、心中有底的狀況下完成對代碼的搭建和重構。

封裝好則測試易,反之不恰當的封裝讓測試變得困難。

可測試性是一個檢驗組件結構良好程度的實踐標準。

VI. 參考資料



--END--
相關文章
相關標籤/搜索