React 單元測試策略及落地

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

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

Github 討論:https://github.com/linesh-sim...css

原文地址:https://blog.linesh.tw/#/post...html

目錄

  1. 爲何要作單元測試前端

    1. 單元測試的上下文
    2. 測試策略:測試金字塔
    3. 如何寫好單元測試:好測試的特徵java

      • 有且僅有一個失敗的理由
      • 表達力極強
      • 快、穩定
  2. React 單元測試策略及落地react

    1. React 應用的單元測試策略
    2. actions 測試
    3. reducer 測試
    4. selector 測試
    5. saga 測試ios

      • 來自官方的錯誤姿式
      • 正確姿式
    6. component 測試git

      • 業務型組件 - 分支渲染
      • 業務型組件 - 事件調用
      • 功能型組件 - children 型高階組件
    7. utils 測試
  3. 總結
  4. 未盡話題 & 歡迎討論

爲何要作單元測試

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

「爲何咱們須要作單元測試」,這是一個關鍵的問題。每一個人都有本身關於該不應作測試、該怎麼作、作到什麼程度的見解,試圖面面俱到、左右逢源地評價這些見解是不可能的。咱們須要一個視角,一個談論單元測試的上下文。作單元測試固然有好處,但本文不會從有什麼好處出發來談,而是談,在咱們在乎的這個上下文中,不作單元測試會有什麼問題。github

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

單元測試的上下文

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

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 中已經被改過

第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 測試

每一個項目都會有 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、好比重構、好比整潔代碼設計模式等。若是讀者有由此文章而生髮、而疑慮、而不吐不快的種種興趣和分享,都十分歡迎留下你的想法和指點。寫文交流,樂趣如此。感謝。

相關文章
相關標籤/搜索