大家好,我是若川。今天分享一篇vue項目如何做單元測試的好文,文章比較長,建議先收藏,需要時用電腦看。
點擊下方卡片關注我、加個星標
學習源碼系列、年度總結、JS基礎系列
關于單元測試,最常見的問題應該就是“前端單元測試有必要嗎?”,通過這篇文章,你將會了解單元測試的必要性,以及在Vue項目中如何能夠全面可靠的測試我們寫的組件。
單元測試的必要性
一般在我們的印象里,單元測試都是測試工程師的工作,前端負責代碼就行了;百度搜索Vue單元測試,聯想詞出來的都是“單元測試有必要嗎?”
“單元測試是做什么的?”
雖然我們平時項目中一般都會有測試工程師來對我們的頁面進行測試“兜底”,但是根據我的觀察,一般測試工程師并不會覆蓋所有的業務邏輯,而且有一些深層次的代碼邏輯測試工程師在不了解代碼的情況下也根本無法進行觸發。因此在這種情況下,我們并不能夠完全的依賴測試工程師對我們項目測試,前端項目的單元測試就顯得非常的有必要。
而且單元測試也能夠幫助我們節省很大一部分自我測試的成本,假如我們有一個訂單展示的組件,根據訂單狀態的不同以及其他的一些業務邏輯來進行對應文案的展示;我們想在頁面上查看文案展示是否正確,這時就需要繁瑣的填寫下單信息后才能查看;如果第二天又又加入了一些新的邏輯判斷(你前一天下的單早就過期啦),這時你有三個選擇,第一種選擇就是再次繁瑣地填寫訂單并支付完(又給老板提供資金支持了),第二種選擇就是死皮賴臉的求著后端同事給你更改訂單狀態(后端同事給你一個白眼自己體會),第三種選擇就是代理接口或者使用mock數據(你需要編譯整個項目運行進行測試)。
這時,單元測試就提供了第四種成本更低的測試方式,寫一個測試用例,來對我們的組件進行測試,判斷文案是否按照我們預想的方式進行展示;這種方式既不需要依賴后端的協助,也不需要對項目進行任何改動,可謂是省時又省力。
測試框架和斷言庫
說到單元測試,我們首先來介紹一下流行的測試框架,主要是mocha和jest。先簡單介紹下mocha,翻譯成中文就是摩卡
(人家是一種咖啡!不是抹茶啊),名字的由來估猜是因為開發人員喜歡喝摩卡咖啡,就像Java名字也是從咖啡由來一樣,mocha的logo也是一杯摩卡咖啡:
和jest相比,兩者主要的不同就是jest內置了集成度比較高的斷言庫expect.js
,而mocha需要搭配額外的斷言庫,一般會選擇比較流行的chai
作為斷言庫,這里一直提到斷言庫,那么什么是斷言庫呢?我們首先來看下mocha是怎么來測試代碼的,首先我們寫了一個addNum函數
,但是不確定是否返回我們想要的結果,因此需要對這個函數進行測試:
//src/index.js
function?addNum(a,?b)?{return?a?+?b;
}
module.exports?=?addNum;
然后就可以寫我們的測試文件了,所有的測試文件都放在test目錄下,一般會將測試文件和所要測試的源碼文件同名,方便進行對應,運行mocha時會自動對test目錄下所有js文件進行測試:
//test/index.test.js
var?addNum?=?require("../src/index");
describe("測試addNum函數",?()?=>?{it("兩數相加結果為兩個數字的和",?()?=>?{if?(addNum(1,?2)?!==?3)?{throw?new?Error("兩數相加結果不為兩個數字的和");}});
});
上面這段代碼就是測試腳本的語法,一個測試腳本會包括一個或多個describe
塊,每個describe
又包括一個或多個it
塊;這里describe
稱為測試套件
(test suite),表示一組相關的測試,它包含了兩個參數,第一個參數是這個測試套件
的名稱,第二個參數是實際執行的函數。
而it
稱為測試用例
,表示一個單獨的測試,是測試的最小單位,它也包含兩個參數,第一個參數是測試用例
的名稱,第二個參數是實際執行的函數。
it
塊中就是我們需要測試的代碼,如果運行結果不是我們所預期的就拋出異常;上面的測試用例寫好后,我們就可以運行測試了,

運行結果通過了,是我們想要的結果,說明我們的函數是正確的;但是每次都通過拋出異常來判斷,多少有點繁瑣了,斷言庫就出現了;斷言的目的就是將測試代碼運行后和我們的預期做比較,如果和預期一致,就表明代碼沒有問題;如果和預期不一致,就是代碼有問題了;每一個測試用例最后都會有一個斷言進行判斷,如果沒有斷言,測試就沒有意義了。
上面也說了mocha一般搭配chai斷言庫,而chai有好幾種斷言風格,比較常見的有should和expect兩種風格,我們分別看下這兩種斷言:
var?chai?=?require("chai"),expect?=?chai.expect,should?=?chai.should();describe("測試addNum函數",?()?=>?{it("1+2",?()?=>?{addNum(1,?2).should.equal(3);});it("2+3",?()?=>?{expect(addNum(2,?3)).to.be.equal(5);});
});
這里should是后置的,在斷言變量之后,而expect是前置的,作為斷言的開始,兩種風格純粹看個人喜好;我們發現這里expect是從chai中獲取的一個函數,而should則是直接調用,這是因為should實際上是給所有的對象都擴充了一個 getter
屬性should
,因此我們才能夠在變量上使用.should
方式來進行斷言。
和chai的多種斷言風格不同,jest內置了斷言庫expect,它的語法又有些不同:
describe("測試addNum函數",?()?=>?{it("1+2",?()?=>?{expect(addNum(1,?2)).toBe(3);});it("2+3",?()?=>?{expect(addNum(2,?3)).toBe(5);});
});
jest中的expect直接通過toBe
的語法,在形式上相較于mocha更為簡潔;這兩個框架在使用上極其相似,比如在異步代碼上都支持done
回調和async/await
關鍵字,在斷言語法和其他用法有些差別;兩者也有相同的鉤子機制,連名字都相同beforeEach和afterEach;在vue cli腳手架創建項目時,也可以在兩個框架中進行選擇其一,我們這里主要以jest進行測試。
Jest
Jest是Facebook出品的一個測試框架,相較于其他測試框架,最大的特點就是內置了常用的測試工具,比如自帶斷言、測試覆蓋率工具,實現了開箱即用,這也和它官方的slogan相符。
?Jest 是一個令人愉快的 JavaScript 測試框架,專注于
?簡潔明快
。
Jest幾乎是零配置的,它會自動識別一些常用的測試文件,比如*.spec.js
和 *.test.js
后綴的測試腳本,所有的測試腳本都放在tests
或__tests__
目錄下;我們可以在全局安裝jest或者局部安裝,然后在packages.json中指定測試腳本:
{"scripts":?{"test":?"jest"}
}
當我們運行npm run test
時會自動運行測試目錄下所有測試文件,完成測試;我們在jest官網可能還會看到通過test函數寫的測試用例:
test("1+2",?()?=>?{expect(addNum(1,?2)).toBe(3);
});
和it函數相同,test函數也代表一個測試用例,mocha只支持it
,而jest支持it
和test
,這里為了和jest官網保持統一,下面代碼統一使用test
函數。
匹配器
我們經常需要對測試代碼返回的值進行匹配測試,上面代碼中的toBe
是最簡單的一個匹配器,用來測試兩個數值是否相同。
test("test?tobe",?()?=>?{expect(2?+?2).toBe(4);expect(true).toBe(true);const?val?=?"team";expect(val).toBe("team");expect(undefined).toBe(undefined);expect(null).toBe(null);
});
toBe函數內部使用了Object.is
來進行精確匹配,它的特性類似于===
;對于普通類型的數值可以進行比較,但是對于對象數組等復雜類型,就需要用到toEqual
來比較了:
????test("expect?a?object",?()?=>?{var?obj?=?{a:?"1",};obj.b?=?"2";expect(obj).toEqual({?a:?"1",?b:?"2"?});
});test("expect?array",?()?=>?{var?list?=?[];list.push(1);list.push(2);expect(list).toEqual([1,?2]);
});
我們有時候還需要對undefined、null等類型或者對條件語句中的表達式的真假進行精確匹配,Jest也有五個函數幫助我們:
toBeNull:只匹配null
toBeUndefined:只匹配undefined
toBeDefined:與toBeUndefined相反,等價于.not.toBeUndefined
toBeTruthy:匹配任何 if 語句為真
toBeFalsy:匹配任何 if 語句為假
test("null",?()?=>?{const?n?=?null;expect(n).toBeNull();expect(n).not.toBeUndefined();expect(n).toBeDefined();expect(n).not.toBeTruthy();expect(n).toBeFalsy();
});
test("0",?()?=>?{const?z?=?0;expect(z).not.toBeNull();expect(z).not.toBeUndefined();expect(z).toBeDefined();expect(z).not.toBeTruthy();expect(z).toBeFalsy();
});
test("undefined",?()?=>?{const?a?=?undefined;expect(a).not.toBeNull();expect(a).toBeUndefined();expect(a).not.toBeDefined();expect(a).not.toBeTruthy();expect(a).toBeFalsy();
});
toBeTruthy和toBeFalsy用來判斷在if語句中的表達式是否成立,等價于`if(n)和
if(!n)``的判斷。
對于數值類型的數據,我們有時候也可以通過大于或小于來進行判斷:
test("number",?()?=>?{const?val?=?2?+?2;//?大于expect(val).toBeGreaterThan(3);//?大于等于expect(val).toBeGreaterThanOrEqual(3.5);//?小于expect(val).toBeLessThan(5);//?小于等于expect(val).toBeLessThanOrEqual(4.5);//?完全判斷expect(val).toBe(4);expect(val).toEqual(4);
});
浮點類型的數據雖然我們也可以用toBe和toEqual來進行比較,但是如果遇到有些特殊的浮點數據計算,比如0.1+0.2就會出現問題,我們可以通過toBeCloseTo
來判斷:
test("float",?()?=>?{//?expect(0.1?+?0.2).toBe(0.3);?報錯expect(0.1?+?0.2).toBeCloseTo(0.3);
});
對于數組、set或者字符串等可迭代類型的數據,可以通過toContain
來判斷內部是否有某一項:
test("expect?iterable",?()?=>?{const?shoppingList?=?["diapers","kleenex","trash?bags","paper?towels","milk",];expect(shoppingList).toContain("milk");expect(new?Set(shoppingList)).toContain("diapers");expect("abcdef").toContain("cde");
});
異步代碼
我們項目中經常也會涉及到異步代碼,比如setTimeout、接口請求等都會涉及到異步,那么這些異步代碼怎么來進行測試呢?假設我們有一個異步獲取數據的函數fetchData
:
export?function?fetchData(cb)?{setTimeout(()?=>?{cb("res?data");},?2000);
}
在2秒后通過回調函數返回了一個字符串,我們可以在測試用例的函數中使用一個done
的參數,Jest會等done回調后再完成測試:
test("callback",?(done)?=>?{function?cb(data)?{try?{expect(data).toBe("res?data");done();}?catch?(error)?{done();}}fetchData(cb);
});
我們將一個回調函數傳入fetchData,在回調函數中對返回的數據進行斷言,在斷言結束后需要調用done;如果最后沒有調用done,那么Jest不知道什么時候結束,就會報錯;在我們日常代碼中,都會通過promise來獲取數據,將我們的fetchData
進行一下改寫:
export?function?fetchData()?{return?new?Promise((resolve,?reject)?=>?{setTimeout(()?=>?{resolve("promise?data");},?2000);});
}
Jest支持在測試用例中直接返回一個promise,我們可以在then中進行斷言:
test("promise?callback",?()?=>?{return?fetchData().then((res)?=>?{expect(res).toBe("promise?data");});
});
除了直接將fetchData返回,我們也可以在斷言中使用.resolves/.rejects
匹配符,Jest也會等待promise結束:
test("promise?callback",?()?=>?{return?expect(fetchData()).resolves.toBe("promise?data");
});
除此之外,Jest還支持async/await
,不過我們需要在test的匿名函數加上async修飾符
表示:
test("async/await?callback",?async?()?=>?{const?data?=?await?fetchData();expect(data).toBe("promise?data");
});
全局掛載與卸載
全局掛載和卸載有點類似Vue-Router的全局守衛,在每個導航觸發前和觸發后做一些操作;在Jest中也有,比如我們需要在每個測試用例前初始化一些數據,或者在每個測試用例之后清除數據,就可以使用beforeEach
和afterEach
:
let?cityList?=?[]
beforeEach(()?=>?{initializeCityDatabase();
});afterEach(()?=>?{clearCityDatabase();
});test("city?data?has?suzhou",?()?=>??{expect(cityList).toContain("suzhou")
})test("city?data?has?shanghai",?()?=>??{expect(cityList).toContain("suzhou")
})
這樣,每個測試用例進行測試前都會調用init,每次結束后都會調用clear;我們有可能會在某些test
中更改cityList的數據,但是在beforeEach
進行初始化的操作后,每個測試用例獲取的cityList數據就保證都是相同的;和上面一節異步代碼一樣,在beforeEach
和afterEach
我們也可以使用異步代碼來進行初始化:
let?cityList?=?[]
beforeEach(()?=>?{return?initializeCityDatabase().then((res)=>{cityList?=?res.data});
});
//或者使用async/await
beforeEach(async?()?=>?{cityList?=?await?initializeCityDatabase();
});
和beforeEach
和afterEach
相對應的就是beforeAll
和afterAll
,區別就是beforeAll
和afterAll
只會執行一次;beforeEach
和afterEach
默認會應用到每個test,但是我們可能希望只針對某些test,我們可以通過describe
將這些test放到一起,這樣就只應用到describe
塊中的test:
beforeEach(()?=>?{//?應用到所有的test
});
describe("put?test?together",?()?=>?{beforeEach(()?=>?{//?只應用當前describe塊中的test});test("test1",?()=>?{})test("test2",?()=>?{})
});
模擬函數
在項目中,一個模塊的函數內常常會去調用另外一個模塊的函數。在單元測試中,我們可能并不需要關心內部調用的函數的執行過程和結果,只想知道被調用模塊的函數是否被正確調用,甚至會指定該函數的返回值,因此模擬函數十分有必要。
如果我們正在測試一個函數forEach,它的參數包括了一個回調函數,作用在數組上的每個元素:
export?function?forEach(items,?callback)?{for?(let?index?=?0;?index?<?items.length;?index++)?{callback(items[index]);}
}
為了測試這個forEach,我們需要構建一個模擬函數,來檢查模擬函數是否按照預期被調用了:
test("mock?callback",?()?=>?{const?mockCallback?=?jest.fn((x)?=>?42?+?x);forEach([0,?1,?2],?mockCallback);expect(mockCallback.mock.calls.length).toBe(3);expect(mockCallback.mock.calls[0][0]).toBe(0);expect(mockCallback.mock.calls[1][0]).toBe(1);expect(mockCallback.mock.calls[2][0]).toBe(1);expect(mockCallback.mock.results[0].value).toBe(42);
});
我們發現在mockCallback有一個特殊的.mock
屬性,它保存了模擬函數被調用的信息;我們打印出來看下:

它有四個屬性:
calls:調用參數
instances:this指向
invocationCallOrder:函數調用順序
results:調用結果
在上面屬性中有一個instances
屬性,表示了函數的this指向,我們還可以通過bind
函數來更改我們模擬函數的this:
test("mock?callback",?()?=>?{const?mockCallback?=?jest.fn((x)?=>?42?+?x);const?obj?=?{?a:?1?};const?bindMockCallback?=?mockCallback.bind(obj);forEach([0,?1,?2],?bindMockCallback);expect(mockCallback.mock.instances[0]).toEqual(obj);expect(mockCallback.mock.instances[1]).toEqual(obj);expect(mockCallback.mock.instances[2]).toEqual(obj);
});
通過bind更改函數的this之后,我們可以用instances
來進行檢測;模擬函數可以在運行時將返回值進行注入:
const?myMock?=?jest.fn();
//?undefined
console.log(myMock());myMock.mockReturnValueOnce(10).mockReturnValueOnce("x").mockReturnValue(true);//10?x?true?true
console.log(myMock(),?myMock(),?myMock(),?myMock());myMock.mockReturnValueOnce(null);//?null?true?true
console.log(myMock(),?myMock(),?myMock());
我們第一次執行myMock,由于沒有注入任何返回值,然后通過mockReturnValueOnce
和mockReturnValue
進行返回值注入,Once只會注入一次;模擬函數在連續性函數傳遞返回值時使用注入非常的有用:
const?filterFn?=?jest.fn();
filterFn.mockReturnValueOnce(true).mockReturnValueOnce(false);
const?result?=?[2,?3].filter((num)?=>?filterFn(num));
expect(result).toEqual([2]);
我們還可以對模擬函數的調用情況進行斷言:
const?mockFunc?=?jest.fn();//?斷言函數還沒有被調用
expect(mockFunc).not.toHaveBeenCalled();
mockFunc(1,?2);
mockFunc(2,?3);
//?斷言函數至少調用一次
expect(mockFunc).toHaveBeenCalled();
//?斷言函數調用參數
expect(mockFunc).toHaveBeenCalledWith(1,?2);
expect(mockFunc).toHaveBeenCalledWith(2,?3);
//?斷言函數最后一次的調用參數
expect(mockFunc).toHaveBeenLastCalledWith(2,?3);
除了能對函數進行模擬,Jest還支持攔截axios返回數據,假如我們有一個獲取用戶的接口:
//?/src/api/users
const?axios?=?require("axios");function?fetchUserData()?{return?axios.get("/user.json").then((resp)?=>?resp.data);
}module.exports?=?{fetchUserData,
};
現在我們想要測試fetchUserData
函數獲取數據但是并不實際請求接口,我們可以使用jest.mock
來模擬axios模塊:
const?users?=?require("../api/users");
const?axios?=?require("axios");
jest.mock("axios");test("should?fetch?users",?()?=>?{const?userData?=?{name:?"aaa",age:?10,};const?resp?=?{?data:?userData?};axios.get.mockResolvedValue(resp);return?users.fetchUserData().then((res)?=>?{expect(res).toEqual(userData);});
});
一旦我們對模塊進行了模擬,我們可以用get函數提供一個mockResolvedValue方法,以返回我們需要測試的數據;通過模擬后,實際上axios并沒有去真正發送請求去獲取/user.json
的數據。
Vue Test Utils
Vue Test Utils是Vue.js官方的單元測試實用工具庫,能夠對我們編寫的Vue組件進行測試。
掛載組件
在Vue中我們通過import
引入組件,然后在components
進行注冊后就能使用;在單元測試中,我們使用mount
來進行掛載組件;假如我們寫了一個計數器組件counter.js
,用來展示count,并且有一個按鈕操作count:
<!--?Counter.vue?-->
<template><div?class="counter"><span?class="count">{{?count?}}</span><button?id="add"?@click="add">加</button></div>
</template>
<script>
export?default?{data()?{return?{count:?0,};},methods:?{add()?{this.count++;},},
};
</script>
組件進行掛載后得到一個wrapper(包裹器),wrapper會暴露很多封裝、遍歷和查詢其內部的Vue組件實例的便捷的方法。
import?{?mount?}?from?"@vue/test-utils";
import?Counter?from?"@/components/Counter";
const?wrapper?=?mount(Counter);
const?vm?=?wrapper.vm;
我們可以通過wrapper.vm
來訪問組件的Vue實例,進而獲取實例上的methods和data等;通過wrapper,我們可以對組件的渲染情況做斷言:
//?test/unit/counter.spec.js
describe("Counter",?()?=>?{const?wrapper?=?mount(Counter);test("counter?class",?()?=>?{expect(wrapper.classes()).toContain("counter");expect(wrapper.classes("counter")).toBe(true);});test("counter?has?span",?()?=>?{expect(wrapper.html()).toContain("<span class="count">0</span>");});test("counter?has?btn",?()?=>?{expect(wrapper.find("button#add").exists()).toBe(true);expect(wrapper.find("button#add").exists()).not.toBe(false);});
});
上面幾個函數我們根據名字也能猜出它們的作用:
classes:獲取wrapper的class,并返回一個數組
html:獲取組件渲染html結構字符串
find:返回匹配子元素的wrapper
exists:斷言wrapper是否存在
find返回的是查找的第一個DOM節點,但有些情況我們希望能操作一組DOM,我們可以用findAll
函數:
const?wrapper?=?mount(Counter);
//?返回一組wrapper
const?divList?=?wrapper.findAll('div');
divList.length
//?找到第一個div,返回它的wrapper
const?firstDiv?=?divList.at(0);
有些組件需要通過外部傳入的props、插槽slots、provide/inject等其他的插件或者屬性,我們在mount掛載時可以傳入一個對象,設置這些額外屬性:
const?wrapper?=?mount(Component,?{//?向組件傳入data,合并到現有的data中data()?{return?{foo:?"bar"}},//?設置組件的propspropsData:?{msg:?"hello"},//?vue本地拷貝localVue,//?偽造全局對象mocks:?{$route},//?插槽//?鍵名就是相應的?slot?名//?鍵值可以是一個組件、一個組件數組、一個字符串模板或文本。slots:?{default:?SlotComponent,foo:?"<div?/>",bar:?"<my-component?/>",baz:?""},//?用來注冊自定義組件stubs:?{"my-component":?MyComponent,"el-button":?true,},//?設置組件實例的$attrs 對象。attrs:?{},//?設置組件實例的$listeners對象。listeners:?{click:?jest.fn()},//?為組件傳遞用于注入的屬性provide:?{foo()?{return?"fooValue"}}
})
stubs
主要用來處理在全局注冊的自定義組件,比如我們常用的組件庫Element等,直接使用el-button
、el-input
組件,或者vue-router注冊在全局的router-view
組件等;當我們在單元測試中引入時就會提示我們對應的組件找不到,這時我們就可以通過這個stubs
來避免報錯。
我們在對某個組件進行單元測試時,希望只針對單一組件進行測試,避免子組件帶來的副作用;比如我們在父組件ParentComponent
中判斷是否有某個div時,恰好子組件ChildComponent
也渲染了該div,那么就會對我們的測試帶來一定的干擾;我們可以使用shallowMount
掛載函數,相遇比mount,shallowMount不會渲染子組件:
import?{?shallowMount?}?from?'@vue/test-utils'
const?wrapper?=?shallowMount(Component)
這樣就保證了我們需要測試的組件在渲染時不會渲染其子組件,避免子組件的干擾。
操作組件
我們經常需要對子組件中的元素或者子組件的數據進行一些操作和修改,比如頁面的點擊、修改data數據,進行操作后再來斷言數據是否正確;我們以一個簡單的Form組件為例:
<template><div?class="form"><div?class="title">{{?title?}}</div><div><span>請填寫姓名:</span><input?type="text"?id="name-input"?v-model="name"?/><div?class="name">{{?name?}}</div></div><div><span>請選擇性別:</span><input?type="radio"?name="sex"?v-model="sex"?value="f"?id=""?/><input?type="radio"?name="sex"?v-model="sex"?value="m"?id=""?/></div><div><span>請選擇愛好:</span>footbal<inputtype="checkbox"name="hobby"v-model="hobby"value="footbal"/>basketball<inputtype="checkbox"name="hobby"v-model="hobby"value="basketball"/>ski<input?type="checkbox"?name="hobby"?v-model="hobby"?value="ski"?/></div><div><input:class="submit???'submit'?:?''"type="submit"value="提交"@click="clickSubmit"/></div></div>
</template>
<script>
export?default?{name:?"Form",props:?{title:?{type:?String,default:?"表單名稱",},},data()?{return?{name:?"",sex:?"f",hobby:?[],submit:?false,};},methods:?{clickSubmit()?{this.submit?=?!this.submit;},},
};
</script>
我們可以向Form表單組件傳入一個title,作為表單的名稱,其內部也有input、radio和checkbox等一系列元素,我們就來看下怎么對這些元素進行修改;首先我們來修改props的值,在組件初始化的時候我們傳入了propsData
,在后續的代碼中我們可以通過setProps
對props值進行修改:
const?wrapper?=?mount(Form,?{propsData:?{title:?"form?title",},
});
const?vm?=?wrapper.vm;
test("change?prop",?()?=>?{expect(wrapper.find(".title").text()).toBe("form?title");wrapper.setProps({title:?"new?form?title",});//?報錯了expect(wrapper.find(".title").text()).toBe("new?form?title");
});
我們滿懷期待進行測試,但是發現最后一條斷言報錯了;這是因為Vue異步更新數據,我們改變prop和data后,獲取dom發現數據并不會立即更新;在頁面上我們一般都會通過$nextTick
進行解決,在單元測試時,我們也可以使用nextTick配合獲取DOM:
test("change?prop1",?async?()?=>?{expect(wrapper.find(".title").text()).toBe("new?form?title");wrapper.setProps({title:?"new?form?title1",});await?Vue.nextTick();//?或者使用vm的nextTick//?await?wrapper.vm.nextTick();expect(wrapper.find(".title").text()).toBe("new?form?title1");
});test("change?prop2",?(done)?=>?{expect(wrapper.find(".title").text()).toBe("new?form?title1");wrapper.setProps({title:?"new?form?title2",});Vue.nextTick(()?=>?{expect(wrapper.find(".title").text()).toBe("new?form?title2");done();});
});
和Jest中測試異步代碼一樣,我們也可以使用done回調
或者async/await
來進行異步測試;除了設置props,setData
可以用來改變wrapper中的data:
test("test?set?data",?async?()?=>?{wrapper.setData({name:?"new?name",});expect(vm.name).toBe("new?name");await?Vue.nextTick();expect(wrapper.find(".name").text()).toBe("new?name");
});
對于input、textarea或者select這種輸入性的組件元素,我們有兩種方式來改變他們的值:
test("test?input?set?value",?async?()?=>?{const?input?=?wrapper.find("#name-input");await?input.setValue("change?input?by?setValue");expect(vm.name).toBe("change?input?by?setValue");expect(input.element.value).toBe("change?input?by?setValue");
});
//?等價于
test("test?input?trigger",?()?=>?{const?input?=?wrapper.find("#name-input");input.element.value?=?"change?input?by?trigger";//?通過input.element.value改變值后必須觸發trigger才能真正修改input.trigger("input");expect(vm.name).toBe("change?input?by?trigger");
});
可以看出,通過input.element.value
或者setValue
的兩種方式改變值后,由于v-model綁定關系,因此vm中的data數據也進行了改變;我們還可以通過input.element.value
來獲取input元素的值。
對于radio、checkbox選擇性的組件元素,我們可以通過setChecked(Boolean)
函數來觸發值的更改,更改同時也會更新元素上v-model綁定的值:
test("test?radio",?()?=>?{expect(vm.sex).toBe("f");const?radioList?=?wrapper.findAll('input[name="sex"]');radioList.at(1).setChecked();expect(vm.sex).toBe("m");
});
test("test?checkbox",?()?=>?{expect(vm.hobby).toEqual([]);const?checkboxList?=?wrapper.findAll('input[name="hobby"]');checkboxList.at(0).setChecked();expect(vm.hobby).toEqual(["footbal"]);checkboxList.at(1).setChecked();expect(vm.hobby).toEqual(["footbal",?"basketball"]);checkboxList.at(0).setChecked(false);expect(vm.hobby).toEqual(["basketball"]);
});
對于按鈕等元素,我們希望在上面觸發點擊操作,可以使用trigger
進行觸發:
test("test?click",?async?()?=>?{const?submitBtn?=?wrapper.find('input[type="submit"]');await?submitBtn.trigger("click");expect(vm.submit).toBe(true);await?submitBtn.trigger("click");expect(vm.submit).toBe(false);
});
自定義事件
對于一些組件,可能會通過$emit
觸發一些返回數據,比如我們改寫上面Form表單中的submit按鈕,點擊后返回一些數據:
{methods:?{clickSubmit()?{this.$emit("foo",?"foo1",?"foo2");this.$emit("bar",?"bar1");},},
}
除了觸發組件中元素的點擊事件進行$emi,我們還可以通過wrapper.vm
觸發,因為vm本身相當于組件的this
:
wrapper.vm.$emit("foo",?"foo3");
最后,所有$emit觸發返回的數據都存儲在wrapper.emitted()
,它返回了一個對象;結構如下:
{foo:?[?[?'foo1',?'foo2'?],?[?'foo3'?]?],bar:?[?[?'bar1'?]?]
}
emitted()
返回對象中的屬性是一個數組,數組的length代表了這個方法被觸發了多少次;我們可以對對象上的屬性進行斷言,來判斷組件的emit是否被觸發:
test("test?emit",?async?()?=>?{//?組件元素觸發emitawait?wrapper.find('input[type="submit"]').trigger("click");wrapper.vm.$emit("foo",?"foo3");await?vm.$nextTick();//?foo被觸發過expect(wrapper.emitted().foo).toBeTruthy();//?foo觸發過兩次expect(wrapper.emitted().foo.length).toBe(2);//?斷言foo第一次觸發的數據expect(wrapper.emitted().foo[0]).toEqual(["foo1",?"foo2"]);//?baz沒有觸發expect(wrapper.emitted().baz).toBeFalsy();
});
我們也可以把emitted()
函數進行改寫,并不是一次性獲取整個emitted對象
:
expect(wrapper.emitted('foo')).toBeTruthy();
expect(wrapper.emitted('foo').length).toBe(2);
有一些組件觸發emit事件可能是由其子組件觸發的,我們可以通過子組件的vm進行emit:
import?{?mount?}?from?'@vue/test-utils'
import?ParentComponent?from?'@/components/ParentComponent'
import?ChildComponent?from?'@/components/ChildComponent'describe('ParentComponent',?()?=>?{it("emit",?()?=>?{const?wrapper?=?mount(ParentComponent)wrapper.find(ChildComponent).vm.$emit('custom')})
})
配合Vue-Router
在有些組件中,我們有可能會用到Vue-Router
的相關組件或者Api方法,比如我們有一個Header組件:
<template><div><div?@click="jump">{{?$route.params.id?}}</div><router-link?:to="{?path:?'/detail'?}"></router-link><router-view></router-view></div>
</template>
<script>
export?default?{data()?{return?{};},mounted()?{},methods:?{jump()?{this.$router.push({path:?"/list",});},},
};
</script>
直接在測試腳本中引入會報錯,提示找不到router-link
和router-view
兩個組件和$route
屬性;這里不推薦使用Vue.use(VueRouter)
,因為會污染全局的Vue;我們有兩種方法解決,第一種使用createLocalVue
創建一個Vue的類,我們可以在這個類中進行添加組件、混入和安裝插件而不會污染全局的Vue類:
import?{?shallowMount,?createLocalVue?}?from?'@vue/test-utils'
import?VueRouter?from?'vue-router'
import?Header?from?"@/components/Header";//?一個Vue類
const?localVue?=?createLocalVue()
localVue.use(VueRouter)
//?路由數組
const?routes?=?[]
const?router?=?new?VueRouter({routes
})shallowMount(Header,?{localVue,router
})
我們來看下這里做了哪些操作,通過createLocalVue
創建了一個localVue,相當于import Vue
;然后localVue.use
告訴Vue來使用VueRouter,和Vue.use
有著相同的作用;最后實例化創建router
對象傳入shallowMount進行掛載。
第二種方式是注入偽造數據,這里主要用的就是mocks
和stubs
,mocks
用來偽造router等全局對象,是一種將屬性添加到Vue.prototype
上的方式;而stubs
用來覆寫全局或局部注冊的組件:
import?{?mount?}?from?"@vue/test-utils";
import?Header?from?"@/components/Header";describe("header",?()?=>?{const?$route?=?{path:?"/home",params:?{id:?"111",},};const?$router?=?{push:?jest.fn(),};const?wrapper?=?mount(Header,?{stubs:?["router-view",?"router-link"],mocks:?{$route,$router,},});const?vm?=?wrapper.vm;test("render?home?div",?()?=>?{expect(wrapper.find("div").text()).toBe("111");});
});
相比于第一種方式,第二種方式可操作性更強,可以直接偽造$route路由的數據;一般第一種方式不會單獨使用,經常會搭配第二種偽造數據的方式。
配合Vuex
我們通常會在組件中會用到vuex,我們可以通過偽造store
數據來模擬測試,假如我們有一個的count組件,它的數據存放在vuex中:
<template><div><div?class="number">{{?number?}}</div><div?class="add"?@click="clickAdd">add</div><div?class="sub"?@click="clickSub">sub</div></div>
</template>
<script>
import?{?mapState,?mapGetters?}?from?"vuex";
export?default?{name:?"Count",computed:?{...mapState({number:?(state)?=>?state.number,}),},methods:?{clickAdd()?{this.$store.commit("ADD_COUNT");},clickSub()?{this.$store.commit("SUB_COUNT");},},
};
</script>
在vuex中我們通過mutations
對number進行修改:
export?default?new?Vuex.Store({state:?{number:?0,},mutations:?{ADD_COUNT(state)?{state.number?=?state.number?+?1;},SUB_COUNT(state)?{state.number?=?state.number?-?1;},}
});
那我們現在如何來偽造store
數據呢?這里和Vue-Router
的原理是一樣的,通過createLocalVue
創建一個隔離的Vue類:
import?{?mount,?createLocalVue?}?from?"@vue/test-utils";
import?Count?from?"@/components/Count";
import?Vuex?from?"vuex";const?localVue?=?createLocalVue();
localVue.use(Vuex);describe("count",?()?=>?{const?state?=?{number:?0,};const?mutations?=?{ADD_COUNT:?jest.fn(),SUB_COUNT:?jest.fn(),};const?store?=?new?Vuex.Store({state,mutations});test("render",?async?()?=>?{const?wrapper?=?mount(Count,?{store,localVue,});expect(wrapper.find(".number").text()).toBe("0");wrapper.find(".add").trigger("click");expect(mutations.ADD_COUNT).toHaveBeenCalled();expect(mutations.SUB_COUNT).not.toHaveBeenCalled();});
});
我們看一下這里做了什么操作,前面和VueRouter一樣創建一個隔離類localVue
;然后通過new Vuex.Store
創建了一個store并填入假數據state和mutations;這里我們并不關心mutations中函數做了哪些操作,我們只要知道元素點擊觸發了哪個mutations函數,通過偽造的函數我們去斷言mutations是否被調用。
另一種測試store
數據的方式是創建一個運行中的store,不再通過頁面觸發Vuex中的函數,這樣的好處就是不需要偽造Vuex函數;假設我們有一個store/list.js
export?default?{state:?{list:?[],},getters:?{joinList:?(state)?=>?{return?state.list.join(",");},},mutations:?{PUSH(state,?payload)?{state.list.push(payload);},},
};
import?{?createLocalVue?}?from?"@vue/test-utils";
import?Vuex?from?"vuex";
import?{?cloneDeep?}?from?"lodash";
import?listStore?from?"@/store/list";describe("list",?()?=>?{test("expect?list",?()?=>?{const?localVue?=?createLocalVue();localVue.use(Vuex);const?store?=?new?Vuex.Store(cloneDeep(listStore));expect(store.state.list).toEqual([]);store.commit("PUSH",?"1");expect(store.state.list).toEqual(["1"]);});test("list?getter",?()?=>?{const?localVue?=?createLocalVue();localVue.use(Vuex);const?store?=?new?Vuex.Store(cloneDeep(listStore));expect(store.getters.joinList).toBe("");store.commit("PUSH",?"1");store.commit("PUSH",?"3");expect(store.getters.joinList).toBe("1,3");});
});
我們直接創建了一個store,通過store來進行commit和getters的操作。
總結
前端框架迭代不斷,但是前端單元測試確顯有人關注;一個健壯的前端項目應該有單元測試的模塊,保證了我們的項目代碼質量和功能的穩定;但是也并不是所有的項目都需要有單元測試的,畢竟編寫測試用例也需要成本;因此如果你的項目符合下面的幾個條件,就可以考慮引入單元測試:
長期穩定的項目迭代,需要保證代碼的可維護性和功能穩定;
頁面功能相對來說說比較復雜,邏輯較多;
對于一些復用性很高的組件,可以考慮單元測試;
最近組建了一個湖南人的前端交流群,如果你是湖南人可以加我微信 ruochuan12 拉你進群。
今日話題
略。歡迎分享、收藏、點贊、在看我的公眾號文章~
一個愿景是幫助5年內前端人成長的公眾號
可加我個人微信?ruochuan12,長期交流學習
推薦閱讀
我在阿里招前端,該怎么幫你(可進面試群)
2年前端經驗,做的項目沒技術含量,怎么辦?
點擊上方卡片關注我、加個星標
·················?若川簡介?·················
你好,我是若川,畢業于江西高校。現在是一名前端開發“工程師”。寫有《學習源碼整體架構系列》多篇,在知乎、掘金收獲超百萬閱讀。
從2014年起,每年都會寫一篇年度總結,已經寫了7篇,點擊查看年度總結。
同時,活躍在知乎@若川,掘金@若川。致力于分享前端開發經驗,愿景:幫助5年內前端人走向前列。