摘要:寫好的單元測試,對開發速度項目維護有莫大的幫助。我認為單元測試的上下文存在于敏捷中。接下來一小節,就可以正式進入如何做的環節了如何寫好單元測試。前面說到,我們對單元測試寄予
寫好的單元測試,對開發速度、項目維護有莫大的幫助。前端的測試工具一直推陳出新,而測試的核心、原則卻少有變化。與產品代碼一并交付可靠的測試代碼,是每個專業開發者應該不斷靠近的一個理想之地。本文就圍繞測試講講,為什么我們要做測試,什么是好的測試和原則,以及如何在一個 React 項目中落地這些測試策略。
本文使用的測試框架、斷言工具是 jest。文章不打算對測試框架、語法本身做過多介紹,因為已有很多文章。本文假定讀者已有一定基礎,至少熟悉語法,但并不假設讀者寫過單元測試。在介紹什么是好的單元測試時,我會簡單介紹一個好的單元測試的結構。目錄Github 討論:https://github.com/linesh-sim...
原文地址:https://blog.linesh.tw/#/post...
為什么要做單元測試
單元測試的上下文
測試策略:測試金字塔
如何寫好單元測試:好測試的特征
有且僅有一個失敗的理由
表達力極強
快、穩定
React 單元測試策略及落地
React 應用的單元測試策略
actions 測試
reducer 測試
selector 測試
saga 測試
來自官方的錯誤姿勢
正確姿勢
component 測試
業務型組件 - 分支渲染
業務型組件 - 事件調用
功能型組件 - children 型高階組件
utils 測試
總結
未盡話題 & 歡迎討論
為什么要做單元測試雖然關于測試的文章有很多,關于 React 的文章也有很多,但關于 React 應用之詳細單元測試的文章還比較少。而且更多的文章都更偏向于對工具本身進行講解,只講「我們可以這么測」,卻沒有回答「我們為什么要這么測」、「這么測究竟好不好」的問題。這幾個問題上的空白,難免使人得出測試無用、測試成本高、測試使開發變慢的錯誤觀點,導致在「質量內建」已漸入人心的今日,很多人仍然認為測試是二等公民,是成本,是錦上添花。這一點上,我的態度一貫鮮明:不僅要寫測試,還要把單元測試寫好;不僅要有測試前移質量內建的意識,還要有基于測試進行快速反饋快速開發的能力。沒自動化測試的代碼不叫完成,不能驗收。
「為什么我們需要做單元測試」,這是一個關鍵的問題。每個人都有自己關于該不該做測試、該怎么做、做到什么程度的看法,試圖面面俱到、左右逢源地評價這些看法是不可能的。我們需要一個視角,一個談論單元測試的上下文。做單元測試當然有好處,但本文不會從有什么好處出發來談,而是談,在我們在意的這個上下文中,不做單元測試會有什么問題。
那么我們談論單元測試的上下文是什么呢?不做單元測試我們會遇到什么問題呢?
單元測試的上下文先說說問題。最大的一個問題是,不寫單元測試,你就不敢重構,就只能看著代碼腐化。代碼質量談不上,持續改進談不上,個人成長更談不上。始終是原始的勞作方式。
再說說上下文。我認為單元測試的上下文存在于「敏捷」中。現代企業數字化競爭日益激烈,業務端快速上線、快速驗證、快速失敗的思路對技術端的響應力提出了更高的要求:更快上線、更頻繁上線、持續上線。怎么樣衡量這個「更快」呢?那就是第一圖提到的 lead time,它度量的是一個 idea 從提出并被驗證,到最終上生產環境面對用戶獲取反饋的時間。顯然,這個時間越短,軟件就能越快獲得反饋,對價值的驗證就越快發生。這個結論對我們寫不寫單元測試有什么影響呢?答案是,不寫單元測試,你就快不起來。為啥呢?因為每次發布,你都要投入人力來進行手工測試;因為沒有測試,你傾向于不敢隨意重構,這又導致代碼逐漸腐化,復雜度使得你的開發速度降低。
再考慮到以下兩個大事實:人員會流動,應用會變大。人員一定會流動,需求一定會增加,再也沒有任何人能夠了解任何一個應用場景。因此,意圖依賴人、依賴手工的方式來應對響應力的挑戰首先是低效的,從時間維度上來講也是不現實的。那么,為了服務于「高響應力」這個目標,我們就需要一套自動化的測試套件,它能幫我們提供快速反饋、做質量的守衛者。唯解決了人工、質量的這一環,效率才能穩步提升,團隊和企業的高響應力才可能達到。
那么在「響應力」這個上下文中來談要不要單元測試,我們就可以很有根據了,而不是開發爽了就用,不爽就不用這樣含糊的答案:
如果你說我的業務部門不需要頻繁上線,并且我有足夠的人力來覆蓋手工測試,那你可以不用單元測試
如果你說我是個小項目小部門不需要多高的響應力,每天摸摸魚就過去了,那你可以不用單元測試
如果你說我不在意代碼腐化,并且我也不做重構,那你可以不用單元測試
如果你說我不在意代碼質量,好幾個沒有測試保護的 if-else 裸奔也不在話下,腦不好還做什么程序員,那你可以不用單元測試
如果你說我確有快速部署的需求,但我們不 care 質量問題,出回歸問題就修,那你可以不用單元測試
除此之外,你就需要寫單元測試。如果你想隨時整理重構代碼,那么你需要寫單元測試;如果你想有自動化的測試套件來幫你快速驗證提交的完整性,那么你需要寫單元測試;如果你是個長期項目有人員流動,那么你需要寫單元測試;如果你不想花大量的時間在記住業務場景和手動測試應用上,那么你就需要單元測試。
至此,我們從「響應力」這個上下文中,回答了「為什么我們需要寫單元測試」的問題。接下來可以談下一個問題了:「為什么是單元測試」。
測試策略:測試金字塔上面我直接從高響應力談到單元測試,可能有的同學會問,高響應力這個事情我認可,也認可快速開發的同時,質量也很重要。但是,為了達到「保障質量」的目的,不一定得通過測試呀,也不一定得通過單元測試鴨。
這是個好的問題。為了達到保障質量這個目標,測試當然只是其中一個方式,穩定的自動化部署、集成流水線、良好的代碼架構、組織架構的必要調整等,都是必須跟上的設施。我從未認為單元測試是解決質量問題的銀彈,多方共同提升才可能起到效果。但相反,也很難想象單元測試都沒有都寫不好的項目,能有多高的響應力。
即便我們談自動化測試,未必也不可能全部都是寫單元測試。我們對自動化測試套件寄予的厚望是,它能幫我們安全重構已有代碼、保存業務上下文、快速回歸。測試種類多種多樣,為什么我要重點談單元測試呢?因為這篇文章主題就是談單元測試啊…它寫起來相對最容易、運行速度最快、反饋效果又最直接。下面這個圖,想必大家都有所耳聞:
這就是有名的測試金字塔。對于一個自動化測試套件,應該包含種類不同、關注點不同的測試,比如關注單元的單元測試、關注集成和契約的集成測試和契約測試、關注業務驗收點的端到端測試等。正常來說,我們會受到資源的限制,無法應用所有層級的測試,效果也未必最佳。因此,我們需要有策略性地根據收益-成本的原則,考慮項目的實際情況和痛點來定制測試策略:比如三方依賴多的項目可以多寫些契約測試,業務場景多、復雜或經常回歸的場景可以多寫些端到端測試,等。但不論如何,整個測試金字塔體系中,你還是應該擁有更多低層次的單元測試,因為它們成本相對最低,運行速度最快(通常是毫秒級別),而對單元的保護價值相對更大。
以上是對「為什么我們需要的是單元測試」這個問題的回答。接下來一小節,就可以正式進入如何做的環節了:「如何寫好單元測試」。
關于測試金字塔的補充閱讀:測試金字塔實戰。
如何寫好單元測試:好測試的特征寫單元測試僅僅是第一步,下面還有個更關鍵的問題,就是怎樣寫出好的、容易維護的單元測試。好的測試有其特征,雖然它并不是什么新的東西,但總需要時時拿出來溫故知新。很多時候,同學感覺測試難寫、難維護、不穩定、價值不大等,可能都是因為單元測試寫不好所導致的。那么我們就來看看,一個好的單元測試,應該遵循哪幾點原則。
首先,我們先來看個簡單的例子,一個最簡單的 JavaScript 的單元測試長什么樣:
// production code const computeSumFromObject = (a, b) => { return a.value + b.value } // testing code it("should return 5 when adding object a with value 2 and b with value 3", () => { // given - 準備數據 const a = { value: 2 } const b = { value: 3 } // when - 調用被測函數 const result = computeSumFromObject(a, b) // then - 斷言結果 expect(result).toBe(5) })
以上就是一個最簡答的單元測試部分。但麻雀雖小,五臟基本全,它揭示了單元測試的一個基本結構:準備輸入數據、調用被測函數、斷言輸出結果。任何單元測試都可以遵循這樣一個骨架,它是我們常說的 given-when-then 三段式。
為什么說單元測試說來簡單,做到卻不簡單呢?除了遵循三段式,顯然我們還需要遵循一些其他的原則。前面說到,我們對單元測試寄予了幾點厚望,下面就來看看,它如何能達到我們期望的效果,以此來反推單元測試的特征:
安全重構已有代碼 -> 應該有且僅有一個失敗的理由、不關注內部實現
保存業務上下文 -> 表達力極強
快速回歸 -> 快、穩定
下面來看看這三個原則都是咋回事:
有且僅有一個失敗的理由有且僅有一個失敗的理由,這個理由是什么呢?是 「當輸入不變時,當且僅當被測業務代碼功能被改動了」時,測試才應該掛掉。為什么這會支持我們重構呢,因為重構的意思是,在不改動軟件外部可觀測行為的基礎上,調整軟件內部實現的一種手段。也就是說,當我被測的代碼輸入輸出沒變時,任我怎么倒騰重構代碼的內部實現,測試都不應該掛掉。這樣才能說是支持了重構。有的單元測試寫得,內部實現(比如數據結構)一調整,測試就掛掉,盡管它的業務本身并沒修改,這樣怎么支持重構呢?不怪得要反過來罵測試成本高,沒有用。一般會出現這種情況,可能是因為是先寫完代碼再補的測試,或者對代碼的接口和抽象不明確所導致。
另外,還有一些測試(比如下文要看到的 saga 官方推薦的測試),它需要測試實現代碼的執行次序。這也是一種「關注內部實現」的測試,這就使得除了業務目標外,還有「執行次序」這個因素可能使測試掛掉。這樣的測試也是很脆弱的。
表達力極強表達力極強,講的是兩方面:
看到測試時,你就知道它測的業務點是啥
測試掛掉時,能清楚地知道業務、期望數據與實際輸出的差異
這些表達力體現在許多方面,比如測試描述、數據準備的命名、與測試無關數據的清除、斷言工具能提供的比對等。空口無憑,請大家在閱讀后面測試落地時時常對照。
快、穩定不快的單元測試還能叫單元測試嗎?一般來講,一個沒有依賴、沒有 API 調用的單元測試,都能在毫秒級內完成。那么為了達到快、穩定這個目標,我們需要:
隔離盡量多的依賴。依賴少,速度就快,自然也更穩定
將依賴、集成等耗時、依賴三方返回的地方放到更高層級的測試中,有策略性地去做
測試代碼中不要包含邏輯。不然你咋知道是實現掛了還是你的測試掛了呢?
在后面的介紹中,我會將這些原則落實到我們寫的每個單元測試中去。大家可以時時翻到這個章節來對照,是不是遵循了我們說的這幾點原則,不遵循是不是確實會帶來問題。時時勤拂拭,莫使惹塵埃啊。
React 單元測試策略及落地 React 應用的單元測試策略上個項目上的 React(-Native) 應用架構如上所述。它涉及一個常見 React 應用的幾個層面:組件、數據管理、redux、副作用管理等,是一個常見的 React、Redux 應用架構,也是 dva 所推薦的 66%的最佳實踐(redux+saga),對于不同的項目應該有一定的適應性。架構中的不同元素有不同的特點,因此即便是單元測試,我們也有針對性的測試策略:
架構層級 | 測試內容 | 測試策略 | 解釋 |
---|---|---|---|
action(creator) 層 | 是否正確創建 action 對象 | 一般不需要測試,視信心而定 | 這個層級非常簡單,基礎設施搭好以后一般不可能出錯,享受了架構帶來的簡單性 |
reducer 層 | 是否正確完成計算 | 對于有邏輯的 reducer 需要 100%覆蓋率 | 這個層級輸入輸出明確,又有業務邏輯的計算在內,天然屬于單元測試寵愛的對象 |
selector 層 | 是否正確完成計算 | 對于有較復雜邏輯的 selector 需要 100%覆蓋率 | 這個層級輸入輸出明確,又有業務邏輯的計算在內,天然屬于單元測試寵愛的對象 |
saga(副作用) 層 | 是否獲取了正確的參數去調用 API,并使用正確的數據存取回 redux 中 | 對于是否獲取了正確參數、是否調用正確的 API、是否使用了正確的返回值保存數據、業務分支邏輯、異常分支 這五個業務點建議 100% 覆蓋 | 這個層級也有業務邏輯,對前面所述的 5 大方面進行測試很有重構價值 |
component(組件接入) 層 | 是否渲染了正確的組件 | 組件的分支渲染邏輯要求 100% 覆蓋、交互事件的調用參數一般要求 100% 覆蓋、被 redux connect 過的組件不測、純 UI 不測、CSS 一般不測 | 這個層級最為復雜,測試策略還是以「代價最低,收益最高」為指導原則進行 |
UI 層 | 樣式是否正確 | 目前不測 | 這個層級以我目前理解來說,測試較難穩定,成本又較高 |
utils 層 | 各種幫助函數 | 沒有副作用的必須 100% 覆蓋,有副作用的視項目情況自定 |
對于這個策略,這里做一些其他補充:
關于不測 redux connect 過的組件這個策略。理由是成本遠高于收益:要犧牲開發體驗(搞起來沒那么快了),要配置依賴(配置 store、
關于 UI 測試這塊的策略。團隊之前嘗試過 snapshot 測試,對它寄予厚望,理由是成本低,看起來又像萬能藥。不過由于其難以提供精確快照比對,整個工作的基礎又依賴于開發者盡心做好「確認比對」這個事情,很依賴人工耐心又打斷日常的開發節奏,導致成本和收益不成正比。我個人目前是持保留態度的。
關于 DOM 測試這塊的策略。也就是通過 enzyme 這類工具,通過 css selector 來進行 DOM 渲染方面的測試。這類測試由于天生需要通過 css selector 去關聯 DOM 元素,除了被測業務外 css selector 本身就是掛測試的一個因素。一個 DOM 測試至少有兩個原因可使它掛掉,并不符合我們上面提到的最佳實踐。但這種測試有時又確實有用,后文講組件測試時會專門提到,如何針對它制定適合的策略。
actions 測試這一層太過簡單,基本都可以不用測試,獲益于架構的簡單性。當然,如果有些經常出錯的 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: [], }, } expect(actions.saveUserComments(comments)).toEqual(expected) })reducer 測試
reducer 大概有兩種:一種比較簡單,僅一一保存對應的數據切片;一種復雜一些,里面具有一些計算邏輯。對于第一種 reducer,寫起來非常簡單,簡單到甚至可以不需要用測試去覆蓋。其正確性基本由簡單的架構和邏輯去保證的。下面是對一個簡單 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 例子,它在保存數據的同時,還進行了合并、去重的操作:
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 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 作為純函數,非常適合做單元測試,加之一般在 reducer 中做重邏輯處理,此處做單元測試保護的價值也很大。請留意,上面所說的單元測試,是不是符合我們描述的單元測試基本原則:
有且僅有一個失敗的理由:當輸入不變時,僅當我們被測「合并去重」的業務操作不符預期時,才可能掛掉測試
表達力極強:測試描述已經寫得清楚「當使用新獲取到的留言數據分發 action saveUserComments 時,應該與已有留言合并并去除重復的部分」;此外,測試數據只準備了足夠體現「合并」這個操作的兩條 id 的數據,而沒有放很多的數據,形成雜音;
快、穩定:沒有任何依賴,測試代碼不包含準備數據、調用、斷言外的任何邏輯
selector 測試selector 同樣是重邏輯的地方,可以認為是 reducer 到組件的延伸。它也是一個純函數,測起來與 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 expected = { canvas: false, casual: false, oxford: false, bullock: true, ankle: false, } const productLabels = labelArrayToObjectSelector(store, { id: 10085 }) expect(productLabels).toEqual(expected) })saga 測試
saga 是負責調用 API、處理副作用的一層。在實際的項目上副作用還有其他的中間層進行處理,比如 redux-thunk、redux-promise 等,本質是一樣的,只不過 saga 在測試性上要好一些。這一層副作用怎么測試呢?首先為了保證單元測試的速度和穩定性,像 API 調用這種不確定性的依賴我們一定是要 mock 掉的。經過仔細總結,我認為這一層主要的測試內容有五點:
是否使用正確的參數(通常是從 action payload 或 redux 中來),調用了正確的 API
對于 mock 的 API 返回,是否保存了正確的數據(通常是通過 action 保存到 redux 中去)
主要的業務邏輯(比如僅當用戶滿足某些權限時才調用 API 等)
異常邏輯
其他副作用是否發生(比如有時有需要 Emit 的事件、需要保存到 IndexDB 中去的數據等)
來自官方的錯誤姿勢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()) ) })
這個方案寫多了,大家開始感受到了痛點,明顯違背我們前面提到的一些原則:
測試分明就是把實現抄了一遍。這違反上述所說「有且僅有一個掛測試的理由」的原則,改變實現次序也將會使測試掛掉
當在實現中某個部分加入新的語句時,該語句后續所有的測試都會掛掉,并且出錯信息非常難以描述原因,導致常常要陷入「調試測試」的境地,這也是依賴于實現次序帶來的惡果,根本無法支持「重構」這種改變內部實現但不改變業務行為的代碼清理行為
為了測試兩個重要的業務「只保存獲取回來的前三個推薦產品」、「對非 VIP 用戶推送廣告」,不得不在前面先按次序先斷言許多個不重要的實現
測試沒有重點,隨便改點什么都會掛測試
正確姿勢針對以上痛點,我們理想中的 saga 測試應該是這樣:1) 不依賴實現次序;2) 允許僅對真正關心的、有價值的業務進行測試;3) 支持不改動業務行為的重構。如此一來,測試的保障效率和開發者體驗都將大幅提升。
于是,我們發現官方提供了這么一個跑測試的工具,剛好可以用來完美滿足我們的需求:runSaga。我們可以用它將 saga 全部執行一遍,搜集所有發布出去的 action,由開發者自由斷言其感興趣的 action!基于這個發現,我們推出了我們的第二版 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() })
這個測試已經簡短了許多,沒有了無關斷言的雜音,依然遵循 given-when-then 的結構。并且同樣是測試「只保存獲取回來的前三個推薦產品」、「對非 VIP 用戶推送廣告」兩個關心的業務點,其中自有簡潔的規律:
當輸入不變時,無論你怎么優化內部實現、調整內部次序,這個測試關心的業務場景都不會掛,真正做到了測試保護重構、支持重構的作用
可以僅斷言你關心的點,忽略不重要或不關心的中間過程(比如上例中,我們就沒有斷言其他 notImportant 的 action 是否被 dispatch 出去),消除無關斷言的雜音,提升了表達力
使用了 product 這樣的測試數據創建套件(fixtures),精簡測試數據,消除無關數據的雜音,提升了表達力
自定義的 expect(action).toHaveBeenDispatchedWith(payload) matcher 很有表達力,且出錯信息友好
這個自定義的 matcher 是通過 jest 的 expect.extend 擴展實現的:
expect.extend({ toHaveBeenDispatched(action) { ... }, toHaveBeenDispatchedWith(action, payload) { ... }, })
上面是我們認為比較好的副作用測試工具、測試策略和測試方案。使用時,需要牢記你真正關心的業務價值點(本節開始提到的 5 點),以及做到在較為復雜的單元測試中始終堅守三大基本原則。唯如此,單元測試才能真正提升開發速度、支持重構、充當業務上下文的文檔。
component 測試組件測試其實是實踐最多,測試實踐看法和分歧也最多的地方。React 組件是一個高度自治的單元,從分類上來看,它大概有這么幾類:
展示型業務組件
容器型業務組件
通用 UI 組件
功能型組件
先把這個分類放在這里,待會回過頭來談。對于 React 組件測什么不測什么,我有一些思考,也有一些判斷標準:除去功能型組件,其他類型的組件一般是以渲染出一個語法樹為終點的,它描述了頁面的 UI 內容、結構、樣式和一些邏輯 component(props) => UI。內容、結構和樣式,比起測試,直接在頁面上調試反饋效果更好。測也不是不行,但都難免有不穩定的成本在;邏輯這塊,還是有一測的價值,但需要控制好依賴。綜合「好的單元測試標準」作為原則進行考慮,我的建議是:兩測兩不測。
組件分支渲染邏輯必須測
事件調用和參數傳遞一般要測
純 UI 不在單元測試層級測
連接 redux 的高階組件不測
其他的一般不測(比如 CSS,官方文檔有反例)
組件的分支邏輯,往往也是有業務含義和業務價值的分支,添加單元測試既能保障重構,還可順便做文檔用;事件調用同樣也有業務價值和文檔作用,而事件調用的參數調用有時可起到保護重構的作用。
純 UI 不在單元測試級別測試的原因,純粹就是因為不好斷言。所謂快照測試有意義的前提在于兩個:必須是視覺級別的比對、必須開發者每次都認真檢查。jest 有個 snapshot 測試的概念,但那個 UI 測試是代碼級的比對,不是視覺級的比對,最終還是繞了一圈,去除了雜音還不如看 Git 的 commit diff。每次要求開發者自覺檢查,既打亂工作流,也難以堅持。考慮到這些成本,我不推薦在單元測試的級別來做 UI 類型的測試。對于我們之前中等規模的項目,訴諸手工還是有一定的可控性。
連接 redux 的高階組件不測。原因是,connect 過的組件從測試的角度看無非幾個測試點:
mapStateToProps 中是否從 store 中取得了正確的參數
mapDispatchToProps 中是否地從 actions 中取得了正確的參數
map 過的 props 是否正確地被傳遞給了組件
redux 對應的數據切片更新時,是否會使用新的 props 觸發組件進行一次更新
這四個點,react-redux 已經都幫你測過了,已經證明 work 了,為啥要重復測試自尋煩惱呢?當然,不測這個東西的話,還是有這么一種可能,就是你 export 的純組件測試都是過的,但是代碼實際運行出錯。窮盡下來主要可能是這幾種問題:
你在 mapStateToProps 中打錯了字或打錯了變量名
你寫了 mapStateToProps 但沒有 connect 上去
你在 mapStateToProps 中取的路徑是錯的,在 redux 中已經被改過
第一、二種可能,無視。測試不是萬能藥,不能預防人主動犯錯,這種場景如果是小步提交發現起來是很快的,如果不小步提交那什么測試都幫不了你的;如果某段數據獲取的邏輯多處重復,則可以考慮將該邏輯抽取到 selector 中并進行多帶帶測試。
第三種可能,確實是問題,但發生頻率目前看來較低。為啥呢,因為沒有類型系統我們不會也不敢隨意改 redux 的數據結構啊…(這侵入性重的框架喲)所以針對這些少量出現的場景,不必要采取錯殺一千的方式進行完全覆蓋。默認不測,出了問題或者經常可能出問題的部分,再策略性地補上測試進行固定即可。
綜上,@connect 組件不測,因為框架本身已做了大部分測試,剩下的場景出 bug 頻率不高,而施加測試的話提高成本(準備依賴和數據),降低開發體驗,模糊測試場景,性價比不大,所以強烈建議省了這份心。不測 @connect 過的組件,其實也是 官方文檔 推薦的做法。
然后,基于上面第 1、2 個結論,映射回四類組件的結構當中去,我們可以得到下面的表格,然后發現…每種組件都要測渲染分支和事件調用,跟組件類型根本沒必然的關聯…不過,功能型組件有可能會涉及一些其他的模式,因此又大致分出一小節來談。
組件類型 / 測試內容 | 分支渲染邏輯 | 事件調用 | @connect | 純 UI |
---|---|---|---|---|
展示型組件 | ? | ? | - | ?? |
容器型組件 | ? | ? | ?? | ?? |
通用 UI 組件 | ? | ? | - | ?? |
功能型組件 | ? | ? | ?? | ?? |
export const CommentsSection = ({ comments }) => ({comments.length > 0 && ()Comments
)} {comments.map((comment) => ()}
對應的測試如下,測試的是不同的分支渲染邏輯:沒有評論時,則不渲染 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(業務型組件 - 事件調用) 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( ) const header = component.find("h2") const comments = component.find(Comment) expect(header.html()).toBe("Comments") expect(comments).toHaveLength(2) })
測試事件的一個場景如下:當某條產品被點擊時,應該將產品相關的信息發送給埋點系統進行埋點。
export const ProductItem = ({ id, productName, introduction, trackPressEvent, }) => (trackPressEvent(id, productName)}> )
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() component.find(TouchableWithoutFeedback).simulate("press") expect(trackPressEvent).toHaveBeenCalledWith( 100832, "iMac Pro - Power to the pro." ) })
簡單得很吧。這里的幾個測試,在你改動了樣式相關的東西時,不會掛掉;但是如果你改動了分支邏輯或函數調用的內容時,它就會掛掉了。而分支邏輯或函數調用,恰好是我覺得接近業務的地方,所以它們對保護代碼邏輯、保護重構是有價值的。當然,它們多少還是依賴了組件內部的實現細節,比如說 find(TouchableWithoutFeedback),還是做了「組件內部使用了 TouchableWithoutFeedback 組件」這樣的假設,而這個假設很可能是會變的。也就是說,如果我換了一個組件來接受點擊事件,盡管點擊時的行為依然發生,但這個測試仍然會掛掉。這就違反了我們所說了「有且僅有一個使測試失敗的理由」。這對于組件測試來說,是不夠完美的地方。
但這個問題無法避免。因為組件本質是渲染組件樹,那么測試中要與組件樹關聯,必然要通過 組件名、id 這樣的 selector,這些 selector 的關聯本身就是使測試掛掉的「另一個理由」。但對組件的分支、事件進行測試又有一定的價值,無法避免。所以,我認為這個部分還是要用,只不過同時需要一些限制,以控制這些假設為維護測試帶來的額外成本:
不要斷言組件內部結構。像那些 expect(component.find("div > div > p").html().toBe("Content") 的真的就算了吧
正確拆分組件樹。一個組件盡量只負責一個功能,不允許堆疊太多的函數和功能。要符合單一職責原則
如果你的每個組件都十分清晰直觀、邏輯分明,那么像上面這樣的組件測起來也就很輕松,一般就遵循 shallow -> find(Component) -> 斷言的三段式,哪怕是了解了一些組件的內部細節,通常也在可控的范圍內,維護起來成本并不高。這是目前我覺得平衡了表達力、重構意義和測試成本的實踐。
功能型組件 - children 型高階組件功能型組件,指的是跟業務無關的另一類組件:它是功能型的,更像是底層支撐著業務組件運作的基礎組件,比如路由組件、分頁組件等。這些組件一般偏重邏輯多一點,關心 UI 少一些。其本質測法跟業務組件是一致的:不關心 UI 具體渲染,只測分支渲染和事件調用。但由于它偏功能型的特性,使得它在設計上常會出現一些業務型組件不常出現的設計模式,如高階組件、以函數為子組件等。下面分別針對這幾種進行分述。
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 = () =>utils 測試test("should not render children component when remote toggle is empty", () => { const component = shallow( ) expect(component.find(DummyComponent)).toHaveLength(0) }) test("should render children component when remote toggle is present and stated on", () => { const features = { promotion618: FeatureToggles.on, } const component = shallow( ) expect(component.find(DummyComponent)).toHaveLength(1) }) test("should not render children component when remote toggle object is present but stated off", () => { const features = { promotion618: FeatureToggles.off, } const component = shallow( ) expect(component.find(DummyComponent)).toHaveLength(0) })
每個項目都會有 utils。一般來說,我們期望 util 都是純函數,即是不依賴外部狀態、不改變參數值、不維護內部狀態的函數。這樣的函數測試效率也非常高。測試原則跟前面所說的也并沒什么不同,不再贅述。不過值得一提的是,因為 util 函數多是數據驅動,一個輸入對應一個輸出,并且不需要準備任何依賴,這使得它非常適合采用參數化測試的方法。這種測試方法,可以提升數據準備效率,同時依然能保持詳細的用例信息、錯誤提示等優點。jest 從 23 后就內置了對參數化測試的支持了,如下:
test.each([ [["0", "99"], 0.99, "(整數部分為0時也應返回)"], [["5", "00"], 5, "(小數部分不足時應該補0)"], [["5", "10"], 5.1, "(小數部分不足時應該補0)"], [["4", "38"], 4.38, "(小數部分不足時應該補0)"], [["4", "99"], 4.994, "(超過默認2位的小數的直接截斷,不四舍五入)"], [["4", "99"], 4.995, "(超過默認2位的小數的直接截斷,不四舍五入)"], [["4", "99"], 4.996, "(超過默認2位的小數的直接截斷,不四舍五入)"], [["-0", "50"], -0.5, "(整數部分為負數時應該保留負號)"], ])( "should return %s when number is %s (%s)", (expected, input, description) => { expect(truncateAndPadTrailingZeros(input)).toEqual(expected) } )總結
好,到此為止,本文的主要內容也就講完了。總結下來,本文主要覆蓋到的內容如下:
單元測試對于任何 React 項目(及其他任何項目)來說都是必須的
我們需要自動化的測試套件,根本目標是為了提升企業和團隊的 IT「響應力」
之所以優先選擇單元測試,是依據測試金字塔的成本收益比原則確定得到的
好的單元測試具備三大特征:有且僅有一個失敗的理由、表達力極強、快、穩定
單元測試也有測試策略:在 React 的典型架構下,一個測試體系大概分為六層:組件、action、reducer、selector、副作用層、utils。它們分別的測試策略為:
reducer、selector 的重邏輯代碼要求 100% 覆蓋
utils 層的純函數要求 100% 覆蓋
副作用層主要測試:是否拿到了正確的參數、是否調用了正確的 API、是否保存了正確的數據、業務邏輯、異常邏輯 五個層面
組件層兩測兩不測:分支渲染邏輯必測、事件、交互調用必測;純 UI(包括 CSS)不測、@connect 過的高階組件不測
action 層選擇性覆蓋:可不測
其他高級技巧:定制測試工具(jest.extend)、參數化測試等
未盡話題 & 歡迎討論講完 React 下的單元測試尚且已經這么花費篇幅,文章中難免還有些我十分想提又意猶未盡的地方。比如完整的測試策略、比如 TDD、比如重構、比如整潔代碼設計模式等。如果讀者有由此文章而生發、而疑慮、而不吐不快的種種興趣和分享,都十分歡迎留下你的想法和指點。寫文交流,樂趣如此。感謝。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/98783.html
摘要:單元測試針對程序模塊進行測試。是開源的單元測試工具。一個好的單元測試應該具備的條件安全重構已有代碼單元測試一個很重要的價值是為重構保駕護航。斷言外部依賴單元測試的一個重要原則就是無依賴和隔離。 前端測試金字塔 對于一個 Web 應用來說,理想的測試組合應該包含大量單元測試(unit tests),部分快照測試(snapshot tests),以及少量端到端測試(e2e tests)。參...
摘要:以下兩個要點將會對任何微服務重構策略產生重大影響。批量替換通過批發更換,您可以一次性重構整個應用程序,直接從單體式轉移到一組微服務器。如果您通過使用破解您的微服務器,那么每個域將圍繞一個用例,或者更常見的,一組相互關聯的用例。 在決定使用微服務之后,為了將微服務付諸實踐,也許你已經開始重構你的應用程序或把重構工作列入了待辦事項清單。 無論是哪種情況,如果這是你第一次重構應用程序,那么您...
摘要:前端每周清單第期現狀分析與優化策略單元測試爬蟲作者王下邀月熊編輯徐川前端每周清單專注前端領域內容,以對外文資料的搜集為主,幫助開發者了解一周前端熱點分為新聞熱點開發教程工程實踐深度閱讀開源項目巔峰人生等欄目。 showImg(https://segmentfault.com/img/remote/1460000011008022); 前端每周清單第 29 期:Web 現狀分析與優化策略...
摘要:感受構建工具給前端優化工作帶來的便利。多多益處邏輯清晰,程序注重數據與表現分離,可讀性強,利于規避和排查問題構建工具層出不窮。其實工具都能滿足需求,關鍵是看怎么用,工具的使用背后是對前端性能優化的理解程度。 這篇主要介紹一下我在玩Webpack過程中的心得。通過實例介紹WebPack的安裝,插件使用及加載策略。感受構建工具給前端優化工作帶來的便利。 showImg(https://se...
摘要:月日,首期沙龍海量運維實踐大曝光在騰訊大廈圓滿舉行。織云高效的實踐是,它是以運維標準化為基石,以為核心的自動化運維平臺。 作者丨周小軍,騰訊SNG資深運維工程師,負責社交產品分布式存儲的運維及團隊管理工作。對互聯網網站架構、數據中心、云計算及自動化運維等領域有深入研究和理解。 12月16日,首期沙龍海量運維實踐大曝光在騰訊大廈圓滿舉行。沙龍出品人騰訊運維技術總監、復旦大學客座講師、De...
閱讀 3414·2021-11-24 09:38
閱讀 3193·2021-11-22 09:34
閱讀 2106·2021-09-22 16:03
閱讀 2363·2019-08-29 18:37
閱讀 376·2019-08-29 16:15
閱讀 1767·2019-08-26 13:56
閱讀 862·2019-08-26 12:21
閱讀 2204·2019-08-26 12:15