寫好的單元測試,對開發速度、項目維護有莫大的幫助。前端的測試工具一直推陳出新,而測試的核心、原則卻少有變化。與產品代碼一併交付可靠的測試代碼,是每一個專業開發者應該不斷靠近的一個理想之地。本文就圍繞測試講講,爲何咱們要作測試,什麼是好的測試和原則,以及如何在一個 React 項目中落地這些測試策略。javascript
本文使用的測試框架、斷言工具是 jest。文章不打算對測試框架、語法自己作過多介紹,由於已有不少文章。本文假定讀者已有必定基礎,至少熟悉語法,但並不假設讀者寫過單元測試。在介紹什麼是好的單元測試時,我會簡單介紹一個好的單元測試的結構。Github 討論:https://github.com/linesh-sim...css
原文地址:https://blog.linesh.tw/#/post...html
爲何要作單元測試前端
如何寫好單元測試:好測試的特徵java
React 單元測試策略及落地react
saga 測試ios
component 測試git
children
型高階組件雖然關於測試的文章有不少,關於 React 的文章也有不少,但關於 React 應用之詳細單元測試的文章還比較少。並且更多的文章都更偏向於對工具自己進行講解,只講「咱們能夠這麼測」,卻沒有回答「咱們爲何要這麼測」、「這麼測究竟好很差」的問題。這幾個問題上的空白,不免令人得出測試無用、測試成本高、測試使開發變慢的錯誤觀點,致使在「質量內建」已漸入人心的今日,不少人仍然認爲測試是二等公民,是成本,是錦上添花。這一點上,個人態度一向鮮明:不只要寫測試,還要把單元測試寫好;不只要有測試前移質量內建的意識,還要有基於測試進行快速反饋快速開發的能力。沒自動化測試的代碼不叫完成,不能驗收。程序員
「爲何咱們須要作單元測試」,這是一個關鍵的問題。每一個人都有本身關於該不應作測試、該怎麼作、作到什麼程度的見解,試圖面面俱到、左右逢源地評價這些見解是不可能的。咱們須要一個視角,一個談論單元測試的上下文。作單元測試固然有好處,但本文不會從有什麼好處出發來談,而是談,在咱們在乎的這個上下文中,不作單元測試會有什麼問題。github
那麼咱們談論單元測試的上下文是什麼呢?不作單元測試咱們會遇到什麼問題呢?
先說說問題。最大的一個問題是,不寫單元測試,你就不敢重構,就只能看着代碼腐化。代碼質量談不上,持續改進談不上,我的成長更談不上。始終是原始的勞做方式。
再說說上下文。我認爲單元測試的上下文存在於「敏捷」中。現代企業數字化競爭日益激烈,業務端快速上線、快速驗證、快速失敗的思路對技術端的響應力提出了更高的要求:更快上線、更頻繁上線、持續上線。怎麼樣衡量這個「更快」呢?那就是第一圖提到的 lead time,它度量的是一個 idea 從提出並被驗證,到最終上生產環境面對用戶獲取反饋的時間。顯然,這個時間越短,軟件就能越快得到反饋,對價值的驗證就越快發生。這個結論對咱們寫不寫單元測試有什麼影響呢?答案是,不寫單元測試,你就快不起來。爲啥呢?由於每次發佈,你都要投入人力來進行手工測試;由於沒有測試,你傾向於不敢隨意重構,這又致使代碼逐漸腐化,複雜度使得你的開發速度下降。
再考慮到如下兩個大事實:人員會流動,應用會變大。人員必定會流動,需求必定會增長,再也沒有任何人可以瞭解任何一個應用場景。所以,意圖依賴人、依賴手工的方式來應對響應力的挑戰首先是低效的,從時間維度上來說也是不現實的。那麼,爲了服務於「高響應力」這個目標,咱們就須要一套自動化的測試套件,它能幫咱們提供快速反饋、作質量的守衛者。惟解決了人工、質量的這一環,效率才能穩步提高,團隊和企業的高響應力纔可能達到。
那麼在「響應力」這個上下文中來談要不要單元測試,咱們就能夠頗有根據了,而不是開發爽了就用,不爽就不用這樣含糊的答案:
if-else
裸奔也不在話下,腦很差還作什麼程序員,那你能夠不用單元測試除此以外,你就須要寫單元測試。若是你想隨時整理重構代碼,那麼你須要寫單元測試;若是你想有自動化的測試套件來幫你快速驗證提交的完整性,那麼你須要寫單元測試;若是你是個長期項目有人員流動,那麼你須要寫單元測試;若是你不想花大量的時間在記住業務場景和手動測試應用上,那麼你就須要單元測試。
至此,咱們從「響應力」這個上下文中,回答了「爲何咱們須要寫單元測試」的問題。接下來能夠談下一個問題了:「爲何是單元測試」。
上面我直接從高響應力談到單元測試,可能有的同窗會問,高響應力這個事情我承認,也承認快速開發的同時,質量也很重要。可是,爲了達到「保障質量」的目的,不必定得經過測試呀,也不必定得經過單元測試鴨。
這是個好的問題。爲了達到保障質量這個目標,測試固然只是其中一個方式,穩定的自動化部署、集成流水線、良好的代碼架構、組織架構的必要調整等,都是必須跟上的設施。我從未認爲單元測試是解決質量問題的銀彈,多方共同提高才可能起到效果。但相反,也很難想象單元測試都沒有都寫很差的項目,能有多高的響應力。
即使咱們談自動化測試,未必也不可能所有都是寫單元測試。咱們對自動化測試套件寄予的厚望是,它能幫咱們安全重構已有代碼、保存業務上下文、快速回歸。測試種類多種多樣,爲何我要重點談單元測試呢?由於這篇文章主題就是談單元測試啊…它寫起來相對最容易、運行速度最快、反饋效果又最直接。下面這個圖,想必你們都有所耳聞:
這就是有名的測試金字塔。對於一個自動化測試套件,應該包含種類不一樣、關注點不一樣的測試,好比關注單元的單元測試、關注集成和契約的集成測試和契約測試、關注業務驗收點的端到端測試等。正常來講,咱們會受到資源的限制,沒法應用全部層級的測試,效果也未必最佳。所以,咱們須要有策略性地根據收益-成本的原則,考慮項目的實際狀況和痛點來定製測試策略:好比三方依賴多的項目能夠多寫些契約測試,業務場景多、複雜或常常回歸的場景能夠多寫些端到端測試,等。但不論如何,整個測試金字塔體系中,你仍是應該擁有更多低層次的單元測試,由於它們成本相對最低,運行速度最快(一般是毫秒級別),而對單元的保護價值相對更大。
以上是對「爲何咱們須要的是單元測試」這個問題的回答。接下來一小節,就能夠正式進入如何作的環節了:「如何寫好單元測試」。
關於測試金字塔的補充閱讀:測試金字塔實戰。
寫單元測試僅僅是第一步,下面還有個更關鍵的問題,就是怎樣寫出好的、容易維護的單元測試。好的測試有其特徵,雖然它並非什麼新的東西,但總須要時時拿出來溫故知新。不少時候,同窗感受測試難寫、難維護、不穩定、價值不大等,可能都是由於單元測試寫很差所致使的。那麼咱們就來看看,一個好的單元測試,應該遵循哪幾點原則。
首先,咱們先來看個簡單的例子,一個最簡單的 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(-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、 <Provider />
,在大型或遺留系統中補測試還極可能遇到 @connect
組件裏套 @connect
組件的場景);而後收益也只是可能覆蓋到了幾個極少數出現的場景。得不償失,果斷不測。
關於 UI 測試這塊的策略。團隊以前嘗試過 snapshot 測試,對它寄予厚望,理由是成本低,看起來又像萬能藥。不過因爲其難以提供精確快照比對,整個工做的基礎又依賴於開發者盡心作好「確認比對」這個事情,很依賴人工耐心又打斷平常的開發節奏,致使成本和收益不成正比。我我的目前是持保留態度的。
關於 DOM 測試這塊的策略。也就是經過 enzyme 這類工具,經過 css selector 來進行 DOM 渲染方面的測試。這類測試因爲天生須要經過 css selector 去關聯 DOM 元素,除了被測業務外 css selector 自己就是掛測試的一個因素。一個 DOM 測試至少有兩個緣由可以使它掛掉,並不符合咱們上面提到的最佳實踐。但這種測試有時又確實有用,後文講組件測試時會專門提到,如何針對它制定適合的策略。
這一層太過簡單,基本均可以不用測試,獲益於架構的簡單性。固然,若是有些常常出錯的 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 作測試的例子:
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 中作重邏輯處理,此處作單元測試保護的價值也很大。請留意,上面所說的單元測試,是否是符合咱們描述的單元測試基本原則:
saveUserComments
時,應該與已有留言合併並去除重複的部分」;此外,測試數據只准備了足夠體現「合併」這個操做的兩條 id 的數據,而沒有放不少的數據,造成雜音;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 是負責調用 API、處理反作用的一層。在實際的項目上反作用還有其餘的中間層進行處理,好比 redux-thunk、redux-promise 等,本質是同樣的,只不過 saga 在測試性上要好一些。這一層反作用怎麼測試呢?首先爲了保證單元測試的速度和穩定性,像 API 調用這種不肯定性的依賴咱們必定是要 mock 掉的。通過仔細總結,我認爲這一層主要的測試內容有五點:
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()) ) })
這個方案寫多了,你們開始感覺到了痛點,明顯違背咱們前面提到的一些原則:
針對以上痛點,咱們理想中的 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 點),以及作到在較爲複雜的單元測試中始終堅守三大基本原則。惟如此,單元測試才能真正提高開發速度、支持重構、充當業務上下文的文檔。
組件測試實際上是實踐最多,測試實踐見解和分歧也最多的地方。React 組件是一個高度自治的單元,從分類上來看,它大概有這麼幾類:
先把這個分類放在這裏,待會回過頭來談。對於 React 組件測什麼不測什麼,我有一些思考,也有一些判斷標準:除去功能型組件,其餘類型的組件通常是以渲染出一個語法樹爲終點的,它描述了頁面的 UI 內容、結構、樣式和一些邏輯 component(props) => UI
。內容、結構和樣式,比起測試,直接在頁面上調試反饋效果更好。測也不是不行,但都不免有不穩定的成本在;邏輯這塊,仍是有一測的價值,但須要控制好依賴。綜合「好的單元測試標準」做爲原則進行考慮,個人建議是:兩測兩不測。
組件的分支邏輯,每每也是有業務含義和業務價值的分支,添加單元測試既能保障重構,還可順便作文檔用;事件調用一樣也有業務價值和文檔做用,而事件調用的參數調用有時可起到保護重構的做用。
純 UI 不在單元測試級別測試的緣由,純粹就是由於很差斷言。所謂快照測試有意義的前提在於兩個:必須是視覺級別的比對、必須開發者每次都認真檢查。jest 有個 snapshot 測試的概念,但那個 UI 測試是代碼級的比對,不是視覺級的比對,最終仍是繞了一圈,去除了雜音還不如看 Git 的 commit diff。每次要求開發者自覺檢查,既打亂工做流,也難以堅持。考慮到這些成本,我不推薦在單元測試的級別來作 UI 類型的測試。對於咱們以前中等規模的項目,訴諸手工仍是有必定的可控性。
鏈接 redux 的高階組件不測。緣由是,connect
過的組件從測試的角度看無非幾個測試點:
mapStateToProps
中是否從 store
中取得了正確的參數mapDispatchToProps
中是否地從 actions
中取得了正確的參數props
是否正確地被傳遞給了組件props
觸發組件進行一次更新這四個點,react-redux
已經都幫你測過了,已經證實 work 了,爲啥要重複測試自尋煩惱呢?固然,不測這個東西的話,仍是有這麼一種可能,就是你 export 的純組件測試都是過的,可是代碼實際運行出錯。窮盡下來主要多是這幾種問題:
mapStateToProps
中打錯了字或打錯了變量名mapStateToProps
但沒有 connect 上去mapStateToProps
中取的路徑是錯的,在 redux 中已經被改過第1、二種可能,無視。測試不是萬能藥,不能預防人主動犯錯,這種場景若是是小步提交發現起來是很快的,若是不小步提交那什麼測試都幫不了你的;若是某段數據獲取的邏輯多處重複,則能夠考慮將該邏輯抽取到 selector 中並進行單獨測試。
第三種可能,確實是問題,但發生頻率目前看來較低。爲啥呢,由於沒有類型系統咱們不會也不敢隨意改 redux 的數據結構啊…(這侵入性重的框架喲)因此針對這些少許出現的場景,沒必要要採起錯殺一千的方式進行徹底覆蓋。默認不測,出了問題或者常常可能出問題的部分,再策略性地補上測試進行固定便可。
綜上,@connect
組件不測,由於框架自己已作了大部分測試,剩下的場景出 bug 頻率不高,而施加測試的話提升成本(準備依賴和數據),下降開發體驗,模糊測試場景,性價比不大,因此強烈建議省了這份心。不測 @connect
過的組件,其實也是 官方文檔 推薦的作法。
而後,基於上面第 一、2 個結論,映射回四類組件的結構當中去,咱們能夠獲得下面的表格,而後發現…每種組件都要測渲染分支和事件調用,跟組件類型根本沒必然的關聯…不過,功能型組件有可能會涉及一些其餘的模式,所以又大體分出一小節來談。
組件類型 / 測試內容 | 分支渲染邏輯 | 事件調用 | @connect |
純 UI |
---|---|---|---|---|
展現型組件 | ✅ | ✅ | - | ✖️ |
容器型組件 | ✅ | ✅ | ✖️ | ✖️ |
通用 UI 組件 | ✅ | ✅ | - | ✖️ |
功能型組件 | ✅ | ✅ | ✖️ | ✖️ |
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) })
測試事件的一個場景以下:當某條產品被點擊時,應該將產品相關的信息發送給埋點系統進行埋點。
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} /> ) 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 = () => <View /> test('should not render children component when remote toggle is empty', () => { 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 stated 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 object is present but stated off', () => { const features = { promotion618: FeatureToggles.off, } const component = shallow( <FeatureToggle features={features} featureName="promotion618"> <DummyComponent /> </FeatureToggle> ) 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 的典型架構下,一個測試體系大概分爲六層:組件、action、reducer、selector、反作用層、utils。它們分別的測試策略爲:
@connect
過的高階組件不測jest.extend
)、參數化測試等講完 React 下的單元測試尚且已經這麼花費篇幅,文章中不免還有些我十分想提又意猶未盡的地方。好比完整的測試策略、好比 TDD、好比重構、好比整潔代碼設計模式等。若是讀者有由此文章而生髮、而疑慮、而不吐不快的種種興趣和分享,都十分歡迎留下你的想法和指點。寫文交流,樂趣如此。感謝。