為什么談前端單元測(cè)試

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)境。

參考資料

https://cn.redux.js.org/

https://doc.ebichu.cc/jest/docs/zh-Hans/api.html

https://zhuanlan.zhihu.com/p/55960017

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末俯在,一起剝皮案震驚了整個(gè)濱河市竟秫,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌跷乐,老刑警劉巖肥败,帶你破解...
    沈念sama閱讀 206,378評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異愕提,居然都是意外死亡馒稍,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門浅侨,熙熙樓的掌柜王于貴愁眉苦臉地迎上來纽谒,“玉大人,你說我怎么就攤上這事如输」那” “怎么了?”我有些...
    開封第一講書人閱讀 152,702評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵不见,是天一觀的道長(zhǎng)澳化。 經(jīng)常有香客問我,道長(zhǎng)稳吮,這世上最難降的妖魔是什么缎谷? 我笑而不...
    開封第一講書人閱讀 55,259評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮灶似,結(jié)果婚禮上列林,老公的妹妹穿的比我還像新娘。我一直安慰自己酪惭,他們只是感情好希痴,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,263評(píng)論 5 371
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著撞蚕,像睡著了一般润梯。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上甥厦,一...
    開封第一講書人閱讀 49,036評(píng)論 1 285
  • 那天纺铭,我揣著相機(jī)與錄音,去河邊找鬼刀疙。 笑死舶赔,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的谦秧。 我是一名探鬼主播竟纳,決...
    沈念sama閱讀 38,349評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼撵溃,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了锥累?” 一聲冷哼從身側(cè)響起缘挑,我...
    開封第一講書人閱讀 36,979評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎桶略,沒想到半個(gè)月后语淘,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,469評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡际歼,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,938評(píng)論 2 323
  • 正文 我和宋清朗相戀三年惶翻,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片鹅心。...
    茶點(diǎn)故事閱讀 38,059評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡吕粗,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出旭愧,到底是詐尸還是另有隱情颅筋,我是刑警寧澤,帶...
    沈念sama閱讀 33,703評(píng)論 4 323
  • 正文 年R本政府宣布榕茧,位于F島的核電站垃沦,受9級(jí)特大地震影響客给,放射性物質(zhì)發(fā)生泄漏用押。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,257評(píng)論 3 307
  • 文/蒙蒙 一靶剑、第九天 我趴在偏房一處隱蔽的房頂上張望蜻拨。 院中可真熱鬧,春花似錦桩引、人聲如沸缎讼。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽血崭。三九已至,卻和暖如春厘灼,著一層夾襖步出監(jiān)牢的瞬間夹纫,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工设凹, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留舰讹,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,501評(píng)論 2 354
  • 正文 我出身青樓闪朱,卻偏偏與公主長(zhǎng)得像月匣,于是被迫代替她去往敵國(guó)和親钻洒。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,792評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容

  • 在這里說一下前端開發(fā)的一個(gè)特點(diǎn)是更多的會(huì)涉及用戶界面锄开,當(dāng)開發(fā)規(guī)模達(dá)到一定程度時(shí)素标,幾乎注定了其復(fù)雜度會(huì)成倍的增長(zhǎng)。 ...
    愛碼小士閱讀 5,887評(píng)論 1 4
  • 前言 隨著Web業(yè)務(wù)的日益復(fù)雜化和多元化萍悴,前端開發(fā)也有了前端工程化的概念糯钙,前端工程化成為目前前端架構(gòu)中重要的一環(huán),...
    CharmSun閱讀 1,211評(píng)論 0 1
  • 什么是單元測(cè)試 單元測(cè)試(unit testing)是指對(duì)軟件中的最小可測(cè)試單元進(jìn)行檢查和驗(yàn)證退腥。 簡(jiǎn)單來說任岸,單元就...
    kyleBoy閱讀 1,536評(píng)論 0 3
  • 一、百變怪 Mockito Mockito可謂是Java世界的百變怪狡刘,使用它享潜,可以輕易的復(fù)制出各種類型的對(duì)象,并與...
    羅力閱讀 3,900評(píng)論 3 18
  • 母誕欣逢國(guó)慶周嗅蔬, 芳齡米壽囍盈眸剑按。 如今養(yǎng)老千般好, 樂享人間敬白頭澜术。 (注:米壽艺蝴,即八十八歲,因米字由八十八構(gòu)成鸟废。)
    筆名江風(fēng)閱讀 1,214評(píng)論 10 13