React單元測試策略及落地

單元測試是現(xiàn)代軟件開發(fā)最基本,也普遍落地不力的實踐。市面關(guān)于React單元測試的文章,普遍停留在“可以如何寫”和介紹工具的層面,既未回答“為何必須做單元測試”,也未回答“單元測試的最佳實踐”兩個關(guān)鍵問題。本文正是要對這兩個問題作出回答。

本文所用技術(shù)棧為前端React棧,測試框架與斷言工具是jest。文章將略過對測試框架本身的語法介紹,著眼于“為何做”與“最佳實踐”的部分。閱讀第二部分的代碼,需要讀者對JavaScript與React語法有一定了解,但第一部分的原理并不對讀者做任何語言上的假設。

目錄

  1. 第一部分:為什么必須做單元測試
    1. 單元測試的上下文
    2. 測試策略——測試金字塔
    3. TDD——單元測試的核心靈魂
  2. 第二部分:什么是好的單元測試
  3. 第三部分:React 單元測試策略
  4. 第四部分:React 單元測試落地
    1. actions 測試
    2. reducer 測試
    3. selector 測試
    4. saga 測試
      • 來自官方的錯誤姿勢
      • 正確姿勢
    5. component 測試
      • 業(yè)務型組件 - 分支渲染
      • 業(yè)務型組件 - 事件調(diào)用
      • 功能型組件 - children 型高階組件
    6. utils 測試
  5. 總結(jié)
  6. 未盡話題
  7. 參考

第一部分:為什么必須做單元測試

對于單元測試有保障質(zhì)量之利益一事,業(yè)界已多有共識,但談及其實施,則有種種“浪費時間”“很難寫”“作用不大”顧及成本之聲,他們板起一副編寫單元測試將付出巨大成本的嚴肅臉。在“單元測試能保障質(zhì)量”已成政治正確的今日,又有一種中庸的聲音,說“單元測試固然有用,但也要根據(jù)項目情況進行裁剪,要寫對開發(fā)者真正有用的測試”,一副放之四海皆準的樣子,于是終于能夠安心地根據(jù)項目情況將單元測試裁剪掉。

這種態(tài)度我一貫旗幟鮮明地反對:上來就談裁剪,不是正確的導向。與產(chǎn)品代碼一并交付高質(zhì)量的測試代碼,是每個開發(fā)者日常交付軟件的基本職責。

單元測試的上下文

“為什么我們需要做單元測試”,這是一個關(guān)鍵的問題。往小了說,不做單元測試的代碼無法保證后續(xù)不被破壞,無法重構(gòu),只能看著代碼腐化;往大了說,不做單元測試的團隊響應力不可能提高。

實際上,自動化測試是實現(xiàn)“敏捷”的基本保障。現(xiàn)代企業(yè)數(shù)字化競爭日益激烈,業(yè)務端快速上線、快速驗證、快速失敗的思路對技術(shù)端的響應力提出了更高的要求:更快上線持續(xù)上線。怎么樣衡量這個“更快”呢?第一圖給出了一個指標:lead time。它度量的是一個想法從提出并被驗證,到最終上生產(chǎn)環(huán)境面對用戶獲取反饋的時間。顯然,這個時間越短,軟件就能越快獲得反饋,對價值的驗證就越快發(fā)生,軟件對反饋的響應能力就越強。這個結(jié)論對我們寫不寫單元測試有什么影響呢?答案是,不寫單元測試、不寫好的單元測試,你就快不起來。為啥呢?因為每次發(fā)布,你都要投入人力來進行手工測試;因為沒有自動化測試,你傾向于不敢隨意重構(gòu),這又導致代碼逐漸腐化,復雜度使得你的開發(fā)速度降低。

再考慮到以下兩大事實:人員會流動,應用會變大。人員一定會流動,需求一定會增加,直至再也沒有一個人能夠了解應用的所有功能,那時對應用做出修改的成本將變得很高。因此,意圖依賴人、依賴手工的方式來應對響應力的挑戰(zhàn)首先是低效的,從時間維度上來講也是不可能的。因此,為了服務于“高響應力”這個目標,隨時重構(gòu)整理代碼是必須的,這就需要我們有一套自動化的測試套件,它能幫我們提供快速反饋,做質(zhì)量的守衛(wèi)者。唯解決了人工、質(zhì)量的這一環(huán),開發(fā)效率才能穩(wěn)步提升,團隊和企業(yè)的高響應力才可能達到。

在“響應力”和“隨時重構(gòu)”這個上下文中來談要不要單元測試,我們就可以很有根據(jù)了,而不是含糊不清地回答“看項目的具體情況”了。顯然,寫出易于理解、易于修改、可以重構(gòu)的代碼,是每個開發(fā)者的本來職責,而單元測試正是達成此一目的的唯一途徑。

測試策略——測試金字塔

上面我直接從高響應力談到單元測試,可能有的同學會問,高響應力這個事情我認可,也認可快速開發(fā)的同時,質(zhì)量也很重要。但是,為了達到“保障質(zhì)量”的目的,不一定得通過測試呀,就算需要測試,也不一定得通過單元測試鴨。

這是個好的問題。為了達到保障質(zhì)量這個目標,測試當然只是其中一個方式,穩(wěn)定的自動化部署、集成流水線、良好的代碼架構(gòu)、甚至于團隊架構(gòu)的必要調(diào)整等,都是必須跟上的設施。自動化測試不是解決質(zhì)量問題的銀彈,多方共同提升才可能起到效果。

即便我們談自動化測試,也未必全部都是單元測試。我們對自動化測試套件寄予的厚望是,它能幫我們安全重構(gòu)已有代碼、快速回歸已有功能保存業(yè)務上下文。測試種類多種多樣,為什么我要重點談單元測試呢?因為它寫起來相對最容易、運行速度最快、反饋效果又最直接。下面這個圖,想必大家都有所耳聞:

這就是有名的測試金字塔。對于一個自動化測試套件,應該包含種類不同、關(guān)注點不同的測試,比如關(guān)注單元的單元測試、關(guān)注集成和契約的集成測試和契約測試、關(guān)注業(yè)務驗收點的端到端測試等。正常來說,我們會受到資源的限制,無法應用所有層級的測試,效果也未必最佳。因此,我們需要有策略性地根據(jù)收益-成本的原則,考慮項目的實際情況和痛點來定制測試策略:比如三方依賴多的項目可以多寫些契約測試,業(yè)務場景多、復雜或經(jīng)?;貧w的場景可以多寫些端到端測試,等。但不論如何,整個測試金字塔體系中,你還是應該擁有更多低層次的單元測試,因為它們成本相對最低,運行速度最快(通常是毫秒級別),而對單元的保護價值相對更大。

TDD——單元測試的核心靈魂

以上回答了“為何要有單元測試”的問題,卻沒有回答“如何得到這些單元測試”。有同學可能問,你說要寫單元測試,那么什么時候?qū)戇@些單元測試呢?讓誰來寫呢(開發(fā)人員還是測試人員)?代碼實現(xiàn)那么爛,我根本寫不出強壯的測試,怎么辦呢?

回答是,這些單元測試應該由開發(fā)者,在開發(fā)軟件的同時編寫對應的單元測試。它應該是內(nèi)建的,而不是后補的:也即在編寫實現(xiàn)的同時完成單元測試,而不是寫完代碼再一次性補足。測試先行,這正是TDD的做法。使用TDD開發(fā)方法是得到可靠單元測試的唯一途徑。

長久以來,大家都認為單測是單測,TDD是TDD,說單測必須要有,但是否使用TDD(測試先行)應該尊重開發(fā)者的習慣愛好。但事實是,且不說測試很難補,補出來的測試也幾乎不可能完整覆蓋我們對重構(gòu)和質(zhì)量的要求。TDD和單元測試是全有或全無:不做TDD,難以得到好的單元測試;TDD是獲得可靠的單元測試的的唯一途徑。除此之外別無捷徑,想拋開TDD而獲得一個好的單元測試套件是迷思,難以成功。

那么如何掌握TDD呢?事實上非常簡單,多練即可。你可關(guān)注微信公眾號“程序員練功房”,也可掃碼直接開始你的十四天編程訓練營,刻意練習,打好TDD基礎。

第二部分:什么是好的單元測試

好,相信看到這里,你已經(jīng)愿意為一套好的單元測試集而奮斗了。下一個擺在我們眼前的問題就是,“什么才是好的單元測試”,以及“如何寫出這樣的單元測試”了。開始之前,我們先來看個例子,即一個最簡單的JavaScript單元測試長什么樣:

// production code
const computeTotalAmount = (products) => {
  return products.reduce((total, product) => total + product.price, 0); 
}

// testing code
it('should return summed up total amount 1000 when there are three products priced 200, 300, 500', () => {
  // given - 準備數(shù)據(jù)
  const products = [
    { name: 'nike', price: 200 },
    { name: 'adidas', price: 300 },
    { name: 'lining', price: 500 },
  ]

  // when - 調(diào)用被測函數(shù)
  const result = computeTotalAmount(products)

  // then - 斷言結(jié)果
  expect(result).toBe(1000)
})

這個例子雖小,五臟卻基本齊全。遵循這個given-when-then的結(jié)構(gòu),可以讓你寫出比較清晰的測試結(jié)構(gòu),既易于閱讀,也易于編寫。此外,編寫容易維護的單元測試還有一些原則,這些原則對于任何語言、任何層級的測試都適用。這些原則不是新東西,但總是需要時時溫故知新,筆者總結(jié)于此,可以此為鏡,時時檢驗你的單元測試套件是否高效:

  • 只關(guān)注輸入輸出,不關(guān)注內(nèi)部實現(xiàn)
  • 只測一條分支
  • 表達力極強
  • 不包含邏輯
  • 運行速度快

只關(guān)注輸入輸出,不關(guān)注內(nèi)部實現(xiàn)

比如上面那個例子,你是如何完成“求總價格”的,測試本身不關(guān)注,因此你可以用reduce實現(xiàn),也可以自己寫for循環(huán)實現(xiàn)。只要測試輸入沒有變,輸出就不應該變。這個特性,是測試支撐重構(gòu)的基礎。因為重構(gòu)指的是,在不改變軟件外部可觀測行為的基礎上,調(diào)整軟件內(nèi)部的實現(xiàn)。由此也可以看出,如果你是后補的測試,加之實現(xiàn)本身就寫得細節(jié)橫陳,就很難補出這種能夠支撐重構(gòu)、結(jié)構(gòu)又清晰的測試代碼。測試先行本身就會驅(qū)動你寫出易于測試的代碼。

另外,還有一些測試(比如下文要看到的 saga 官方推薦的測試),它需要測試實現(xiàn)代碼的執(zhí)行次序。這也是一種“關(guān)注內(nèi)部實現(xiàn)”的測試,這就使得除了輸入輸出外,還有“執(zhí)行次序”這個因素可能使測試掛掉。顯然,這樣的測試也不利于重構(gòu)的開展。

此外,對外部依賴采取mock策略,同樣是某種程度上的“關(guān)注內(nèi)部實現(xiàn)”,因為mock的失敗同樣將導致測試的失敗,而非真正業(yè)務場景的失敗。對待mock的態(tài)度,我認為是謹慎使用,但本文未做展開。肖鵬有篇文章Mock的七宗罪對此展開了詳細描述,我還沒細看,這里只能先分享給讀者。

只測一條分支

通常來說,一條分支就是一個業(yè)務場景,是你做任務分解過程的一個細粒度的task。為什么測試只測一條分支呢?很顯然,如此你才能給它一個好的描述,這個測試才能保護這個特定的業(yè)務場景,掛了的時候能給你細致到輸入輸出級別的業(yè)務反饋。

常見的反模式是,實現(xiàn)本身就做了太多的事情,不符合SRP原則。

表達力極強

表達力強的測試,能在失敗的時候給你非常迅速的反饋。它講的是兩方面:

  • 看到測試時,你就知道它測的業(yè)務點是啥
  • 測試掛掉時,能清楚地知道失敗的業(yè)務場景、期望數(shù)據(jù)與實際輸出的差異

總結(jié)起來,這些表達力主要體現(xiàn)在以下的方面:

  • 測試描述。遵循上一條原則(一個單元測試只測一個分支)的情況下,描述通常能寫出一個相當詳細的業(yè)務場景。這為測試的讀者提供了極佳的業(yè)務上下文
  • 測試數(shù)據(jù)準備。無關(guān)的測試數(shù)據(jù)(比如對象中的很多無關(guān)字段)不應該寫出來,應只準備能體現(xiàn)測試業(yè)務的最小數(shù)據(jù)
  • 輸出報告。選用斷言工具時,應注意除了要提供測試結(jié)果,還要能準確提供“期望值”與“實際值”的差異

上述第三點有些反例,比如說chai和sinon提供的斷言API就不如jest友好,體現(xiàn)在:

  • expect(array).to.eql(array)出錯的時候,只能報告說expect [Array (42)] to equal [Array (42)],具體是哪個數(shù)據(jù)不匹配,根本沒報告
  • expect(sinonStub.calledWith(args)).to.be.true出錯的時候,會報告說expect false to be true。廢話,我還不知道掛了么,但是那個stub究竟被什么參數(shù)調(diào)用則沒有報告

這些細節(jié),在閱讀本文后面的任意測試,以及您自己編寫單元測試的時候應該時常對照和雕琢。

不包含邏輯

跟寫聲明式的代碼一樣的道理,測試需要都是簡單的聲明:準備數(shù)據(jù)、調(diào)用函數(shù)、斷言,讓人一眼就明白這個測試在測什么。如果含有邏輯,你讀的時候就要多花時間理解;一旦測試掛掉,你咋知道是實現(xiàn)掛了還是測試本身就掛了呢?

運行速度快

單元測試只有在毫秒級別內(nèi)完成,開發(fā)者才會愿意頻繁地運行它,將其作為快速反饋的手段也才能成立。那么為了使單元測試更快,我們需要:

  • 盡可能地避免依賴。除了恰當設計好對象,關(guān)于避免依賴我已知有兩種不同的看法:
    • 使用mock適當隔離掉三方的依賴(如數(shù)據(jù)庫、網(wǎng)絡、文件等)
    • 避免mock,換用更快速的數(shù)據(jù)庫、啟動輕量級服務器、重點測試文件內(nèi)容等來迂回
  • 將依賴、集成等耗時、依賴三方返回的地方放到更高層級的測試中,有策略性地去做

在如何避免依賴的問題上,截止我下筆此文章時仍在采用第一種方案,如何才能“適當”隔離掉三方依賴也難以在此詳細表述,好在并不影響本文行文;近期可能會考察下第二種方法。

在后面的介紹中,我會將這些原則落實到我們寫的每個單元測試中去。大家可以時時翻到這個章節(jié)來對照,是不是遵循了我們說的這幾點原則,不遵循是不是確實會帶來問題。時時勤拂拭,莫使惹塵埃啊。

第三部分:React 單元測試策略

上個項目上的 React(-Native) 應用架構(gòu)如上所述。它涉及一個常見 React 應用的幾個層面:組件、數(shù)據(jù)管理、redux、副作用管理等,是一個常見的 React、Redux 應用架構(gòu),對于不同的項目應該有一定的適應性。架構(gòu)中的不同元素有不同的特點,因此即便是單元測試,我們也有針對性的測試策略:

架構(gòu)層級 測試內(nèi)容 測試策略 解釋
action(creator)層 是否正確創(chuàng)建 action 對象 一般不需要測試,視信心而定 這個層級架構(gòu)上非常簡單,設施搭好以后一般不可能出錯
reducer 層 是否正確完成計算 有邏輯的 reducer 要求 100%覆蓋率 這個層級輸入輸出明確,又包含業(yè)務計算,非常適合單元測試
selector 層 是否正確完成計算 有邏輯的 selector 要求 100%覆蓋率 這個層級輸入輸出明確,又包含業(yè)務計算,非常適合單元測試
saga 層 是否獲取了正確的參數(shù) 這五個業(yè)務點建議 100% 覆蓋 這個層級主要包含前述 5 大方面的業(yè)務邏輯,進行測試很有重構(gòu)價值
是否正確地調(diào)用了 API
是否使用了正確的返回值存取回 redux 中
業(yè)務分支邏輯
異常邏輯
component 層 組件分支渲染邏輯 要求 100%覆蓋 這個層級最為復雜,還是以「代價最低,收益最高」為指導原則進行
交互事件是否以正確的參數(shù)被調(diào)用 要求 100%覆蓋
redux connect 過的組件 不測
UI 層 組件是否渲染了正確的樣式 目前不測 這個層級以我目前理解來說測試較難穩(wěn)定,成本又較高
utils 層 各種幫助函數(shù) 沒有副作用的必須 100% 覆蓋

對于這個策略,這里做一些其他補充:

關(guān)于不測 redux connect 過的組件策略:理由是成本高于收益,得不償失:

  • 要配置依賴(配置 store、<provider></provider>;如果是補測試還可能遇到 @connect 組件里套 @connect 組件的場景);
  • 犧牲了開發(fā)體驗,搞起來沒那么快了;
  • 收益只是可能覆蓋到了幾個偶爾出現(xiàn)的場景(比如接入錯誤的字段、寫字段時寫錯等);

關(guān)于 UI 測試策略:團隊之前嘗試過 snapshot 測試,對它寄予希望,主要理由是成本低,看起來又像萬能藥。實質(zhì)上其整個機制的工作基礎依賴于開發(fā)者在每次運行測試時耐心做好“確認比對”這個事情,這會打斷日常的開發(fā)節(jié)奏(特別是依賴于TDD的紅綠循環(huán)進行快速反饋的項目);其次還有些小的問題,比如其難以提供精確的快照比對,而只是代碼層面的近似快照。我個人目前對此種測試類型持保留態(tài)度。

第四部分:React 單元測試落地

actions 測試

這一層獲益于架構(gòu)的簡單性,甚至都可以不用測試。當然,如果有些經(jīng)常出錯的action,可以針對性地對這些action creator補充測試。其測試方法如下:

export const saveUserComments = (comments) => ({
  type: 'saveUserComments',
  payload: {
    comments,
  },
})
import * as actions from './actions'

test('should dispatch saveUserComments action with fetched user comments', () => {
  const comments = []
  const expected = {
    type: 'saveUserComments',
    payload: {
      comments: [],
    },
  }
  
  const result = actions.saveUserComments(comments)

  expect(result).toEqual(expected)
})

reducer 測試

reducer 大概有兩種:一種比較簡單,僅一一保存對應的數(shù)據(jù)切片;一種復雜一些,里面具有一些計算邏輯。對于第一種 reducer,寫起來非常簡單,簡單到甚至可以不需要用測試去覆蓋,其正確性基本由簡單的架構(gòu)和邏輯去保證。下面是對一個簡單 reducer 做測試的例子:

import Immutable from 'seamless-immutable'

const initialState = Immutable.from({
  isLoadingProducts: false,
})

export default createReducer((on) => {
  on(actions.isLoadingProducts, (state, action) => {
    return state.merge({
      isLoadingProducts: action.payload.isLoadingProducts,
    })
  })
}, initialState)
import reducers from './reducers'
import actions from './actions'

test('should save loading start indicator when action isLoadingProducts is dispatched given isLoadingProducts is true', () => {
  const state = { isLoadingProducts: false }
  const expected = { isLoadingProducts: true }

  const result = reducers(state, actions.isLoadingProducts(true))

  expect(result).toEqual(expected)
})

下面是一個較為復雜、更具備測試價值的 reducer 例子,它在保存數(shù)據(jù)的同時,還進行了合并、去重的操作:

import uniqBy from 'lodash/uniqBy'

export default createReducers((on) => {
  on(actions.saveUserComments, (state, action) => {
    return state.merge({
      comments: uniqBy(
        state.comments.concat(action.payload.comments), 
        'id',
      ),
    })
  })
})
import reducers from './reducers'
import actions from './actions'

test(`
  should merge user comments and remove duplicated comments by comment id 
  when action saveUserComments is dispatched with new fetched comments
`, () => {
  const state = {
    comments: [{ id: 1, content: 'comments-1' }],
  }
  const comments = [
    { id: 1, content: 'comments-1' },
    { id: 2, content: 'comments-2' },
  ]

  const expected = {
    comments: [
      { id: 1, content: 'comments-1' },
      { id: 2, content: 'comments-2' },
    ],
  }

  const result = reducers(state, actions.saveUserComments(comments))

  expect(result).toEqual(expected)
})

reducer 作為純函數(shù),非常適合做單元測試,加之一般在 reducer 中做重邏輯處理,此處做單元測試保護的價值很大。請留意,上面所說的單元測試,是不是符合我們描述的單元測試基本原則:

  • 只關(guān)注輸入輸出,不關(guān)注內(nèi)部實現(xiàn):在輸入不變時,僅可能因為“合并去重”的業(yè)務操作不符預期時才會掛測試
  • 表達力極強:測試描述已經(jīng)寫得清楚“當使用新獲取到的留言數(shù)據(jù)分發(fā) action saveUserComments 時,應該與已有留言合并并去除重復的部分”;此外,測試數(shù)據(jù)只準備了足夠體現(xiàn)“合并”這個操作的兩條 id 的數(shù)據(jù),而沒有放很多的數(shù)據(jù),形成雜音;
  • 不包含邏輯:測試代碼不包含準備數(shù)據(jù)、調(diào)用、斷言外的任何邏輯
  • 運行速度快:沒有任何依賴

selector 測試

selector 同樣是重邏輯的地方,可以認為是 reducer 到組件的延伸。它也是一個純函數(shù),測起來與 reducer 一樣方便、價值不菲,也是應該重點照顧的部分。況且,稍微大型一點的項目,應該說必然會用到 selector。原因我講在這里。下面看一個 selector 的測試用例:

import { createSelector } from 'reselect'

// for performant access/filtering in React component
export const labelArrayToObjectSelector = createSelector(
  [(store, ownProps) => store.products[ownProps.id].labels],
  (labels) => {
    return labels.reduce(
      (result, { code, active }) => ({
        ...result,
        [code]: active,
      }),
      {}
    )
  }
)
import { labelArrayToObjectSelector } from './selector'

test('should transform label array to object', () => {
  const store = {
    products: {
      10085: {
        labels: [
          { code: 'canvas', name: '帆布鞋', active: false },
          { code: 'casual', name: '休閑鞋', active: false },
          { code: 'oxford', name: '牛津鞋', active: false },
          { code: 'bullock', name: '布洛克', active: true },
          { code: 'ankle', name: '高幫鞋', active: true },
        ],
      },
    },
  }
  const expectedActiveness = {
    canvas: false,
    casual: false,
    oxford: false,
    bullock: true,
    ankle: false,
  }

  const productLabels = labelArrayToObjectSelector(store, { id: 10085 })

  expect(productLabels).toEqual(expectedActiveness)
})

saga 測試

saga 是負責調(diào)用 API、處理副作用的一層。在實際的項目上副作用還有其他的中間層進行處理,比如 redux-thunk、redux-promise 等,本質(zhì)是一樣的,只不過 saga 在測試性上要好一些。這一層副作用怎么測試呢?首先為了保證單元測試的速度和穩(wěn)定性,像 API 調(diào)用這種不確定性的依賴我們一定是要 mock 掉的。經(jīng)過仔細總結(jié),我認為這一層主要的測試內(nèi)容有五點:

  • 是否使用正確的參數(shù)(通常是從 action payload 或 redux 中來),調(diào)用了正確的 API
  • 對于 mock 的 API 返回,是否保存了正確的數(shù)據(jù)(通常是通過 action 保存到 redux 中去)
  • 主要的業(yè)務邏輯(比如僅當用戶滿足某些權(quán)限時才調(diào)用 API 等分支邏輯)
  • 異常邏輯(比如找不到用戶等異常邏輯)
  • 其他副作用是否發(fā)生(比如有時有需要 Emit 的事件、需要保存到 IndexDB 中去的數(shù)據(jù)等)

來自官方的錯誤姿勢

redux-saga 官方提供了一個 util: CloneableGenerator 用以幫我們寫 saga 的測試。這是我們項目使用的第一種測法,大概會寫出來的測試如下:

import chunk from 'lodash/chunk'

export function* onEnterProductDetailPage(action) {
  yield put(actions.notImportantAction1('loading-stuff'))
  yield put(actions.notImportantAction2('analytics-stuff'))
  yield put(actions.notImportantAction3('http-stuff'))
  yield put(actions.notImportantAction4('other-stuff'))

  const recommendations = yield call(Api.get, 'products/recommended')
  const MAX_RECOMMENDATIONS = 3
  const [products = []] = chunk(recommendations, MAX_RECOMMENDATIONS)

  yield put(actions.importantActionToSaveRecommendedProducts(products))

  const { payload: { userId } } = action
  const { vipList } = yield select((store) => store.credentails)
  if (!vipList.includes(userId)) {
    yield put(actions.importantActionToFetchAds())
  }
}
import { put, call } from 'saga-effects'
import { cloneableGenerator } from 'redux-saga/utils'
import { Api } from 'src/utils/axios'
import { onEnterProductDetailPage } from './saga'

const product = (productId) => ({ productId })

test(`
  should only save the three recommended products and show ads 
  when user enters the product detail page 
  given the user is not a VIP
`, () => {
  const action = { payload: { userId: 233 } }
  const credentials = { vipList: [2333] }
  const recommendedProducts = [product(1), product(2), product(3), product(4)]
  const firstThreeRecommendations = [product(1), product(2), product(3)]
  const generator = cloneableGenerator(onEnterProductDetailPage)(action)

  expect(generator.next().value).toEqual(
    actions.notImportantAction1('loading-stuff')
  )
  expect(generator.next().value).toEqual(
    actions.notImportantAction2('analytics-stuff')
  )
  expect(generator.next().value).toEqual(
    actions.notImportantAction3('http-stuff')
  )
  expect(generator.next().value).toEqual(
    actions.notImportantAction4('other-stuff')
  )

  expect(generator.next().value).toEqual(call(Api.get, 'products/recommended'))
  expect(generator.next(recommendedProducts).value).toEqual(
    firstThreeRecommendations
  )
  generator.next()
  expect(generator.next(credentials).value).toEqual(
    put(actions.importantActionToFetchAds())
  )
})

這個方案寫多了,大家開始感受到了痛點,明顯違背我們前面提到的一些原則:

  1. 測試分明就是把實現(xiàn)抄了一遍。這違反上述所說“不關(guān)注內(nèi)部實現(xiàn)”的原則:action的分發(fā)順序也是一種內(nèi)部實現(xiàn),改變實現(xiàn)次序也將使測試掛掉
  2. 當在實現(xiàn)中某個部分加入新的語句時,該語句后續(xù)所有的測試都會掛掉,并且出錯信息非常難以描述原因,導致常常要陷入“調(diào)試測試”的境地,這也是依賴于實現(xiàn)次序帶來的惡果,根本無法支持重構(gòu)這種改變內(nèi)部實現(xiàn)但不改變業(yè)務行為的代碼清理行為
  3. 為了測試兩個重要的業(yè)務“只保存獲取回來的前三個推薦產(chǎn)品”、“對非 VIP 用戶推送廣告”,不得不在前面先按次序斷言許多個不重要的實現(xiàn)
  4. 測試沒有重點,隨便改點什么都會掛測試

正確姿勢

針對以上痛點,我們認為真正能夠保障質(zhì)量、重構(gòu)和開發(fā)者體驗的 saga 測試應該是這樣:

  1. 不依賴實現(xiàn)次序;
  2. 允許僅對真正關(guān)心的、有價值的業(yè)務進行測試;
  3. 支持不改動業(yè)務行為的重構(gòu);

于是,我們發(fā)現(xiàn)官方提供了這么一個跑測試的工具,剛好可以用來完美滿足我們的需求:runSaga。我們可以用它將 saga 全部執(zhí)行一遍,搜集所有發(fā)布出去的 action,由開發(fā)者自由斷言其感興趣的 action!基于這個發(fā)現(xiàn),我們推出了我們的第二版 saga 測試方案:runSaga + 自定義拓展 jest 的 expect 斷言。最終,使用這個工具寫出來的 saga 測試,幾近完美:

import { put, call } from 'saga-effects'
import { Api } from 'src/utils/axios'
import { testSaga } from '../../../testing-utils'
import { onEnterProductDetailPage } from './saga'

const product = (productId) => ({ productId })

test(`
  should only save the three recommended products and show ads 
  when user enters the product detail page 
  given the user is not a VIP
`, async () => {
  const action = { payload: { userId: 233 } }
  const store = { credentials: { vipList: [2333] } }
  const recommendedProducts = [product(1), product(2), product(3), product(4)]
  const firstThreeRecommendations = [product(1), product(2), product(3)]
  Api.get = jest.fn().mockImplementations(() => recommendedProducts)

  await testSaga(onEnterProductDetailPage, action, store)

  expect(Api.get).toHaveBeenCalledWith('products/recommended')
  expect(
    actions.importantActionToSaveRecommendedProducts
  ).toHaveBeenDispatchedWith(firstThreeRecommendations)
  expect(actions.importantActionToFetchAds).toHaveBeenDispatched()
})

這個測試已經(jīng)簡短了許多,沒有了無關(guān)斷言的雜音,依然遵循 given-when-then 的結(jié)構(gòu),并且同樣是測試“只保存獲取回來的前三個推薦產(chǎn)品”、“對非 VIP 用戶推送廣告”兩個關(guān)心的業(yè)務點:

  • 當輸入不變時,無論你怎么優(yōu)化內(nèi)部實現(xiàn)、調(diào)整內(nèi)部次序,這個測試關(guān)心的業(yè)務場景都不會掛,真正做到了測試保護重構(gòu)、支持重構(gòu)的作用
  • 可以僅斷言你關(guān)心的點,忽略不重要或不關(guān)心的中間過程(比如上例中,我們就沒有斷言其他 notImportant 的 action 是否被 dispatch 出去),消除無關(guān)斷言的雜音,提升了表達力
  • 使用了 product 這樣的測試數(shù)據(jù)創(chuàng)建套件(fixtures),精簡測試數(shù)據(jù),消除無關(guān)數(shù)據(jù)的雜音,提升了表達力
  • 自定義的 expect(action).toHaveBeenDispatchedWith(payload) matcher 很有表達力,且出錯信息友好

這個自定義的 matcher 是通過 jest 的 expect.extend 擴展實現(xiàn)的:

expect.extend({
  toHaveBeenDispatched(action) { ... },
  toHaveBeenDispatchedWith(action, payload) { ... },
})

上面是我們認為比較好的副作用測試工具、測試策略和測試方案。使用時,需要牢記你真正關(guān)心的業(yè)務價值點(也即本節(jié)開始提到的 5 點),以及做到在較為復雜的單元測試中始終堅守幾條基本原則。唯如此,單元測試才能真正提升開發(fā)速度、支持重構(gòu)、充當業(yè)務上下文的文檔。

作者注:本文成文后,社區(qū)又有一些簡化測試的方案出來。讀者也可帶著這些測試原則去考察一番:

component 測試

組件測試其實是實踐最多、測試實踐看法和分歧也最多的地方。React 組件是一個高度自治的單元,從分類上來看,它大概有這么幾類:

  • 展示型業(yè)務組件
  • 容器型業(yè)務組件
  • 通用 UI 組件
  • 功能型組件

先把這個分類放在這里,待會回過頭來談。對于 React 組件測什么不測什么,我有一些思考,也有一些判斷標準:除去功能型組件,其他類型的組件一般是以渲染出一個語法樹為終點的,它描述了頁面的 UI 內(nèi)容、結(jié)構(gòu)、樣式和一些邏輯 component(props) => UI。內(nèi)容、結(jié)構(gòu)和樣式,比起測試,直接在頁面上調(diào)試反饋效果更好。測也不是不行,但都難免有不穩(wěn)定的成本在;邏輯這塊,有一測的價值,但需要控制好依賴。綜合上面提到的測試原則進行考慮,我的建議是:兩測兩不測。

  • 組件分支渲染邏輯必須測
  • 事件調(diào)用和參數(shù)傳遞一般要測
  • 連接 redux 的高階組件不測
  • 渲染出來的 UI 不在單元測試層級測

組件的分支邏輯,往往也是有業(yè)務含義和業(yè)務價值的分支,添加單元測試既能保障重構(gòu),還可順便做文檔用;事件調(diào)用同樣也有業(yè)務價值和文檔作用,而事件調(diào)用的參數(shù)調(diào)用有時可起到保護重構(gòu)的作用。

純 UI 不在單元測試級別測試的原因,純粹就是因為不好斷言。所謂快照測試有意義的前提在于兩個:必須是視覺級別的比對、必須開發(fā)者每次都認真檢查。jest 有個 snapshot 測試的概念,但那個 UI 測試是代碼級的比對,不是視覺級的比對,最終還是繞了一圈,去除了雜音還不如看 Git 的 commit diff。每次要求開發(fā)者自覺檢查,既打亂工作流,也難以堅持。考慮到這些成本,我不推薦在單元測試的級別來做 UI 類型的測試。對于我們之前中等規(guī)模的項目,訴諸手工還是有一定的可控性。

連接 redux 的高階組件不測。原因是,connect 過的組件從測試的角度看無非幾個測試點:

  • mapStateToProps 中是否從 store 中取得了正確的參數(shù)
  • mapDispatchToProps 中是否地從 actions 中取得了正確的參數(shù)
  • map 過的 props 是否正確地被傳遞給了組件
  • redux 對應的數(shù)據(jù)切片更新時,是否會使用新的 props 觸發(fā)組件進行一次更新

這四個點,react-redux 已經(jīng)都幫你測過了,已經(jīng)證明 work 了,開發(fā)者沒有必要進行測試。當然,不測這個東西的話,還是有這么一種可能,就是你 export 的純組件測試都是過的,但是代碼實際運行出錯。窮盡下來主要可能是這幾種問題:

  • 你在 mapStateToProps 中打錯了字或打錯了變量名
  • 你寫了 mapStateToProps 但沒有 connect 上去
  • 你在 mapStateToProps 中取的路徑是錯的,在 redux 中已經(jīng)被改過

第一、二種可能,如果是小步前進其實發(fā)現(xiàn)起來很快。如果某段數(shù)據(jù)獲取的邏輯多處重復,則可以考慮將該邏輯抽取到 selector 中并進行單獨測試;第三種可能,確實是問題,但由于在我所在項目發(fā)生頻率較低(部分因為上個項目沒有類型系統(tǒng)我們不會隨意改 redux 的數(shù)據(jù)結(jié)構(gòu)…),所以針對這些少量出現(xiàn)的場景,不必要采取錯殺一千的方式進行完全覆蓋。默認不測,出了問題或者經(jīng)??赡艹鰡栴}的部分,再策略性地補上測試進行固定即可。

綜上,@connect 組件默認不測,因為框架本身已做了大部分測試,剩下的場景出 bug 頻率不高,而施加測試的話提高成本(準備依賴和數(shù)據(jù)),降低開發(fā)體驗,性價比不大,所以建議省了這份心。不測 @connect 過的組件,其實也是 官方文檔 推薦的做法。

然后,基于上面第 1、2 個結(jié)論,映射回四類組件的結(jié)構(gòu)當中去,我們可以得到下面的表格,然后發(fā)現(xiàn)…每種組件都要測渲染分支事件調(diào)用,跟組件類型根本沒必然的關(guān)聯(lián)…不過,功能型組件有可能會涉及一些其他的模式,因此又大致分出一小節(jié)來談。

組件類型 / 測試內(nèi)容 分支渲染邏輯 事件調(diào)用 @connect 純 UI
展示型組件 ? ? - ??
容器型組件 ? ? ?? ??
通用 UI 組件 ? ? - ??
功能型組件 ? ? ?? ??

業(yè)務型組件 - 分支渲染

export const CommentsSection = ({ comments }) => (
  <div>
    {comments.length > 0 && (
      <h2>Comments</h2>
    )}

    {comments.map((comment) => (
      <Comment content={comment} key={comment.id} />
    )}
  </div>
)

對應的測試如下,測試的是不同的分支渲染邏輯:沒有評論時,則不渲染 Comments header。

import { CommentsSection } from './index'
import { Comment } from './Comment'

test('should not render a header and any comment sections when there is no comments', () => {
  const component = shallow(<CommentsSection comments={[]} />)

  const header = component.find('h2')
  const comments = component.find(Comment)

  expect(header).toHaveLength(0)
  expect(comments).toHaveLength(0)
})

test('should render a comments section and a header when there are comments', () => {
  const contents = [
    { id: 1, author: '男***8', comment: '價廉物美,相信奧康旗艦店' },
    { id: 2, author: '雨***成', comment: '所以一雙合腳的鞋子...' },
  ]
  const component = shallow(<CommentsSection comments={contents} />)

  const header = component.find('h2')
  const comments = component.find(Comment)

  expect(header.html()).toBe('Comments')
  expect(comments).toHaveLength(2)
})

業(yè)務型組件 - 事件調(diào)用

測試事件的一個場景如下:當某條產(chǎn)品被點擊時,應該將產(chǎn)品相關(guān)的信息發(fā)送給埋點系統(tǒng)進行埋點。

export const ProductItem = ({
  id,
  productName,
  introduction,
  trackPressEvent,
}) => (
  <TouchableWithoutFeedback onPress={() => trackPressEvent(id, productName)}>
    <View>
      <Title name={productName} />
      <Introduction introduction={introduction} />
    </View>
  </TouchableWithoutFeedback>
)
import { ProductItem } from './index'

test(`
  should send product id and name to analytics system 
  when user press the product item
`, () => {
  const trackPressEvent = jest.fn()
  const component = shallow(
    <productitem id={100832}
      introduction="iMac Pro - Power to the pro."
      trackPressEvent={trackPressEvent}></productitem>
  )

  component.find(TouchableWithoutFeedback).simulate('press')

  expect(trackPressEvent).toHaveBeenCalledWith(
    100832,
    'iMac Pro - Power to the pro.'
  )
})

簡單得很吧。這里的幾個測試,在你改動了樣式相關(guān)的東西時,不會掛掉;但是如果你改動了分支邏輯或函數(shù)調(diào)用的內(nèi)容時,它就會掛掉了。而分支邏輯或函數(shù)調(diào)用,恰好是我覺得接近業(yè)務的地方,所以它們對保護代碼邏輯、保護重構(gòu)是有價值的。當然,它們多少還是依賴了組件內(nèi)部的實現(xiàn)細節(jié),比如說 find(TouchableWithoutFeedback),還是做了“組件內(nèi)部使用了 TouchableWithoutFeedback 組件”這樣的假設,而這個假設很可能是會變的。也就是說,如果我換了一個組件來接受點擊事件,盡管點擊時的行為依然發(fā)生,但這個測試仍然會掛掉。這就違反了我們所說了“不關(guān)注內(nèi)部實現(xiàn)”原則,這對于組件測試來說,確實是不夠完美的地方。

但這個問題無法避免。因為組件本質(zhì)是渲染組件樹,那么測試中要與組件樹關(guān)聯(lián),必然要通過組件名、id這樣的 selector,這些 selector 的關(guān)聯(lián)本身就是一些“內(nèi)部實現(xiàn)”的細節(jié)。但對組件的分支、事件進行測試又有一定的價值,無法避免。所以,我認為這個部分還是要用,只不過同時需要一些限制,以控制這些假設為維護測試帶來的額外成本:

  • 不要斷言組件內(nèi)部結(jié)構(gòu)。像那些 expect(component.find('div > div > p').html().toBe('Content') 的真的就算了吧
  • 正確拆分組件樹。一個組件盡量只負責一個(或一組高度相關(guān)的)功能,不允許堆疊太多的函數(shù)和功能

也就是說,如果你發(fā)現(xiàn)你很難快速地準備對組件的測試,那么有可能是你的組件太復雜了,這也是一個壞味道。多數(shù)情況下是組件承擔了太多的職責,你應該將它們拆成更小的組件,使其符合單一職責原則。

如果你的每個組件都十分清晰直觀、邏輯分明,那么像上面這樣的組件測起來也就很輕松,一般就遵循 shallow -> find(Component) -> 斷言的三段式,哪怕是了解了一些組件的內(nèi)部細節(jié),通常也在可控的范圍內(nèi),維護起來成本并不高。這是目前我覺得平衡了表達力、重構(gòu)意義和測試成本的實踐。

功能型組件 - children 型高階組件

功能型組件,指的是跟業(yè)務無關(guān)的另一類組件:它是功能型的,更像是底層支撐著業(yè)務組件運作的基礎組件,比如路由組件、分頁組件等。這些組件一般偏重邏輯多一點,關(guān)心 UI 少一些。其本質(zhì)測法跟業(yè)務組件是一致的:不關(guān)心 UI 具體渲染,只測分支渲染和事件調(diào)用。但由于它偏功能型的特性,使得它在設計上常會出現(xiàn)一些業(yè)務型組件不常出現(xiàn)的設計模式,如高階組件、以函數(shù)為子組件等。下面分別針對這幾種進行分述。

export const FeatureToggle = ({ features, featureName, children }) => {
  if (!features[featureName]) {
    return null
  }

  return children
}

export default connect(
  (store) => ({ features: store.global.features })
)(FeatureToggle)
import React from 'react'
import { shallow } from 'enzyme'
import { View } from 'react-native'

import FeatureToggles from './featureToggleStatus'
import { FeatureToggle } from './index'

const DummyComponent = () => <View />

test('should not render children component when remote toggle does not exist', () => {
  const component = shallow(
    <FeatureToggle features={{}} featureName="promotion618">
      <DummyComponent />
    </FeatureToggle>
  )

  expect(component.find(DummyComponent)).toHaveLength(0)
})

test('should render children component when remote toggle is present and is on', () => {
  const features = {
    promotion618: FeatureToggles.on,
  }

  const component = shallow(
    <FeatureToggle features={features} featureName="promotion618">
      <DummyComponent />
    </FeatureToggle>
  )

  expect(component.find(DummyComponent)).toHaveLength(1)
})

test('should not render children component when remote toggle is present but is off', () => {
  const features = {
    promotion618: FeatureToggles.off,
  }

  const component = shallow(
    <FeatureToggle features={features} featureName="promotion618">
      <DummyComponent />
    </FeatureToggle>
  )

  expect(component.find(DummyComponent)).toHaveLength(0)
})

utils 測試

每個項目都會有 utils。一般來說,我們期望 util 都是純函數(shù),即是不依賴外部狀態(tài)、不改變參數(shù)值、不維護內(nèi)部狀態(tài)的函數(shù)。這樣的函數(shù)測試效率也非常高。測試原則跟前面所說的也并沒什么不同,不再贅述。不過值得一提的是,因為 util 函數(shù)多是數(shù)據(jù)驅(qū)動,一個輸入對應一個輸出,并且不需要準備任何依賴,這使得它多了一種測試的選擇,也即是參數(shù)化測試的方式。參數(shù)化測試可以提升數(shù)據(jù)準備效率,同時依然能保持詳細的用例信息、錯誤提示等優(yōu)點。jest 從 23 后就內(nèi)置了對參數(shù)化測試的支持,如下:

test.each([
  [['0', '99'], 0.99, '(整數(shù)部分為0時也應返回)'],
  [['5', '00'], 5, '(小數(shù)部分不足時應該補0)'],
  [['5', '10'], 5.1, '(小數(shù)部分不足時應該補0)'],
  [['4', '38'], 4.38, '(小數(shù)部分不足時應該補0)'],
  [['4', '99'], 4.994, '(超過默認2位的小數(shù)的直接截斷,不四舍五入)'],
  [['4', '99'], 4.995, '(超過默認2位的小數(shù)的直接截斷,不四舍五入)'],
  [['4', '99'], 4.996, '(超過默認2位的小數(shù)的直接截斷,不四舍五入)'],
  [['-0', '50'], -0.5, '(整數(shù)部分為負數(shù)時應該保留負號)'],
])(
  'should return %s when number is %s (%s)',
  (expected, input, description) => {
    expect(truncateAndPadTrailingZeros(input)).toEqual(expected)
  }
)

當然,對純數(shù)據(jù)驅(qū)動的測試,也有一些不同的看法,認為這樣可能丟失一些描述業(yè)務場景的測試描述。所以這種方式還主要看項目組的接受度。

總結(jié)

好,到此為止,本文的主要內(nèi)容也就講完了。總結(jié)下來,本文主要覆蓋到的內(nèi)容如下:

  • 單元測試對于任何 React 項目(及其他任何項目)來說都是必須的
  • 我們需要自動化的測試套件,根本目標是支持隨時隨地的代碼調(diào)整、持續(xù)改進,從而提升團隊響應力
  • 使用TDD開發(fā)是得到好的單元測試的唯一途徑
  • 好的單元測試具備幾大特征:不關(guān)注內(nèi)部實現(xiàn)、只測一條分支、表達力極強不包含邏輯、運行速度快
  • 單元測試也有測試策略:在 React 的典型架構(gòu)下,一個典型的測試策略為:
    • reducer、selector 層的邏輯代碼要求 100% 覆蓋
    • saga(副作用)層:是否拿到了正確的參數(shù)、是否調(diào)用了正確的 API、是否保存了正確的數(shù)據(jù)業(yè)務邏輯、異常邏輯五個層面要求100%覆蓋
    • action 層選擇性覆蓋:可不測
    • utils 層的純函數(shù)要求 100% 覆蓋
    • 組件層:
      • 分支渲染邏輯必測、事件、交互調(diào)用要求100%覆蓋;
      • @connect 過的高階組件不測
      • 純 UI 一般不測
  • 其他高級技巧:定制測試工具(jest.extend)、參數(shù)化測試等

未盡話題

誠然,關(guān)于構(gòu)建一個完整的前端測試體系,有一些點是本文沒有涉及到的,或因為沒有涉獵,或因為尚未嘗試,或因為未有結(jié)論,一并羅列于下。有興趣的讀者可來電交流。

  • 其他層級的測試:前端典型的其他層級測試如契約測試、端到端測試等
  • CSS的測試
  • HTML的測試與重構(gòu)

參考


文/ThoughtWorks林從羽
更多精彩洞見,請關(guān)注微信公眾號:ThoughtWorks洞見

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容