前端搶飯碗系列之Vue項目如何做單元測試

大家好,我是若川。今天分享一篇vue項目如何做單元測試的好文,文章比較長,建議先收藏,需要時用電腦看。

點擊下方卡片關注我、加個星標

學習源碼系列、年度總結、JS基礎系列


關于單元測試,最常見的問題應該就是“前端單元測試有必要嗎?”,通過這篇文章,你將會了解單元測試的必要性,以及在Vue項目中如何能夠全面可靠的測試我們寫的組件。

單元測試的必要性

一般在我們的印象里,單元測試都是測試工程師的工作,前端負責代碼就行了;百度搜索Vue單元測試,聯想詞出來的都是“單元測試有必要嗎?” “單元測試是做什么的?”雖然我們平時項目中一般都會有測試工程師來對我們的頁面進行測試“兜底”,但是根據我的觀察,一般測試工程師并不會覆蓋所有的業務邏輯,而且有一些深層次的代碼邏輯測試工程師在不了解代碼的情況下也根本無法進行觸發。因此在這種情況下,我們并不能夠完全的依賴測試工程師對我們項目測試,前端項目的單元測試就顯得非常的有必要。

而且單元測試也能夠幫助我們節省很大一部分自我測試的成本,假如我們有一個訂單展示的組件,根據訂單狀態的不同以及其他的一些業務邏輯來進行對應文案的展示;我們想在頁面上查看文案展示是否正確,這時就需要繁瑣的填寫下單信息后才能查看;如果第二天又又加入了一些新的邏輯判斷(你前一天下的單早就過期啦),這時你有三個選擇,第一種選擇就是再次繁瑣地填寫訂單并支付完(又給老板提供資金支持了),第二種選擇就是死皮賴臉的求著后端同事給你更改訂單狀態(后端同事給你一個白眼自己體會),第三種選擇就是代理接口或者使用mock數據(你需要編譯整個項目運行進行測試)。

這時,單元測試就提供了第四種成本更低的測試方式,寫一個測試用例,來對我們的組件進行測試,判斷文案是否按照我們預想的方式進行展示;這種方式既不需要依賴后端的協助,也不需要對項目進行任何改動,可謂是省時又省力。

測試框架和斷言庫

說到單元測試,我們首先來介紹一下流行的測試框架,主要是mocha和jest。先簡單介紹下mocha,翻譯成中文就是摩卡(人家是一種咖啡!不是抹茶啊),名字的由來估猜是因為開發人員喜歡喝摩卡咖啡,就像Java名字也是從咖啡由來一樣,mocha的logo也是一杯摩卡咖啡:

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

運行結果通過了,是我們想要的結果,說明我們的函數是正確的;但是每次都通過拋出異常來判斷,多少有點繁瑣了,斷言庫就出現了;斷言的目的就是將測試代碼運行后和我們的預期做比較,如果和預期一致,就表明代碼沒有問題;如果和預期不一致,就是代碼有問題了;每一個測試用例最后都會有一個斷言進行判斷,如果沒有斷言,測試就沒有意義了。

上面也說了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 logo
?

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支持ittest,這里為了和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中也有,比如我們需要在每個測試用例前初始化一些數據,或者在每個測試用例之后清除數據,就可以使用beforeEachafterEach

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數據就保證都是相同的;和上面一節異步代碼一樣,在beforeEachafterEach我們也可以使用異步代碼來進行初始化:

let?cityList?=?[]
beforeEach(()?=>?{return?initializeCityDatabase().then((res)=>{cityList?=?res.data});
});
//或者使用async/await
beforeEach(async?()?=>?{cityList?=?await?initializeCityDatabase();
});

beforeEachafterEach相對應的就是beforeAllafterAll,區別就是beforeAllafterAll只會執行一次;beforeEachafterEach默認會應用到每個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屬性,它保存了模擬函數被調用的信息;我們打印出來看下:

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,由于沒有注入任何返回值,然后通過mockReturnValueOncemockReturnValue進行返回值注入,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-buttonel-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-linkrouter-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進行掛載。

第二種方式是注入偽造數據,這里主要用的就是mocksstubsmocks用來偽造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年內前端人走向前列。

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/news/275753.shtml
繁體地址,請注明出處:http://hk.pswp.cn/news/275753.shtml
英文地址,請注明出處:http://en.pswp.cn/news/275753.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

React Native組件開發指南

React Native的組件開發一直處在一個比較尷尬的處境。在官方未給予相關示例與腳手架的情況下&#xff0c;社區中依然誕生了許許多多的React Native組件。因為缺少示例與規范&#xff0c;很多組件庫僅含有一個index.js文件。這種基礎的目錄結構也導致了一些顯而易見的問題&#…

java activiti jbpm_activiti和jbpm工作流引擎哪個比較好?

原標題&#xff1a;activiti和jbpm工作流引擎哪個比較好&#xff1f;在常用的ERP系統、OA系統的開發中&#xff0c;工作流引擎是一個必不可少的工具。之前在選擇工作流引擎時曾經在activiti和jbpm之間有過比較&#xff0c;當時做出的決定是使用jbpm&#xff0c;但實際開發過程中…

C/C++中善用大括號

C/C中善用大括號轉載于:https://www.cnblogs.com/satng/archive/2010/12/17/2138840.html

識別人聲_演唱人聲的5個技巧

識別人聲什么是聲樂伴奏&#xff1f; (What is Vocal Comping?) Vocal comping describes the process of combining multiple vocal takes into one “supertake” that has the best parts of each. This is called a “composite track,” or comp for short. Many instrum…

你知道source map如何幫你定位源碼么?

大家好&#xff0c;我是若川。今天分享一篇我們經常會忽略的定位原始代碼位置原理的文章。文章不長&#xff0c;例子不錯&#xff0c;可以先收藏&#xff0c;有空時動手試試。學習源碼系列、年度總結、JS基礎系列前言我們知道&#xff0c;代碼上線前要經過壓縮&#xff0c;美化…

OOP 中的 方法調用、接口、鴨式辯型、訪問者模式

2019獨角獸企業重金招聘Python工程師標準>>> 方法調用的四種方式 直接調用&#xff1a;通過類或者實例直接調用其方法。接口調用或者轉型調用&#xff1a;通過將實例回調給一個接口對象&#xff0c;或者轉型為一個父類的實例&#xff0c;來調用間接調用&#xff1a;…

Substitution控件MethodName無法取到Session的解決辦法

Substitution是對緩存頁面實現動態顯示部分內容的控件&#xff0c;使用方法&#xff1a;在后臺頁面添加靜態方法&#xff0c;private static string MethodName(HttpContext context),經典的用法是動態顯示的是當前的登錄用戶名字&#xff08;非登錄用戶顯示其他的&#xff09;…

java中想要保留2位小數_java使double保留兩位小數的多方法 java保留兩位小數

mport java.text.DecimalFormat;DecimalFormat df new DecimalFormat("######0.00");double d1 3.23456double d2 0.0;double d3 2.0;df.format(d1);df.format(d2);df.format(d3);3個結果分別為:3.230.002.00java保留兩位小數問題&#xff1a;方式一&#x…

游戲 新手引導 設計_我認為每個新手設計師都應該知道什么

游戲 新手引導 設計重點 (Top highlight)I should probably have titled this article “What I wish I knew as a newbie designer.” Anyway, I’ve been doing this graphic design thing for a little over a year now, and I know now, a few things that could have made…

畢業年限不長的前端焦慮和突破方法

大家好&#xff0c;我是若川。今天周六&#xff0c;分享一篇相對輕松的文章。經作者耳東蝸牛 授權轉載鏈接&#xff1a;https://juejin.cn/post/6968002742321152014也可點擊文末閱讀原文直達本篇文章來源于&#xff1a;周五和團隊成員[20年畢業]的一次閑聊。畢業不到一年&…

開源自然語言處理工具包hanlp中CRF分詞實現詳解

CRF簡介 CRF是序列標注場景中常用的模型&#xff0c;比HMM能利用更多的特征&#xff0c;比MEMM更能抵抗標記偏置的問題。 [gerative-discriminative.png] CRF訓練 這類耗時的任務&#xff0c;還是交給了用C實現的CRF。關于CRF輸出的CRF模型&#xff0c;請參考《CRF模型格式說明…

java 素數歐拉篩選_[C++]歐拉素數篩的理解與實現

在傳統的素數篩法中&#xff0c;我們使用了對于每一個數n&#xff0c;在 1~(√n) 范圍內進行取模檢查&#xff0c;這樣逐一判斷的復雜度為n(√n)。但如果我們需要更快的篩法時怎么辦&#xff1f;于是著名的歐拉篩誕生了。它能將復雜度降為**O(n)**級別。1.關鍵理解&#xff1a;…

交互規則_您必須永不中斷的10條交互設計規則

交互規則重點 (Top highlight)In life, there are certain rules you must never break. If you do there will be hell to pay. In User Interface design there are also rules to live by. They are called “heuristics” or general principles that improve usability in…

一個幫助我100%拿offer的面試學習法

大家好&#xff0c;我是若川。今天周日&#xff0c;再分享一篇相對輕松的文章。文中說的面試學習法有一定的借鑒意義。另外我也推薦大家每隔一段時間不為跳槽的更新自己簡歷&#xff0c;也是對自己一階段的梳理總結&#xff0c;畢竟功在平時。哈嘍大家好&#xff0c;我是大圣&a…

2010年終總結

還有兩天2010就要結束了&#xff0c;寫下自己的年終總結吧&#xff0c;以總結自己&#xff0c;展望明年。2010對我來說是怎樣的一年呢&#xff1f;忙碌的一年&#xff0c;鴨梨更大的一年&#xff0c;折騰的一年&#xff0c;復雜的一年&#xff0c;夢游的一年&#xff0c;痛并快…

java獲取apk啟動activity_兼容 Android 10 啟動 APK 實現方案

背景我們想啟動 APK 程序&#xff0c;有很多種方法&#xff0c;可以使用 Intent&#xff0c;也可以使用 adb shell 命令來啟動&#xff0c;還有通過反射來啟動 APk 程序。我們這里主要討論通過反射的方式來啟動 apk 程序。Android10 之前&#xff0c;我們通過反射來啟動 APK&am…

Android Studio中解決jar包重復依賴導致的代碼編譯錯誤

在原本的代碼中已經使用了OKHTTP和rxjava&#xff0c;然后今天依賴retrofit的時候一直報錯 Program type already present: okhttp3.internal.ws.RealWebSocket$1.class 說是我重復添加了OKHTTP的包&#xff0c;但其實我直接把OKHTTP的依賴注釋掉都沒用&#xff0c;只要依賴ret…

面試被問項目經驗不用慌,按這個步驟回答絕對驚艷

大家好&#xff0c;我是若川。常有小伙伴問&#xff0c;面試時項目經驗怎么回答&#xff0c;經常會分享這篇文章給TA。本文經授權轉載。面試、學習源碼系列、年度總結、JS基礎系列前言本篇文章的作者是來自阿里淘系用戶增長前端團隊的“亦遜”&#xff0c;18年作為雙非本科生通…

使用概念模型 和心智模型的_為什么要使用模型?

使用概念模型 和心智模型的In a former life, I studied critical feminist theory. This included the field of Semiotics — the study of signs and the production of meaning, as well as Deconstruction —the unpacking of meaning to question assumptions.在過去的生…

長效密鑰與臨時密鑰JAVA判斷_MSBuild無法使用臨時密鑰簽署ClickOnce清單(錯誤MSB3326和MSB3321)...

我正在嘗試在Windows Server計算機上構建ClickOnce Windows Forms項目(.NET 3.5 / Visual Studio 2010) . (為了使用Hudson CI自動化構建過程 . )為了對ClickOnce清單進行簽名&#xff0c;我在Visual Studio中創建了一個臨時密鑰 temp.pfx . 我可以在我的工作站上從Visual Stud…