React 單元測試策略及落地 #一篇就夠系列

Linesh發表於2018-10-28

寫好的單元測試,對開發速度、專案維護有莫大的幫助。前端的測試工具一直推陳出新,而測試的核心、原則卻少有變化。與產品程式碼一併交付可靠的測試程式碼,是每個專業開發者應該不斷靠近的一個理想之地。本文就圍繞測試講講,為什麼我們要做測試,什麼是好的測試和原則,以及如何在一個 React 專案中落地這些測試策略。

本文使用的測試框架、斷言工具是 jest。文章不打算對測試框架、語法本身做過多介紹,因為已有很多文章。本文假定讀者已有一定基礎,至少熟悉語法,但並不假設讀者寫過單元測試。在介紹什麼是好的單元測試時,我會簡單介紹一個好的單元測試的結構。

Github 討論:github.com/linesh-simp…

原文地址:blog.linesh.tw/#/post/2018…

目錄

  1. 為什麼要做單元測試
    1. 單元測試的上下文
    2. 測試策略:測試金字塔
    3. 如何寫好單元測試:好測試的特徵
      • 有且僅有一個失敗的理由
      • 表達力極強
      • 快、穩定
  2. React 單元測試策略及落地
    1. React 應用的單元測試策略
    2. actions 測試
    3. reducer 測試
    4. selector 測試
    5. saga 測試
      • 來自官方的錯誤姿勢
      • 正確姿勢
    6. component 測試
      • 業務型元件 - 分支渲染
      • 業務型元件 - 事件呼叫
      • 功能型元件 - children 型高階元件
    7. utils 測試
  3. 總結
  4. 未盡話題 & 歡迎討論

為什麼要做單元測試

雖然關於測試的文章有很多,關於 React 的文章也有很多,但關於 React 應用之詳細單元測試的文章還比較少。而且更多的文章都更偏向於對工具本身進行講解,只講「我們可以這麼測」,卻沒有回答「我們為什麼要這麼測」、「這麼測究竟好不好」的問題。這幾個問題上的空白,難免使人得出測試無用、測試成本高、測試使開發變慢的錯誤觀點,導致在「質量內建」已漸入人心的今日,很多人仍然認為測試是二等公民,是成本,是錦上添花。這一點上,我的態度一貫鮮明:不僅要寫測試,還要把單元測試寫好;不僅要有測試前移質量內建的意識,還要有基於測試進行快速反饋快速開發的能力。沒自動化測試的程式碼不叫完成,不能驗收。

「為什麼我們需要做單元測試」,這是一個關鍵的問題。每個人都有自己關於該不該做測試、該怎麼做、做到什麼程度的看法,試圖面面俱到、左右逢源地評價這些看法是不可能的。我們需要一個視角,一個談論單元測試的上下文。做單元測試當然有好處,但本文不會從有什麼好處出發來談,而是談,在我們在意的這個上下文中,不做單元測試會有什麼問題。

那麼我們談論單元測試的上下文是什麼呢?不做單元測試我們會遇到什麼問題呢?

單元測試的上下文

先說說問題。最大的一個問題是,不寫單元測試,你就不敢重構,就只能看著程式碼腐化。程式碼質量談不上,持續改進談不上,個人成長更談不上。始終是原始的勞作方式。

image

image

再說說上下文。我認為單元測試的上下文存在於「敏捷」中現代企業數字化競爭日益激烈,業務端快速上線、快速驗證、快速失敗的思路對技術端的響應力提出了更高的要求:更快上線更頻繁上線持續上線。怎麼樣衡量這個「更快」呢?那就是第一圖提到的 lead time,它度量的是一個 idea 從提出並被驗證,到最終上生產環境面對使用者獲取反饋的時間。顯然,這個時間越短,軟體就能越快獲得反饋,對價值的驗證就越快發生。這個結論對我們寫不寫單元測試有什麼影響呢?答案是,不寫單元測試,你就快不起來。為啥呢?因為每次釋出,你都要投入人力來進行手工測試;因為沒有測試,你傾向於不敢隨意重構,這又導致程式碼逐漸腐化,複雜度使得你的開發速度降低。

再考慮到以下兩個大事實:人員會流動,應用會變大。人員一定會流動,需求一定會增加,再也沒有任何人能夠了解任何一個應用場景。因此,意圖依賴人、依賴手工的方式來應對響應力的挑戰首先是低效的,從時間維度上來講也是不現實的。那麼,為了服務於「高響應力」這個目標,我們就需要一套自動化的測試套件,它能幫我們提供快速反饋、做質量的守衛者。唯解決了人工、質量的這一環,效率才能穩步提升,團隊和企業的高響應力才可能達到。

那麼在「響應力」這個上下文中來談要不要單元測試,我們就可以很有根據了,而不是開發爽了就用,不爽就不用這樣含糊的答案:

  • 如果你說我的業務部門不需要頻繁上線,並且我有足夠的人力來覆蓋手工測試,那你可以不用單元測試
  • 如果你說我是個小專案小部門不需要多高的響應力,每天摸摸魚就過去了,那你可以不用單元測試
  • 如果你說我不在意程式碼腐化,並且我也不做重構,那你可以不用單元測試
  • 如果你說我不在意程式碼質量,好幾個沒有測試保護的 if-else 裸奔也不在話下,腦不好還做什麼程式設計師,那你可以不用單元測試
  • 如果你說我確有快速部署的需求,但我們不 care 質量問題,出迴歸問題就修,那你可以不用單元測試

除此之外,你就需要寫單元測試。如果你想隨時整理重構程式碼,那麼你需要寫單元測試;如果你想有自動化的測試套件來幫你快速驗證提交的完整性,那麼你需要寫單元測試;如果你是個長期專案有人員流動,那麼你需要寫單元測試;如果你不想花大量的時間在記住業務場景和手動測試應用上,那麼你就需要單元測試。

至此,我們從「響應力」這個上下文中,回答了「為什麼我們需要寫單元測試」的問題。接下來可以談下一個問題了:「為什麼是單元測試」。

測試策略:測試金字塔

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

這是個好的問題。為了達到保障質量這個目標,測試當然只是其中一個方式,穩定的自動化部署、整合流水線、良好的程式碼架構、組織架構的必要調整等,都是必須跟上的設施。我從未認為單元測試是解決質量問題的銀彈,多方共同提升才可能起到效果。但相反,也很難想象單元測試都沒有都寫不好的專案,能有多高的響應力。

即便我們談自動化測試,未必也不可能全部都是寫單元測試。我們對自動化測試套件寄予的厚望是,它能幫我們安全重構已有程式碼儲存業務上下文快速回歸。測試種類多種多樣,為什麼我要重點談單元測試呢?因為~~這篇文章主題就是談單元測試啊…~~它寫起來相對最容易、執行速度最快、反饋效果又最直接。下面這個圖,想必大家都有所耳聞:

image

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

以上是對「為什麼我們需要的是單元測試」這個問題的回答。接下來一小節,就可以正式進入如何做的環節了:「如何寫好單元測試」。

關於測試金字塔的補充閱讀:測試金字塔實戰

如何寫好單元測試:好測試的特徵

寫單元測試僅僅是第一步,下面還有個更關鍵的問題,就是怎樣寫出好的、容易維護的單元測試。好的測試有其特徵,雖然它並不是什麼新的東西,但總需要時時拿出來溫故知新。很多時候,同學感覺測試難寫、難維護、不穩定、價值不大等,可能都是因為單元測試寫不好所導致的。那麼我們就來看看,一個好的單元測試,應該遵循哪幾點原則。

首先,我們先來看個簡單的例子,一個最簡單的 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 單元測試策略及落地

image

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、 <Provider />,在大型或遺留系統中補測試還很可能遇到 @connect 元件裡套 @connect 元件的場景);然後收益也只是可能覆蓋到了幾個極少數出現的場景。得不償失,果斷不測。

關於 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())
  )
})
複製程式碼

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

  1. 測試分明就是把實現抄了一遍。這違反上述所說「有且僅有一個掛測試的理由」的原則,改變實現次序也將會使測試掛掉
  2. 當在實現中某個部分加入新的語句時,該語句後續所有的測試都會掛掉,並且出錯資訊非常難以描述原因,導致常常要陷入「除錯測試」的境地,這也是依賴於實現次序帶來的惡果,根本無法支援「重構」這種改變內部實現但不改變業務行為的程式碼清理行為
  3. 為了測試兩個重要的業務「只儲存獲取回來的前三個推薦產品」、「對非 VIP 使用者推送廣告」,不得不在前面先按次序先斷言許多個不重要的實現
  4. 測試沒有重點,隨便改點什麼都會掛測試

正確姿勢

針對以上痛點,我們理想中的 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 }) => (
  <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 測試

每個專案都會有 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)
  }
)
複製程式碼

image

總結

好,到此為止,本文的主要內容也就講完了。總結下來,本文主要覆蓋到的內容如下:

  • 單元測試對於任何 React 專案(及其他任何專案)來說都是必須的
  • 我們需要自動化的測試套件,根本目標是為了提升企業和團隊的 IT「響應力」
  • 之所以優先選擇單元測試,是依據測試金字塔的成本收益比原則確定得到的
  • 好的單元測試具備三大特徵:有且僅有一個失敗的理由表達力極強快、穩定
  • 單元測試也有測試策略:在 React 的典型架構下,一個測試體系大概分為六層:元件、action、reducer、selector、副作用層、utils。它們分別的測試策略為:
    • reducer、selector 的重邏輯程式碼要求 100% 覆蓋
    • utils 層的純函式要求 100% 覆蓋
    • 副作用層主要測試:是否拿到了正確的引數是否呼叫了正確的 API是否儲存了正確的資料業務邏輯異常邏輯 五個層面
    • 元件層兩測兩不測:分支渲染邏輯必測事件、互動呼叫必測;純 UI(包括 CSS)不測、@connect 過的高階元件不測
    • action 層選擇性覆蓋:可不測
  • 其他高階技巧:定製測試工具(jest.extend)、引數化測試等

未盡話題 & 歡迎討論

講完 React 下的單元測試尚且已經這麼花費篇幅,文章中難免還有些我十分想提又意猶未盡的地方。比如完整的測試策略、比如 TDD、比如重構、比如整潔程式碼設計模式等。如果讀者有由此文章而生髮、而疑慮、而不吐不快的種種興趣和分享,都十分歡迎留下你的想法和指點。寫文交流,樂趣如此。感謝。

相關文章