例項入門 Vue.js 單元測試
作為一個以 文件豐富 而廣為人知的前端開發框架,Vue.js 的官方文件中分別在《教程-工具-單元測試》、《Cookbook-Vue元件的單元測試》裡對 Vue 元件的單元測試方法做出了介紹,並提供了官方的單元測試實用工具庫Vue Test Utils;甚至在狀態管理工具Vuex 的文件裡也不忘留出《測試》一章。
那是什麼原因讓 Vue.js 的開發團隊如此重視單元測試,要在這個同樣以 易於上手 為賣點的框架中大力科普呢?
官方文件中給出了非常清楚的說法:
元件的單元測試有很多好處: - 提供描述元件行為的文件 - 節省手動測試的時間 - 減少研發新特性時產生的 bug - 改進設計 - 促進重構 自動化測試使得大團隊中的開發者可以維護複雜的基礎程式碼。 複製程式碼
本文作為 ofollow,noindex">《對 React 元件進行單元測試》 一文的姊妹篇,將照貓畫虎式的嘗試面對初學和向中級進階的開發者,對單元測試在 Vue.js 技術棧 中的應用做出入門介紹。
I. 單元測試簡介
單元測試(unit testing),是指對軟體中的最小可測試單元進行檢查和驗證。
簡單來說, 單元
就是人為規定的最小的被測功能模組。單元測試是在軟體開發過程中要進行的最低級別的測試活動,軟體的獨立單元將在與程式的其他部分相隔離的情況下進行測試。
講解具體概念之前,先咀個栗子直觀瞭解下:
比如我們有這樣一個模組,暴露兩個方法用以對選單路徑進行一些處理:
// 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
- 根據失敗報錯,有針對性的編寫或改寫程式碼;這一步的唯一目的就是通過測試,先不必糾結細節
- 再次執行測試;如果能成功則跳到步驟5,否則重複步驟3
- 重構已經通過測試的程式碼,使其更可讀、更易維護,且不影響通過測試
- 重複步驟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
也就是土耳其首都 “伊斯坦布林”,這樣命名是因為土耳其地毯世界聞名,而地毯是用來"覆蓋"的:mask:。
回顧一下上面的圖:
表格中的第2列至第5列,分別對應了四個衡量維度:
if
測試結果根據覆蓋率被分為“綠色、黃色、紅色”三種,應該關注這些指標,測試越全面,就能提供更高的保證。
同時也沒有必要一味追求行覆蓋率,因為它會導致我們過分關注元件的內部實現細節,從而導致瑣碎的測試。
II. Vue.js 中的單元測試工具
2.1 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,由他欺騙特洛伊人 --- 後面的劇情大家就都熟悉了。
所以這個命名的測試工具呢,也正是各種偽裝滲透方法的合集,為單元測試提供了獨立而豐富的 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 在 Jest
和 Vue 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. 總結
單元測試作為一種經典的開發和重構手段,在軟體開發領域被廣泛認可和採用;前端領域也逐漸積累起了豐富的測試框架和方法。
單元測試可以為我們的開發和維護提供基礎保障,使我們在思路清晰、心中有底的情況下完成對程式碼的搭建和重構。
封裝好則測試易,反之不恰當的封裝讓測試變得困難。
可測試性是一個檢驗元件結構良好程度的實踐標準。