1.前言
單元測(cè)試又稱為模塊測(cè)試贼陶,是針對(duì)程序模塊(軟件設(shè)計(jì)的最小單位)來進(jìn)行正確性檢驗(yàn)的測(cè)試工作蟆盹。程序單元是應(yīng)用的最小可測(cè)試部件孩灯。在過程化編程中,一個(gè)單元就是單個(gè)程序逾滥、函數(shù)峰档、過程等;對(duì)于面向?qū)ο缶幊陶迹钚卧褪欠椒娓纾ɑ悾ǔ悾⒊橄箢愐愦⒒蛘吲缮悾ㄗ宇悾┲械姆椒ā?--維基百科
簡(jiǎn)單的說尚卫,單元測(cè)試是一種驗(yàn)證,驗(yàn)證代碼功能尸红,方法的實(shí)現(xiàn)的正確性吱涉。
為什么我們會(huì)區(qū)分前端和后端的單元測(cè)試?對(duì)于后端來說外里,單元測(cè)試并不陌生怎爵,驗(yàn)證一段邏輯的輸入輸出是否符合預(yù)期就可以,模式也很統(tǒng)一盅蝗,畢竟編譯型語言的本質(zhì)就是計(jì)算鳖链。對(duì)前端而言,我們可能面對(duì)更多的是標(biāo)記性語言和腳本語言,單元測(cè)試的邊界很難定義芙委,有渲染也有業(yè)務(wù)逞敷,如何測(cè)試也是很多項(xiàng)目爭(zhēng)議的地方。
2.先說是不是灌侣,再問測(cè)什么
前端是不是要寫單元測(cè)試推捐?
首先,單元測(cè)試的重點(diǎn)在于單元侧啼,如何把代碼拆分成一個(gè)個(gè)的單元牛柒,把業(yè)務(wù)和邏輯代碼分開才是我們最開始需要考慮的問題。單純對(duì)于一個(gè)結(jié)果的輸入輸出來說痊乾,很多時(shí)候?yàn)g覽器給我們的信息更直觀也更容易發(fā)現(xiàn)問題皮壁,這樣看可能端到端的測(cè)試更適合我們,或者點(diǎn)一點(diǎn)哪审,但是這樣我們也很難發(fā)現(xiàn)代碼內(nèi)部的一些問題闪彼。
當(dāng)然,比如你的處理很簡(jiǎn)單并且都和業(yè)務(wù)有關(guān)协饲,邏輯計(jì)算通過一個(gè)請(qǐng)求都交給了后端處理畏腕;或者只做了一個(gè)展示界面,那么單元測(cè)試確實(shí)沒有必要茉稠。
其次描馅,為了以后可以快速定位bug和讓別人接手起來更有信心的方面來看,單元測(cè)試在一些大型或者復(fù)雜的項(xiàng)目中確實(shí)有一定的必要而线。
前端單元測(cè)試到底測(cè)什么铭污?
回到上一個(gè)問題,單元測(cè)試的重點(diǎn)在于單元膀篮,這也是前端單元測(cè)試的難點(diǎn)∴谀現(xiàn)在我們大部分使用的框架大多把頁面渲染和功能放到了一起,那些才是我們需要測(cè)試的單元誓竿?從相對(duì)的角度來說磅网,一些不會(huì)經(jīng)常變化的功能可以細(xì)分成單元進(jìn)行測(cè)試:
1.公共函數(shù)
2.公共組件
越底層的代碼越有測(cè)試的必要,因?yàn)閁I的實(shí)現(xiàn)會(huì)依賴底層代碼筷屡,例如我們可能用到的一些類似ramda涧偷、antd庫,都會(huì)經(jīng)過嚴(yán)格的單元測(cè)試毙死,如果我們想要在項(xiàng)目中自己實(shí)現(xiàn)燎潮,就要對(duì)這樣方法和組件進(jìn)行測(cè)試,業(yè)務(wù)邏輯一般會(huì)跟著項(xiàng)目迭代和更新隨時(shí)變化扼倘,寫測(cè)試的意義不大确封。
單元測(cè)試的意義在哪里?
1.重構(gòu)、重構(gòu)爪喘、重構(gòu)颜曾,重要的事情說三遍
TDD的具體實(shí)現(xiàn)就是通過紅燈->綠燈->重構(gòu)不斷重復(fù),一步一步去健壯我們的代碼腥放,所以單元測(cè)試的最大的意義也是為了我們今后可以重構(gòu)我們的代碼泛啸,只要保證測(cè)試的準(zhǔn)確绿语,就可以在重構(gòu)中準(zhǔn)確的定位到問題秃症。同時(shí)也為以后的開發(fā)提供支持,在測(cè)試的基礎(chǔ)上我們可以重構(gòu)結(jié)構(gòu)和業(yè)務(wù)功能吕粹。
2.單元測(cè)試是最好的注釋
寫注釋是很多程序員都會(huì)忽略的一個(gè)步驟种柑,或者改了代碼你并不會(huì)記得去改注釋痪蝇,很多程序員會(huì)傾向于把變量名作為注釋淀零,但它無法很好的解釋內(nèi)部的邏輯,而測(cè)試會(huì)提示你那些步驟是可以通過砍鸠、如何使用的最好文檔稳其,驶赏。更詳細(xì)的規(guī)范了測(cè)試目標(biāo)的邊界值與非法值。
3.定位bug既鞠,減少bug
測(cè)試最直觀的體現(xiàn)當(dāng)然是與bug相關(guān)的煤傍,單元測(cè)試可以通過不同的條件來發(fā)現(xiàn)問題在哪里,在一些弱類型的語言中也避免了一些類型檢查的低級(jí)錯(cuò)誤嘱蛋,當(dāng)然這個(gè)現(xiàn)在我們都用TypeScript做到了蚯姆。
4.被迫的規(guī)范組織結(jié)構(gòu)
可能平時(shí)我們會(huì)把一個(gè)方法寫的很復(fù)雜、一個(gè)類寫的很大洒敏,沒有想過如何去組織結(jié)構(gòu)龄恋,但如果你想到你即將的測(cè)試要如何寫的時(shí)候,那可能你在開發(fā)前必須要想想哪些部分可以提出來了凶伙。
2.前端單元測(cè)試怎么寫
先介紹幾個(gè)在測(cè)試中我們需要值得注意和經(jīng)常提到的一些概念:冪等郭毕,Mock,斷言
冪等:對(duì)同一輸入操作表現(xiàn)出相同的輸出結(jié)果函荣,不會(huì)隨時(shí)間等因素表現(xiàn)出副作用铣卡。對(duì)于一個(gè)方法來說,冪等是編程中必然的偏竟。而在前端測(cè)試中煮落,現(xiàn)在的框架也會(huì)涉及到組件的生命周期和渲染方式,我們也要注意UI的冪等踊谋,保證一個(gè)組件渲染的結(jié)果相同蝉仇。
Mock: 對(duì)前端來說,Mock數(shù)據(jù)不僅僅包括參數(shù)的模擬,還可能涉及到頁面交互的模擬轿衔;在前端一些函數(shù)的參數(shù)也可以是一個(gè)函數(shù)沉迹,如果不知道函數(shù)調(diào)用的情況這,也會(huì)使測(cè)試的難度增加害驹。好的事情是現(xiàn)在這些我們都可以通過第三方的庫去做到鞭呕,比如enzyme和jest。
斷言:判斷代碼的實(shí)際執(zhí)行結(jié)果與預(yù)期結(jié)果是否一致宛官,在JS中我們的斷言方法只有console.assert葫松,在實(shí)際項(xiàng)目中不是很多見,在測(cè)試的時(shí)候我們可以借助斷言庫進(jìn)行更多方式的比較底洗。
以下以jest腋么、enzyme測(cè)試react為例
函數(shù)
對(duì)于一些基本帶有返回的函數(shù),我們一般可以直接通過斷言它的返回值
// function add(num){ return num + 1}
expect(add(1)).toBe(2);
如果一個(gè)函數(shù)里面并沒有返回亥揖,而是調(diào)用了一個(gè)回調(diào)函數(shù)珊擂,我們可以通過模擬函數(shù)來判斷它是否如期調(diào)用就可以了
function forEach(items, callback) {
for (let index = 0; index < items.length; index++) {
callback(items[index]);
}
}
const mockCallback = jest.fn();
forEach([0, 1], mockCallback);
// 被調(diào)用
expect(mockCallback).toBeCalled();
// 被調(diào)用了兩次
expect(mockCallback).toBeCalledTimes(2);
// 被調(diào)用時(shí)傳入的參數(shù)是0
expect(mockCallback).toHaveBeenCalledWith(0);
異步的請(qǐng)求也可以看作是一個(gè)函數(shù),我們可以用jest.mock的方法模擬請(qǐng)求進(jìn)行測(cè)試费变。
組件
React中摧扇,我們測(cè)試的目的一般都是為了測(cè)試是否渲染了正確的DOM結(jié)構(gòu)和業(yè)務(wù)邏輯。
公共組件一般是一些無狀態(tài)的純函數(shù)組件挚歧,測(cè)起來也相對(duì)簡(jiǎn)單
// 通過enzyme創(chuàng)建一個(gè)虛擬的組件
const wrapper = shallow(
<wrapperComponent />/
);
// 通過class觀察組件是否成功渲染
expect(wrapper.is('.wrapper-class')).to.equal(true);
當(dāng)然扛稽,有些組件我們還有通過props傳入一些屬性;state和一些方法昼激;甚至一些生命周期
class wrapperComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
number: props.number
}
}
componentDidMount() {
console.log(this.state.number)
}
handleClick = () => {
let { number } = this.state;
this.setState({
number: number + 1
});
}
render() {
return (
<div className="wrapper-class">
<button onClick={this.handleClick}>+</button>
</div>
)
}
}
const wrapper = shallow(
<wrapperComponent number={0}/>/
);
// 測(cè)試props
expect(wrapper.props()).toHaveProperty('number',0);
// 測(cè)試生命周期
expect(wrapper.prototype.componentDidMount.calledOnce).toBe(true);
// 測(cè)試方法是否實(shí)現(xiàn)
wrapper.instance().handleClick();
expect(wrapper.state()).to.deep.equal({number: 1});
值得注意的是庇绽,組件內(nèi)部嵌入了自組件也會(huì)增加我們的測(cè)試復(fù)雜度,因?yàn)閟hallow只做了淺層渲染橙困,在考慮我們要做自組件測(cè)試的時(shí)候瞧掺,應(yīng)該采用深度渲染獲取子組件,例如mount方法凡傅。shallow和mount的使用會(huì)影響事件的觸發(fā)不同
高階組件
React中你可能會(huì)涉及到高階組件(High-Order Component)辟狈,理解高階組件,我們可以把High-Order 和 Component分開理解夏跷。高階組件可以看作一個(gè)組件包含了另一組件哼转,我們?nèi)绻淹鈱拥慕M件看作High-Order,里面包裹的組件看作普通的Component就好理解一些槽华。
那么測(cè)試的時(shí)候壹蔓,我們也可以把他們分開來寫。
// 高階組件 component.js
export function HocWrapper(WrapprComponent) {
return class Hoc extends React.Component {
state = {
loading: false
};
render() {
return <WrapperComponent {...this.state} {...this.props} />;
}
};
}
export class WrapprComponent extends React.Component {
render() {
return <div>hello world</div>;
}
}
//component.test.js
import { HocWrapper, WrapprComponent } from "./component.js";
const wrapper = mount(HocWrapper(WrapprComponent));
// 測(cè)試有l(wèi)oading屬性
expect(wrapper.find('WrapperComp').props()).toHaveProperty('loading');
一般來說猫态,為了測(cè)試佣蓉,我們要文件里吧HocWrapper函數(shù)和我們的WrapprComponent組件分別都export出來披摄,當(dāng)然我們自己寫的高階組件都會(huì)這樣做。
而我們?cè)陂_發(fā)中會(huì)用到諸如Redux的connect勇凭,這也是一種高階組件的形式疚膊,所以這時(shí)候?yàn)榱藴y(cè)試,我們會(huì)在一個(gè)文件中export一個(gè)沒有connect的組件作為測(cè)試組件虾标,props作為屬性傳遞進(jìn)去寓盗。
狀態(tài)管理
React中我們一般用Redux做狀態(tài)管理,分為action璧函,reducer傀蚌,還會(huì)有saga做副作用的處理。
對(duì)于actions的測(cè)試柳譬,我們主要驗(yàn)證每個(gè)action對(duì)象是否正確(其實(shí)我覺得這個(gè)用TS做類型推導(dǎo)就相當(dāng)于加了測(cè)試)
// action是否返回正確類型
expect(actions.add(1)).toEqual({type: "ADD", payload: 1});
reducer就是一個(gè)純函數(shù)喳张,而且每個(gè)action對(duì)應(yīng)的reducer職責(zé)也比較單一续镇,所以可以作為公共函數(shù)去做測(cè)試美澳。我們主要測(cè)試的內(nèi)容也是看是否可以根據(jù)action的type返回正確的狀態(tài)。
reducer測(cè)試的邊界條件一般是我們初始化的store摸航,如果沒有action匹配制跟,就返回默認(rèn)的store。
import { reducer, defaultStore } from './reducers';
const expectedState= {number: 1}
// 根據(jù)action是否返回期望的store
expect(reducer(defaultStore, {type: "ADD",1})).toEqual(expectedState);
// 測(cè)試邊界條件
expect(reducer(defaultStore, {type: "UNDEFINED",1})).toEqual(defaultStore);
如果你用了redux酱虎,可能還會(huì)用一些庫來創(chuàng)建并記錄store里的衍生數(shù)據(jù)雨膨,組成我們常用的selector函數(shù),我們測(cè)試的重點(diǎn)放在是否能組成新的selector读串,并且它是根據(jù)store的變化而變化聊记。
// selectors.js
export const domainSelector = (store) => store.init;
export const getAddNumber = createSelector(
domainSelector,
(store) => {number: store.number + 1},
);
// selectors.test.js
import { getAddNumber } form './selectors'
import { reducer, defaultStore } from './reducers';
// 判斷生成selector
expect(getAddNumber(store)).toEqual({number: 1});
// 判斷改變store生成新的selector
reducer(defaultStore, {type: "ADD",1})
expect(getAddNumber(store)).toEqual({number: 2});
對(duì)于一些請(qǐng)求和異步的操作,我們可能用到了saga來管理恢暖。saga對(duì)于異步我們會(huì)分為正常運(yùn)行和捕獲錯(cuò)誤去進(jìn)行測(cè)試排监。
// saga.js
function* callApi(url) {
try {
const result = yield call(myApi, url);
yield put(success(result.json()));
return result.status;
} catch (e) {
yield put(error(e));
return -1;
}
}
// saga.test.js
// try
const gen = cloneableGenerator(fetchProduct)();
const clone = gen.clone();
const url = "http://test.com";
expect(clone.next().value).toEqual(call(myApi, url));
expect(clone.next().value).toEqual(put({ type: 'SUCCESS', payload: 1 }));
// catch 要跳到catch,就要讓它錯(cuò)誤
const error = 'not found';
const clone = gen.clone();
// 需要執(zhí)行
clone.next();
expect(gen.throw('not found').value).toEqual(put({ type: 'ERROR', error }));
這里只對(duì)單元測(cè)試要測(cè)那些點(diǎn)做了闡述杰捂,如果希望了解詳細(xì)的測(cè)試如何編寫舆床,Angular和Vue的CLI已經(jīng)做的很好,也給出了適當(dāng)?shù)睦蛹藜眩瑢?duì)于React挨队,請(qǐng)看這篇文章:https://github.com/Hsueh-Jen/blog/issues/1
3.踩過一些坑
一些window上的屬性
跑測(cè)試的時(shí)候,我們并不是在瀏覽器上運(yùn)行蒿往,所以一些window下的屬性我們無法獲取盛垦,我們常用的有l(wèi)ocalStorage這類的屬性,會(huì)導(dǎo)致測(cè)試報(bào)錯(cuò)瓤漏。
所以我們?cè)诒镜貞?yīng)該自己模擬一個(gè)localStorage方法用于測(cè)試
function storageFunction() {
let storage = {};
return {
setItem: function(key, value) {
storage[key] = value || '';
},
getItem: function(key) {
return key in storage ? storage[key] : null;
},
removeItem: function(key) {
delete storage[key];
},
clear: function() {
storage = {}
}
};
}
箭頭函數(shù)
如果使用箭頭函數(shù)腾夯,需要對(duì)實(shí)例進(jìn)行Mock省撑,才能保證上下文環(huán)境。