單元測試是現(xiàn)代軟件開發(fā)最基本,也普遍落地不力的實踐。市面關(guān)于React單元測試的文章,普遍停留在“可以如何寫”和介紹工具的層面,既未回答“為何必須做單元測試”,也未回答“單元測試的最佳實踐”兩個關(guān)鍵問題。本文正是要對這兩個問題作出回答。
本文所用技術(shù)棧為前端React棧,測試框架與斷言工具是jest。文章將略過對測試框架本身的語法介紹,著眼于“為何做”與“最佳實踐”的部分。閱讀第二部分的代碼,需要讀者對JavaScript與React語法有一定了解,但第一部分的原理并不對讀者做任何語言上的假設。
目錄
- 第一部分:為什么必須做單元測試
- 單元測試的上下文
- 測試策略——測試金字塔
- TDD——單元測試的核心靈魂
- 第二部分:什么是好的單元測試
- 第三部分:React 單元測試策略
- 第四部分:React 單元測試落地
- actions 測試
- reducer 測試
- selector 測試
- saga 測試
- 來自官方的錯誤姿勢
- 正確姿勢
- component 測試
- 業(yè)務型組件 - 分支渲染
- 業(yè)務型組件 - 事件調(diào)用
- 功能型組件 -
children型高階組件
- utils 測試
- 總結(jié)
- 未盡話題
- 參考
第一部分:為什么必須做單元測試
對于單元測試有保障質(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())
)
})
這個方案寫多了,大家開始感受到了痛點,明顯違背我們前面提到的一些原則:
- 測試分明就是把實現(xiàn)抄了一遍。這違反上述所說“不關(guān)注內(nèi)部實現(xiàn)”的原則:action的分發(fā)順序也是一種內(nèi)部實現(xiàn),改變實現(xiàn)次序也將使測試掛掉
- 當在實現(xiàn)中某個部分加入新的語句時,該語句后續(xù)所有的測試都會掛掉,并且出錯信息非常難以描述原因,導致常常要陷入“調(diào)試測試”的境地,這也是依賴于實現(xiàn)次序帶來的惡果,根本無法支持重構(gòu)這種改變內(nèi)部實現(xiàn)但不改變業(yè)務行為的代碼清理行為
- 為了測試兩個重要的業(yè)務“只保存獲取回來的前三個推薦產(chǎn)品”、“對非 VIP 用戶推送廣告”,不得不在前面先按次序斷言許多個不重要的實現(xiàn)
- 測試沒有重點,隨便改點什么都會掛測試
正確姿勢
針對以上痛點,我們認為真正能夠保障質(zhì)量、重構(gòu)和開發(fā)者體驗的 saga 測試應該是這樣:
- 不依賴實現(xiàn)次序;
- 允許僅對真正關(guān)心的、有價值的業(yè)務進行測試;
- 支持不改動業(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洞見





