【實(shí)戰(zhàn)】Vuejs項(xiàng)目如何做單元測試
關(guān)于單元測試,最常見的問題應(yīng)該就是“前端單元測試有必要嗎?”,通過這篇文章,你將會了解單元測試的必要性,以及在Vue項(xiàng)目中如何能夠全面可靠的測試我們寫的組件。
單元測試的必要性
一般在我們的印象里,單元測試都是測試工程師的工作,前端負(fù)責(zé)代碼就行了;百度搜索Vue單元測試,聯(lián)想詞出來的都是“單元測試有必要嗎?” “單元測試是做什么的?”雖然我們平時項(xiàng)目中一般都會有測試工程師來對我們的頁面進(jìn)行測試“兜底”,但是根據(jù)我的觀察,一般測試工程師并不會覆蓋所有的業(yè)務(wù)邏輯,而且有一些深層次的代碼邏輯測試工程師在不了解代碼的情況下也根本無法進(jìn)行觸發(fā)。因此在這種情況下,我們并不能夠完全的依賴測試工程師對我們項(xiàng)目測試,前端項(xiàng)目的單元測試就顯得非常的有必要。
而且單元測試也能夠幫助我們節(jié)省很大一部分自我測試的成本,假如我們有一個訂單展示的組件,根據(jù)訂單狀態(tài)的不同以及其他的一些業(yè)務(wù)邏輯來進(jìn)行對應(yīng)文案的展示;我們想在頁面上查看文案展示是否正確,這時就需要繁瑣的填寫下單信息后才能查看;如果第二天又又加入了一些新的邏輯判斷(你前一天下的單早就過期啦),這時你有三個選擇,第一種選擇就是再次繁瑣地填寫訂單并支付完(又給老板提供資金支持了),第二種選擇就是死皮賴臉的求著后端同事給你更改訂單狀態(tài)(后端同事給你一個白眼自己體會),第三種選擇就是代理接口或者使用mock數(shù)據(jù)(你需要編譯整個項(xiàng)目運(yùn)行進(jìn)行測試)。
這時,單元測試就提供了第四種成本更低的測試方式,寫一個測試用例,來對我們的組件進(jìn)行測試,判斷文案是否按照我們預(yù)想的方式進(jìn)行展示;這種方式既不需要依賴后端的協(xié)助,也不需要對項(xiàng)目進(jìn)行任何改動,可謂是省時又省力。
測試框架和斷言庫
說到單元測試,我們首先來介紹一下流行的測試框架,主要是mocha和jest。先簡單介紹下mocha,翻譯成中文就是摩卡(人家是一種咖啡!不是抹茶啊),名字的由來估猜是因?yàn)殚_發(fā)人員喜歡喝摩卡咖啡,就像Java名字也是從咖啡由來一樣,mocha的logo也是一杯摩卡咖啡:

和jest相比,兩者主要的不同就是jest內(nèi)置了集成度比較高的斷言庫expect.js,而mocha需要搭配額外的斷言庫,一般會選擇比較流行的chai作為斷言庫,這里一直提到斷言庫,那么什么是斷言庫呢?我們首先來看下mocha是怎么來測試代碼的,首先我們寫了一個addNum函數(shù),但是不確定是否返回我們想要的結(jié)果,因此需要對這個函數(shù)進(jìn)行測試:
//src/index.js
function?addNum(a,?b)?{
??return?a?+?b;
}
module.exports?=?addNum;
然后就可以寫我們的測試文件了,所有的測試文件都放在test目錄下,一般會將測試文件和所要測試的源碼文件同名,方便進(jìn)行對應(yīng),運(yùn)行mocha時會自動對test目錄下所有js文件進(jìn)行測試:
//test/index.test.js
var?addNum?=?require("../src/index");
describe("測試addNum函數(shù)",?()?=>?{
??it("兩數(shù)相加結(jié)果為兩個數(shù)字的和",?()?=>?{
????if?(addNum(1,?2)?!==?3)?{
??????throw?new?Error("兩數(shù)相加結(jié)果不為兩個數(shù)字的和");
????}
??});
});
上面這段代碼就是測試腳本的語法,一個測試腳本會包括一個或多個describe塊,每個describe又包括一個或多個it塊;這里describe稱為測試套件(test suite),表示一組相關(guān)的測試,它包含了兩個參數(shù),第一個參數(shù)是這個測試套件的名稱,第二個參數(shù)是實(shí)際執(zhí)行的函數(shù)。
而it稱為測試用例,表示一個單獨(dú)的測試,是測試的最小單位,它也包含兩個參數(shù),第一個參數(shù)是測試用例的名稱,第二個參數(shù)是實(shí)際執(zhí)行的函數(shù)。
it塊中就是我們需要測試的代碼,如果運(yùn)行結(jié)果不是我們所預(yù)期的就拋出異常;上面的測試用例寫好后,我們就可以運(yùn)行測試了,

運(yùn)行結(jié)果通過了,是我們想要的結(jié)果,說明我們的函數(shù)是正確的;但是每次都通過拋出異常來判斷,多少有點(diǎn)繁瑣了,斷言庫就出現(xiàn)了;斷言的目的就是將測試代碼運(yùn)行后和我們的預(yù)期做比較,如果和預(yù)期一致,就表明代碼沒有問題;如果和預(yù)期不一致,就是代碼有問題了;每一個測試用例最后都會有一個斷言進(jìn)行判斷,如果沒有斷言,測試就沒有意義了。
上面也說了mocha一般搭配chai斷言庫,而chai有好幾種斷言風(fēng)格,比較常見的有should和expect兩種風(fēng)格,我們分別看下這兩種斷言:
var?chai?=?require("chai"),
??expect?=?chai.expect,
??should?=?chai.should();
describe("測試addNum函數(shù)",?()?=>?{
??it("1+2",?()?=>?{
????addNum(1,?2).should.equal(3);
??});
??it("2+3",?()?=>?{
????expect(addNum(2,?3)).to.be.equal(5);
??});
});
這里should是后置的,在斷言變量之后,而expect是前置的,作為斷言的開始,兩種風(fēng)格純粹看個人喜好;我們發(fā)現(xiàn)這里expect是從chai中獲取的一個函數(shù),而should則是直接調(diào)用,這是因?yàn)閟hould實(shí)際上是給所有的對象都擴(kuò)充了一個 getter 屬性should,因此我們才能夠在變量上使用.should方式來進(jìn)行斷言。
和chai的多種斷言風(fēng)格不同,jest內(nèi)置了斷言庫expect,它的語法又有些不同:
describe("測試addNum函數(shù)",?()?=>?{
??it("1+2",?()?=>?{
????expect(addNum(1,?2)).toBe(3);
??});
??it("2+3",?()?=>?{
????expect(addNum(2,?3)).toBe(5);
??});
});
jest中的expect直接通過toBe的語法,在形式上相較于mocha更為簡潔;這兩個框架在使用上極其相似,比如在異步代碼上都支持done回調(diào)和async/await關(guān)鍵字,在斷言語法和其他用法有些差別;兩者也有相同的鉤子機(jī)制,連名字都相同beforeEach和afterEach;在vue cli腳手架創(chuàng)建項(xiàng)目時,也可以在兩個框架中進(jìn)行選擇其一,我們這里主要以jest進(jìn)行測試。
Jest
Jest是Facebook出品的一個測試框架,相較于其他測試框架,最大的特點(diǎn)就是內(nèi)置了常用的測試工具,比如自帶斷言、測試覆蓋率工具,實(shí)現(xiàn)了開箱即用,這也和它官方的slogan相符。

?Jest 是一個令人愉快的 JavaScript 測試框架,專注于
?簡潔明快。
Jest幾乎是零配置的,它會自動識別一些常用的測試文件,比如*.spec.js和 *.test.js后綴的測試腳本,所有的測試腳本都放在tests或__tests__目錄下;我們可以在全局安裝jest或者局部安裝,然后在packages.json中指定測試腳本:
{
??"scripts":?{
????"test":?"jest"
??}
}
當(dāng)我們運(yùn)行npm run test時會自動運(yùn)行測試目錄下所有測試文件,完成測試;我們在jest官網(wǎng)可能還會看到通過test函數(shù)寫的測試用例:
test("1+2",?()?=>?{
??expect(addNum(1,?2)).toBe(3);
});
和it函數(shù)相同,test函數(shù)也代表一個測試用例,mocha只支持it,而jest支持it和test,這里為了和jest官網(wǎng)保持統(tǒng)一,下面代碼統(tǒng)一使用test函數(shù)。
匹配器
我們經(jīng)常需要對測試代碼返回的值進(jìn)行匹配測試,上面代碼中的toBe是最簡單的一個匹配器,用來測試兩個數(shù)值是否相同。
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函數(shù)內(nèi)部使用了Object.is來進(jìn)行精確匹配,它的特性類似于===;對于普通類型的數(shù)值可以進(jìn)行比較,但是對于對象數(shù)組等復(fù)雜類型,就需要用到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等類型或者對條件語句中的表達(dá)式的真假進(jìn)行精確匹配,Jest也有五個函數(shù)幫助我們:
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語句中的表達(dá)式是否成立,等價于`if(n)和if(!n)``的判斷。
對于數(shù)值類型的數(shù)據(jù),我們有時候也可以通過大于或小于來進(jì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);
});
浮點(diǎn)類型的數(shù)據(jù)雖然我們也可以用toBe和toEqual來進(jìn)行比較,但是如果遇到有些特殊的浮點(diǎn)數(shù)據(jù)計(jì)算,比如0.1+0.2就會出現(xiàn)問題,我們可以通過toBeCloseTo來判斷:
test("float",?()?=>?{
????//?expect(0.1?+?0.2).toBe(0.3);?報錯
????expect(0.1?+?0.2).toBeCloseTo(0.3);
});
對于數(shù)組、set或者字符串等可迭代類型的數(shù)據(jù),可以通過toContain來判斷內(nèi)部是否有某一項(xiàng):
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");
});
異步代碼
我們項(xiàng)目中經(jīng)常也會涉及到異步代碼,比如setTimeout、接口請求等都會涉及到異步,那么這些異步代碼怎么來進(jìn)行測試呢?假設(shè)我們有一個異步獲取數(shù)據(jù)的函數(shù)fetchData:
export?function?fetchData(cb)?{
??setTimeout(()?=>?{
????cb("res?data");
??},?2000);
}
在2秒后通過回調(diào)函數(shù)返回了一個字符串,我們可以在測試用例的函數(shù)中使用一個done的參數(shù),Jest會等done回調(diào)后再完成測試:
test("callback",?(done)?=>?{
??function?cb(data)?{
????try?{
??????expect(data).toBe("res?data");
??????done();
????}?catch?(error)?{
??????done();
????}
??}
??fetchData(cb);
});
我們將一個回調(diào)函數(shù)傳入fetchData,在回調(diào)函數(shù)中對返回的數(shù)據(jù)進(jìn)行斷言,在斷言結(jié)束后需要調(diào)用done;如果最后沒有調(diào)用done,那么Jest不知道什么時候結(jié)束,就會報錯;在我們?nèi)粘4a中,都會通過promise來獲取數(shù)據(jù),將我們的fetchData進(jìn)行一下改寫:
export?function?fetchData()?{
??return?new?Promise((resolve,?reject)?=>?{
????setTimeout(()?=>?{
??????resolve("promise?data");
????},?2000);
??});
}
Jest支持在測試用例中直接返回一個promise,我們可以在then中進(jìn)行斷言:
test("promise?callback",?()?=>?{
??return?fetchData().then((res)?=>?{
????expect(res).toBe("promise?data");
??});
});
除了直接將fetchData返回,我們也可以在斷言中使用.resolves/.rejects匹配符,Jest也會等待promise結(jié)束:
test("promise?callback",?()?=>?{
??return?expect(fetchData()).resolves.toBe("promise?data");
});
除此之外,Jest還支持async/await,不過我們需要在test的匿名函數(shù)加上async修飾符表示:
test("async/await?callback",?async?()?=>?{
??const?data?=?await?fetchData();
??expect(data).toBe("promise?data");
});
全局掛載與卸載
全局掛載和卸載有點(diǎn)類似Vue-Router的全局守衛(wèi),在每個導(dǎo)航觸發(fā)前和觸發(fā)后做一些操作;在Jest中也有,比如我們需要在每個測試用例前初始化一些數(shù)據(jù),或者在每個測試用例之后清除數(shù)據(jù),就可以使用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")
})
這樣,每個測試用例進(jìn)行測試前都會調(diào)用init,每次結(jié)束后都會調(diào)用clear;我們有可能會在某些test中更改cityList的數(shù)據(jù),但是在beforeEach進(jìn)行初始化的操作后,每個測試用例獲取的cityList數(shù)據(jù)就保證都是相同的;和上面一節(jié)異步代碼一樣,在beforeEach和afterEach我們也可以使用異步代碼來進(jìn)行初始化:
let?cityList?=?[]
beforeEach(()?=>?{
??return?initializeCityDatabase().then((res)=>{
????cityList?=?res.data
??});
});
//或者使用async/await
beforeEach(async?()?=>?{
??cityList?=?await?initializeCityDatabase();
});
和beforeEach和afterEach相對應(yīng)的就是beforeAll和afterAll,區(qū)別就是beforeAll和afterAll只會執(zhí)行一次;beforeEach和afterEach默認(rèn)會應(yīng)用到每個test,但是我們可能希望只針對某些test,我們可以通過describe將這些test放到一起,這樣就只應(yīng)用到describe塊中的test:
beforeEach(()?=>?{
??//?應(yīng)用到所有的test
});
describe("put?test?together",?()?=>?{
??beforeEach(()?=>?{
????//?只應(yīng)用當(dāng)前describe塊中的test
??});
??test("test1",?()=>?{})
??test("test2",?()=>?{})
});
模擬函數(shù)
在項(xiàng)目中,一個模塊的函數(shù)內(nèi)常常會去調(diào)用另外一個模塊的函數(shù)。在單元測試中,我們可能并不需要關(guān)心內(nèi)部調(diào)用的函數(shù)的執(zhí)行過程和結(jié)果,只想知道被調(diào)用模塊的函數(shù)是否被正確調(diào)用,甚至?xí)付ㄔ摵瘮?shù)的返回值,因此模擬函數(shù)十分有必要。
如果我們正在測試一個函數(shù)forEach,它的參數(shù)包括了一個回調(diào)函數(shù),作用在數(shù)組上的每個元素:
export?function?forEach(items,?callback)?{
??for?(let?index?=?0;?index?????callback(items[index]);
??}
}
為了測試這個forEach,我們需要構(gòu)建一個模擬函數(shù),來檢查模擬函數(shù)是否按照預(yù)期被調(diào)用了:
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);
});
我們發(fā)現(xiàn)在mockCallback有一個特殊的.mock屬性,它保存了模擬函數(shù)被調(diào)用的信息;我們打印出來看下:

它有四個屬性:
calls:調(diào)用參數(shù) instances:this指向 invocationCallOrder:函數(shù)調(diào)用順序 results:調(diào)用結(jié)果
在上面屬性中有一個instances屬性,表示了函數(shù)的this指向,我們還可以通過bind函數(shù)來更改我們模擬函數(shù)的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更改函數(shù)的this之后,我們可以用instances來進(jìn)行檢測;模擬函數(shù)可以在運(yùn)行時將返回值進(jìn)行注入:
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());
我們第一次執(zhí)行myMock,由于沒有注入任何返回值,然后通過mockReturnValueOnce和mockReturnValue進(jìn)行返回值注入,Once只會注入一次;模擬函數(shù)在連續(xù)性函數(shù)傳遞返回值時使用注入非常的有用:
const?filterFn?=?jest.fn();
filterFn.mockReturnValueOnce(true).mockReturnValueOnce(false);
const?result?=?[2,?3].filter((num)?=>?filterFn(num));
expect(result).toEqual([2]);
我們還可以對模擬函數(shù)的調(diào)用情況進(jìn)行斷言:
const?mockFunc?=?jest.fn();
//?斷言函數(shù)還沒有被調(diào)用
expect(mockFunc).not.toHaveBeenCalled();
mockFunc(1,?2);
mockFunc(2,?3);
//?斷言函數(shù)至少調(diào)用一次
expect(mockFunc).toHaveBeenCalled();
//?斷言函數(shù)調(diào)用參數(shù)
expect(mockFunc).toHaveBeenCalledWith(1,?2);
expect(mockFunc).toHaveBeenCalledWith(2,?3);
//?斷言函數(shù)最后一次的調(diào)用參數(shù)
expect(mockFunc).toHaveBeenLastCalledWith(2,?3);
除了能對函數(shù)進(jìn)行模擬,Jest還支持?jǐn)r截axios返回?cái)?shù)據(jù),假如我們有一個獲取用戶的接口:
//?/src/api/users
const?axios?=?require("axios");
function?fetchUserData()?{
??return?axios
????.get("/user.json")
????.then((resp)?=>?resp.data);
}
module.exports?=?{
??fetchUserData,
};
現(xiàn)在我們想要測試fetchUserData函數(shù)獲取數(shù)據(jù)但是并不實(shí)際請求接口,我們可以使用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);
??});
});
一旦我們對模塊進(jìn)行了模擬,我們可以用get函數(shù)提供一個mockResolvedValue方法,以返回我們需要測試的數(shù)據(jù);通過模擬后,實(shí)際上axios并沒有去真正發(fā)送請求去獲取/user.json的數(shù)據(jù)。
Vue Test Utils
Vue Test Utils是Vue.js官方的單元測試實(shí)用工具庫,能夠?qū)ξ覀兙帉懙腣ue組件進(jìn)行測試。
掛載組件
在Vue中我們通過import引入組件,然后在components進(jìn)行注冊后就能使用;在單元測試中,我們使用mount來進(jìn)行掛載組件;假如我們寫了一個計(jì)數(shù)器組件counter.js,用來展示count,并且有一個按鈕操作count:
<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>
組件進(jìn)行掛載后得到一個wrapper(包裹器),wrapper會暴露很多封裝、遍歷和查詢其內(nèi)部的Vue組件實(shí)例的便捷的方法。
import?{?mount?}?from?"@vue/test-utils";
import?Counter?from?"@/components/Counter";
const?wrapper?=?mount(Counter);
const?vm?=?wrapper.vm;
我們可以通過wrapper.vm來訪問組件的Vue實(shí)例,進(jìn)而獲取實(shí)例上的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("">0");
??});
??test("counter?has?btn",?()?=>?{
????expect(wrapper.find("button#add").exists()).toBe(true);
????expect(wrapper.find("button#add").exists()).not.toBe(false);
??});
});
上面幾個函數(shù)我們根據(jù)名字也能猜出它們的作用:
classes:獲取wrapper的class,并返回一個數(shù)組 html:獲取組件渲染html結(jié)構(gòu)字符串 find:返回匹配子元素的wrapper exists:斷言wrapper是否存在
find返回的是查找的第一個DOM節(jié)點(diǎn),但有些情況我們希望能操作一組DOM,我們可以用findAll函數(shù):
const?wrapper?=?mount(Counter);
//?返回一組wrapper
const?divList?=?wrapper.findAll('div');
divList.length
//?找到第一個div,返回它的wrapper
const?firstDiv?=?divList.at(0);
有些組件需要通過外部傳入的props、插槽slots、provide/inject等其他的插件或者屬性,我們在mount掛載時可以傳入一個對象,設(shè)置這些額外屬性:
const?wrapper?=?mount(Component,?{
??//?向組件傳入data,合并到現(xiàn)有的data中
??data()?{
????return?{
??????foo:?"bar"
????}
??},
??//?設(shè)置組件的props
??propsData:?{
????msg:?"hello"
??},
??//?vue本地拷貝
??localVue,
??//?偽造全局對象
??mocks:?{
????$route
??},
??//?插槽
??//?鍵名就是相應(yīng)的?slot?名
??//?鍵值可以是一個組件、一個組件數(shù)組、一個字符串模板或文本。
??slots:?{
????default:?SlotComponent,
????foo:?"",
????bar:?" ",
????baz:?""
??},
??//?用來注冊自定義組件
??stubs:?{
????"my-component":?MyComponent,
????"el-button":?true,
??},
??//?設(shè)置組件實(shí)例的$attrs 對象。
??attrs:?{},
??//?設(shè)置組件實(shí)例的$listeners對象。
??listeners:?{
????click:?jest.fn()
??},
??//?為組件傳遞用于注入的屬性
??provide:?{
????foo()?{
??????return?"fooValue"
????}
??}
})
stubs主要用來處理在全局注冊的自定義組件,比如我們常用的組件庫Element等,直接使用el-button、el-input組件,或者vue-router注冊在全局的router-view組件等;當(dāng)我們在單元測試中引入時就會提示我們對應(yīng)的組件找不到,這時我們就可以通過這個stubs來避免報錯。
我們在對某個組件進(jìn)行單元測試時,希望只針對單一組件進(jìn)行測試,避免子組件帶來的副作用;比如我們在父組件ParentComponent中判斷是否有某個div時,恰好子組件ChildComponent也渲染了該div,那么就會對我們的測試帶來一定的干擾;我們可以使用shallowMount掛載函數(shù),相遇比mount,shallowMount不會渲染子組件:
import?{?shallowMount?}?from?'@vue/test-utils'
const?wrapper?=?shallowMount(Component)
這樣就保證了我們需要測試的組件在渲染時不會渲染其子組件,避免子組件的干擾。
操作組件
我們經(jīng)常需要對子組件中的元素或者子組件的數(shù)據(jù)進(jìn)行一些操作和修改,比如頁面的點(diǎn)擊、修改data數(shù)據(jù),進(jìn)行操作后再來斷言數(shù)據(jù)是否正確;我們以一個簡單的Form組件為例:
<template>
??<div?class="form">
????<div?class="title">{{?title?}}div>
????<div>
??????<span>請?zhí)顚懶彰?span style="color: #f92672;line-height: 26px;">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
??????<input
????????type="checkbox"
????????name="hobby"
????????v-model="hobby"
????????value="footbal"
??????/>
??????basketball
??????<input
????????type="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,作為表單的名稱,其內(nèi)部也有input、radio和checkbox等一系列元素,我們就來看下怎么對這些元素進(jìn)行修改;首先我們來修改props的值,在組件初始化的時候我們傳入了propsData,在后續(xù)的代碼中我們可以通過setProps對props值進(jìn)行修改:
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");
});
我們滿懷期待進(jìn)行測試,但是發(fā)現(xiàn)最后一條斷言報錯了;這是因?yàn)閂ue異步更新數(shù)據(jù),我們改變prop和data后,獲取dom發(fā)現(xiàn)數(shù)據(jù)并不會立即更新;在頁面上我們一般都會通過$nextTick進(jìn)行解決,在單元測試時,我們也可以使用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回調(diào)或者async/await來進(jìn)行異步測試;除了設(shè)置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改變值后必須觸發(fā)trigger才能真正修改
??input.trigger("input");
??expect(vm.name).toBe("change?input?by?trigger");
});
可以看出,通過input.element.value或者setValue的兩種方式改變值后,由于v-model綁定關(guān)系,因此vm中的data數(shù)據(jù)也進(jìn)行了改變;我們還可以通過input.element.value來獲取input元素的值。
對于radio、checkbox選擇性的組件元素,我們可以通過setChecked(Boolean)函數(shù)來觸發(fā)值的更改,更改同時也會更新元素上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"]);
});
對于按鈕等元素,我們希望在上面觸發(fā)點(diǎn)擊操作,可以使用trigger進(jìn)行觸發(fā):
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觸發(fā)一些返回?cái)?shù)據(jù),比如我們改寫上面Form表單中的submit按鈕,點(diǎn)擊后返回一些數(shù)據(jù):
{
??methods:?{
????clickSubmit()?{
??????this.$emit("foo",?"foo1",?"foo2");
??????this.$emit("bar",?"bar1");
????},
??},
}
除了觸發(fā)組件中元素的點(diǎn)擊事件進(jìn)行$emi,我們還可以通過wrapper.vm觸發(fā),因?yàn)関m本身相當(dāng)于組件的this:
wrapper.vm.$emit("foo",?"foo3");
最后,所有$emit觸發(fā)返回的數(shù)據(jù)都存儲在wrapper.emitted(),它返回了一個對象;結(jié)構(gòu)如下:
{
????foo:?[?[?'foo1',?'foo2'?],?[?'foo3'?]?],
????bar:?[?[?'bar1'?]?]
}
emitted()返回對象中的屬性是一個數(shù)組,數(shù)組的length代表了這個方法被觸發(fā)了多少次;我們可以對對象上的屬性進(jìn)行斷言,來判斷組件的emit是否被觸發(fā):
test("test?emit",?async?()?=>?{
??//?組件元素觸發(fā)emit
??await?wrapper.find('input[type="submit"]').trigger("click");
??wrapper.vm.$emit("foo",?"foo3");
??await?vm.$nextTick();
??//?foo被觸發(fā)過
??expect(wrapper.emitted().foo).toBeTruthy();
??//?foo觸發(fā)過兩次
??expect(wrapper.emitted().foo.length).toBe(2);
??//?斷言foo第一次觸發(fā)的數(shù)據(jù)
??expect(wrapper.emitted().foo[0]).toEqual(["foo1",?"foo2"]);
??//?baz沒有觸發(fā)
??expect(wrapper.emitted().baz).toBeFalsy();
});
我們也可以把emitted()函數(shù)進(jìn)行改寫,并不是一次性獲取整個emitted對象:
expect(wrapper.emitted('foo')).toBeTruthy();
expect(wrapper.emitted('foo').length).toBe(2);
有一些組件觸發(fā)emit事件可能是由其子組件觸發(fā)的,我們可以通過子組件的vm進(jìn)行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的相關(guān)組件或者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),因?yàn)闀廴救值腣ue;我們有兩種方法解決,第一種使用createLocalVue創(chuàng)建一個Vue的類,我們可以在這個類中進(jìn)行添加組件、混入和安裝插件而不會污染全局的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)
//?路由數(shù)組
const?routes?=?[]
const?router?=?new?VueRouter({
??routes
})
shallowMount(Header,?{
??localVue,
??router
})
我們來看下這里做了哪些操作,通過createLocalVue創(chuàng)建了一個localVue,相當(dāng)于import Vue;然后localVue.use告訴Vue來使用VueRouter,和Vue.use有著相同的作用;最后實(shí)例化創(chuàng)建router對象傳入shallowMount進(jìn)行掛載。
第二種方式是注入偽造數(shù)據(jù),這里主要用的就是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");
??});
});
相比于第一種方式,第二種方式可操作性更強(qiáng),可以直接偽造$route路由的數(shù)據(jù);一般第一種方式不會單獨(dú)使用,經(jīng)常會搭配第二種偽造數(shù)據(jù)的方式。
配合Vuex
我們通常會在組件中會用到vuex,我們可以通過偽造store數(shù)據(jù)來模擬測試,假如我們有一個的count組件,它的數(shù)據(jù)存放在vuex中:
<template>
??<div>
????<div?class="number">{{?number?}}div>
????<div?class="add"?@click="clickAdd">adddiv>
????<div?class="sub"?@click="clickSub">subdiv>
??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進(jìn)行修改:
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;
????},
??}
});
那我們現(xiàn)在如何來偽造store數(shù)據(jù)呢?這里和Vue-Router的原理是一樣的,通過createLocalVue創(chuàng)建一個隔離的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一樣創(chuàng)建一個隔離類localVue;然后通過new Vuex.Store創(chuàng)建了一個store并填入假數(shù)據(jù)state和mutations;這里我們并不關(guān)心mutations中函數(shù)做了哪些操作,我們只要知道元素點(diǎn)擊觸發(fā)了哪個mutations函數(shù),通過偽造的函數(shù)我們?nèi)嘌詍utations是否被調(diào)用。
另一種測試store數(shù)據(jù)的方式是創(chuàng)建一個運(yùn)行中的store,不再通過頁面觸發(fā)Vuex中的函數(shù),這樣的好處就是不需要偽造Vuex函數(shù);假設(shè)我們有一個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");
??});
});
我們直接創(chuàng)建了一個store,通過store來進(jìn)行commit和getters的操作。
總結(jié)
前端框架迭代不斷,但是前端單元測試確顯有人關(guān)注;一個健壯的前端項(xiàng)目應(yīng)該有單元測試的模塊,保證了我們的項(xiàng)目代碼質(zhì)量和功能的穩(wěn)定;但是也并不是所有的項(xiàng)目都需要有單元測試的,畢竟編寫測試用例也需要成本;因此如果你的項(xiàng)目符合下面的幾個條件,就可以考慮引入單元測試:
長期穩(wěn)定的項(xiàng)目迭代,需要保證代碼的可維護(hù)性和功能穩(wěn)定; 頁面功能相對來說說比較復(fù)雜,邏輯較多; 對于一些復(fù)用性很高的組件,可以考慮單元測試;

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 120+ 篇原創(chuàng)文章

