React Hook測試指南

React爲何須要Hook中咱們探討了React爲何須要引入Hook這個屬性,在React Hook實戰指南中咱們深刻了解了各類Hook的詳細用法以及會遇到的問題,在本篇文章中我將帶你們瞭解一下如何經過爲自定義hook編寫單元測試來提升咱們的代碼質量,它會包含下面的內容:javascript

  • 什麼是單元測試
    • 單元測試的定義
    • 爲何須要編寫單元測試
    • 單元測試須要注意什麼
  • 如何對自定義Hook進行單元測試
    • Jest
    • React-hooks-testing-library
    • 例子

什麼是單元測試

單元測試的定義

要理解單元測試,咱們先來給測試下個定義。用最簡單的話來講測試就是:咱們給被測試對象一些輸入(input),而後看看這個對象的輸出結果(output)是否是符合咱們的預期(match with expected result)。而在軟件工程裏面有不少不一樣類型的測試,例如單元測試(unit test),功能測試(functional test),性能測試(performance test)和集成測試(integration test)等。不一樣種類的測試的主要區別是被測試的對象和評判指標不同。對於單元測試,被測試的對象是咱們源代碼的獨立單元(individual unit),在面向過程編程語言(procedural programming)裏面,單元就是咱們封裝的方法(function),在面向對象的編程語言(object-oriented programming)裏面單元是類(class)的方法(method),咱們通常不推薦將某個類或者某個模塊直接做爲單元測試的單元,由於這會使被測試的邏輯過於龐大,並且問題出現時不容易進行定位。html

爲何須要編寫單元測試

瞭解了單元測試的定義後,咱們再來探討一下爲何咱們要在代碼裏面進行單元測試。java

咱們之因此要在項目中編寫單元測試,主要是由於對代碼進行單元測試有下面這些好處:node

提升代碼質量

單元測試能夠提升咱們的代碼質量主要體如今它能夠在咱們開發某個功能的時候提早幫咱們發現本身編寫的代碼的bug。舉個例子,假如A同窗寫了一個叫作useOptions的hook它接受一個叫作options的參數,這個參數既能夠是一個對象也能夠是一個數組。A同窗本身開發的過程當中他只試過給useOptions傳對象而沒有試過給它傳數組。同一個項目的B同窗在使用useOptions的時候給它傳了個數組發現代碼掛了,這個時候B同窗就得找A同窗確認並等待A同窗修復這個問題,這不但會影響B同窗的開發進度並且還會讓B同窗以爲A同窗不靠譜,或者以爲A同窗的代碼很爛。若是A同窗有對useOptions進行單元測試的話,這個悲劇可能就不會發生了,由於A同窗在爲useOptions編寫單元測試的時候就考慮了options爲數組的狀況,而且在B同窗使用以前就修復了這個問題。所以編寫單元測試可讓咱們在開發的過程當中提早考慮到不少後面使用纔會發現的問題,進而提升咱們的代碼質量。react

方便代碼重構和新功能添加

編寫單元測試的過程實際上是咱們給代碼編寫使用說明書的過程(specification)。這個使用說明書十分重要,它至關於代碼生產者(producer)與代碼消費者(consumer)之間的合約(contract),生產者須要保證在消費者使用代碼沒錯的前提下代碼要有使用說明書上面的效果。這其實會對代碼生產者起到必定的制約做用,由於生產者必須保證不管是給原來的代碼添加新的功能仍是對它進行重構,它都要知足原來使用說明書上的要求。webpack

繼續上面那個例子,A同窗和B同窗都在項目的1.0.0版本中使用了useOptions這個hook,雖然useOptions沒有編寫單元測試,但是代碼是沒有bug的(最起碼沒有被發現)。後面項目須要進行2.0.0版本的升級了,這時候A同窗須要爲useOptions添加新的功能,A同窗在改動了useOptions的代碼後,在本身使用到的地方(對象做爲參數的地方)作了測試,沒有發現bug。在A同窗自測完代碼後,並將這個更改集成(integration)到了項目的master分支上。後面B同窗在更新完A同窗的代碼後,發現本身的代碼出現了一些問題,這個時候B同窗極可能就會手忙腳亂,而且可能須要花費一段時間才能定位到原來是A同窗對useOptions的改動影響到他的功能,這除了會影響到項目的進度外還會讓A同窗和B同窗的關係進一步惡化。這個悲劇一樣也是能夠經過編寫單元測試來避免的,試想一下假如A同窗有給useOptions編寫配套的使用說明書(單元測試),A同窗在改動完代碼後,它的代碼是經過不了使用說明書的檢查的,由於它的改動改變了useOptions以前定義好的外部行爲,這個時候A同窗就會提早修復本身的代碼進而避免了B同窗後面的苦惱。經過這個例子你們可能仍是沒有體會到單元測試對於咱們平時產品迭代或者代碼重構的重要性,但是你試想一下在一個比較大的項目中是有不少個A同窗和B同窗的,也有成千上萬個useOptions函數,當真的發生相似問題的時候bug將會更難被定位和修復,若是咱們大部分的代碼都有單元測試的話,不管是對代碼增長新的功能仍是對原來的代碼進行重構咱們都會更有信心。git

完善咱們代碼的設計

在軟件工程裏面有個概念叫作測試驅動開發(Test-driven Development),它鼓勵咱們在實際開始編碼以前先爲咱們的代碼編寫測試用例。這樣作的目的是讓咱們在開發以前就以代碼使用者的角度去評判咱們的代碼設計。若是咱們的代碼設計很糟糕,咱們就會發現咱們很難爲它們編寫詳盡的單元測試用例,相反若是咱們的代碼設計得很好(低耦合高內聚),各個函數的參數和功能都設計得十分合理,咱們就十分容易就爲它們編寫對應的單元測試。咱們要記住一句話:高質量的代碼必定是能夠被測試的(testable)。那麼爲何是在還沒開始寫代碼以前就編寫測試用例呢?這是由於若是咱們在代碼寫完以後再編寫測試的話,即便咱們發現代碼設計得再不合理,咱們也沒有動力去改了,由於對設計的改動可能會讓咱們重寫全部的代碼,因此咱們須要在實際編碼以前進行單元測試的編寫,由於這個時候的改代碼阻力是最小的。github

提供文檔功能

咱們在爲代碼編寫單元測試的時候其實是在爲代碼編寫一個個使用例子,所以別的開發者在使用咱們代碼的時候能夠經過咱們的單元測試來快速掌握咱們定義的各類函數的用法。另外教你們一個實用的技巧:若是咱們發現某個庫的文檔不是很全面的話,能夠經過查看這個庫的單元測試來快速掌握這個庫的用法。web

單元測試須要注意的問題

隔離性

上面咱們說到單元測試是對代碼獨立的單元進行測試,這個獨立的意思不是說這個函數(單元)不會調用另一個函數(單元),而是說咱們在測試這個函數的時候若是它有調用到其它的函數咱們就須要mock它們,從而將咱們的測試邏輯只放在被測試函數的邏輯上,不會受到其它依賴函數的影響。舉個例子咱們如今要測試如下函數:正則表達式

async function fetchUserDetails(userId) {
  const userDetail = await fetch(`https://myserver.com/users/${userId}`)
  return userDetail
}
複製代碼

在測試fetchUserDetails時咱們就須要mock fetch這個函數了,由於咱們如今測試的函數是fetchUserDetails,咱們只須要肯定在外界調用fetchUserDetails的時候fetch會被調用,而且調用的參數是「https://myserver.com/users/${userId}」就好了,至於fetch函數如何發請求和處理返回來的數據都是fetch函數本身的事,咱們不該該在測試fetchUserDetails的時候關心這個問題。

單元測試要注意隔離性的另一個緣由是它能夠保證當測試案例失敗的時候咱們能夠十分容易定位到問題的所在。以上面的代碼爲例,若是咱們沒有mock fetch函數,一旦咱們的測試失敗,咱們很難分清是fetchUserDetails邏輯錯了仍是fetch的邏輯錯了。

可重複性

咱們編寫的全部單元測試用例必定不能依賴外部的運行環境,不然咱們的單元測試將不具有可重複性(repeatable)。所謂的可重複性就是:若是咱們的單元測試用例如今是能夠經過的,那麼在代碼不發生變更和測試用例沒有改變的前提下它將是一直能夠經過的。舉個測試用例不具有可重複性的例子,假如你將項目的單元測試數據所有放在數據庫裏面,你今天運行項目的測試用例是能夠經過的,而次日其餘人無心改了數據庫的數據,這個時候你的測試用例就經過不了了,咱們就說這些測試用例不具有可重複性,出現這個問題的主要緣由是它們使用了外部的依賴做爲測試條件。因而可知要使咱們的測試用例具有可重複性的一個關鍵點是在編寫單元測試的時候避免外部依賴,這些外部依賴包括數據庫網絡請求本地文件系統等。

另一個影響到測試用例可重複性的一個重要的卻容易被忽略的因素是:不一樣單元測試用例之間共用了一些測試數據,某個測試用例對測試數據的更改可能會影響其它測試用例的正確執行。所以咱們在編寫單元測試用例的時候必定要避免不一樣測試用例之間共用一些測試數據,儘可能將每一個測試用例隔離起來。

提升代碼覆蓋率

在單元測試裏面有個概念叫作代碼覆蓋率(test coverage),它代表咱們代碼被測試的程度。舉個例子假如咱們有一個100行的函數,在咱們運行完全部的爲這個函數編寫的單元測試用例以後,若是測試框架告訴咱們這個函數的覆蓋率是80%,這代表咱們的測試用例代碼只覆蓋了這個函數的80行代碼,還有一些代碼分支(if/else, switch, while)沒有被執行到。若是咱們想經過單元測試來提升咱們代碼質量的話,咱們就須要保證咱們代碼的覆蓋率足夠大,儘可能讓被測試的函數的每一種被執行狀況都被覆蓋到(覆蓋率100%),特別是一些異常的狀況應該也要被覆蓋到(例如參數錯誤,調用第三方依賴報錯等),這樣咱們才能及早地發現代碼的bug並進行修復。

測試用例運行時間要短

我在上面說到單元測試是能夠幫助咱們更好地進行代碼迭代和重構的,要作到這點其實要求咱們在每次代碼歸併的時候對被merge的代碼進行一些自動化檢測(CI),這就包括項目單元測試用例的運行。試想一下在一個比較大型的項目裏面單元測試用例的數量每每是不少的,少則幾百個,多則上千個,若是所有運行全部測試用例的時間須要十幾分鍾甚至一兩小時,這就會影響到代碼集成的進度。爲了不這個問題,咱們就須要確保每一個單元測試用例執行的時間不能過長,例如避免在測試代碼裏面進行一些耗時的計算等。

如何對自定義Hook進行單元測試

React Hook實戰指南中咱們提到Hook就是一些函數,因此對Hook進行單元測試實際上是對一個函數進行測試,只不過這個函數和普通函數的區別是它擁有React給它賦予的特殊功能。在講如何對Hook進行測試以前咱們先來了解一下咱們要用到的測試框架Jest和hook測試庫react-hook-testing-library

Jest

Jest是Facebook開源的一個單元測試框架,它的使用率和知名度都很是高,一些著名的開源項目例如webpack, babel和react等都是使用Jest來進行單元測試的,因爲這篇文章的重點不是Jest的使用,因此我在這裏將不爲你們作具體的介紹,這裏主要介紹一下咱們經常使用到的Jest API:

經常使用API

it/test

it/test函數是用來定義測試用例(test case)的,它的函數簽名是it(description, fn?, timeout?)description參數是對這個測試用例的一個簡短的描述,fn是一個運行咱們實際測試邏輯的函數,而timeout則是這個測試用例的超時時間。下面是一個簡單的例子:

import sum from 'somewhere/sum'

it('test if sum work for positive numbers', () => {
  const result = sum(1, 2)
  expect(result).toEqual(3)
})
複製代碼
describe

describe函數是用來給測試用例分組用的,它的函數簽名是describe(description, fn),description是用來描述這個分組的,而fn函數裏面則能夠定義內嵌的分組(nested)或者是一些測試用例(it),下面是一個簡單的例子:

import sum from 'somewhere/sum'

describe('test sum', () => {
  it('work for positive numbers', () => {
    const result = sum(1, 2)
    expect(result).toEqual(3)
  })

  it('work for negative numbers', () => {
    const result = sum(-1, -2)
    expect(result).toEqual(-3)
  })
})
複製代碼
expect

咱們在剛開始的時候就提到所謂的測試就是要比較被測試對象的輸出和咱們期待的輸出是否是一致的,也就涉及到一個比較的過程,在Jest框架中咱們能夠經過expect函數來訪問一系列matcher來進行這個比較的過程,例如上面的expect(sum).toEqual(3)就是一個用matcher來判斷輸出結果是否是咱們想要的值的過程。關於更加詳細的matcher信息你們能夠參考jest的官方文檔

mock

在Jest框架中用來進行mock的方法有不少,主要用到的是jest.fn()jest.spyOn()

jest.fn

jest.fn會生成一個mock函數,這個函數能夠用來代替源代碼中被使用的第三方函數。jest.fn生成的函數上面有不少屬性,咱們也能夠經過一些matcher來對這個函數的調用狀況進行一些斷言,下面是一個簡單的例子:

// somewhere/functionWithCallback.js
export const functionWithCallback = (callback) => {
  callback(1, 2, 3)
}

// somewhere/functionWithCallback.spec.js
import { functionWithCallback } from 'somewhere/functionWithCallback'

describe('Test functionWithCallback', () => {
  it('if callback is invoked', () => {
    const callback = jest.fn()
    functionWithCallback(callback)

    expect(callback.mock.calls.length).toEqual(1)
  })
})
複製代碼
jest.spyOn

咱們源代碼中的函數可能使用了另一個文件或者node_modules中安裝的一些依賴,這些依賴可使用jest.spyOn來進行mock,下面是一個簡單的例子:

// somewhere/sum.js
import { validateNumber } from 'somewhere/validates'

export default (n1, n2) => {
  validateNumber(n1)
  validateNumber(n2)

  return n1 + n2
}

// somewhere/sum.spec.js
import sum from 'somewhere/sum'
import * as validates from 'somewhere/validates'

it('work for positive numbers', () => {
  // mock validateNumber
  const validateNumberMock = jest.spyOn(validates, 'validateNumber')
  
  const result = sum(1, 2)
  expect(result).toEqual(3)

  // restore original implementation
  validateNumberMock.mockRestore()
})
複製代碼

咱們在上面測試代碼中引入了源代碼使用到的依賴somewhere/validates,這個時候就能夠經過jest.spyOn來mock這個依賴export的一些方法了,例如validateNumber。被mock的函數會在源代碼被執行的時候使用,例如上面sum執行的時候使用到的validateNumber就是咱們在sum.spec.js裏面定義的validateNumberMock。這樣咱們除了能夠保證validateNumber不會影響到咱們對sum函數邏輯的測試,還能夠在外面對validateNumberMock進行一些斷言(assertion)來驗證sum邏輯的正確性。還有一點須要注意的是,我在測試用例執行完以後調用了mockRestore這個函數,這個函數會恢復validateNumber函數原來的實現,從而避免這個測試用例對validate文件的更改影響到其它測試用例的正確執行。

項目引入jest

瞭解完jest的一些基本API以後咱們再來看一下如何在咱們的項目裏面引入jest。

安裝依賴

首先使用下面命令安裝jest

yarn add -D jest
複製代碼

若是你項目使用的是Typescript,則還須要安裝ts-jest做爲依賴:

yarn add -D ts-jest
複製代碼

配置jest

安裝完jest後須要在package.json文件裏面配置一下:

{ 
  "jest": {
    "transform": {
      "^.+\\.tsx?$": "ts-jest"
    },
    "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$",
    "moduleDirectories": [
      "node_modules",
      "src"
    ],
    "moduleFileExtensions": [
      "ts",
      "tsx",
      "js",
      "jsx",
      "json",
      "node"
    ]
  }
}
複製代碼

上面各個配置項的意思分別是:

  • transform: 告訴jest,你的ts或者tsx文件須要使用ts-jest來進行轉換。
  • testRegex: 告訴jest哪些文件是須要被做爲測試代碼進行執行的,從上面的正則表達式咱們能夠看出文件名中有test和spec的文件將會被做爲測試用例執行。
  • moduleDirectories: 告訴jest在執行測試用例代碼的時候,代碼用到的dependencies應該去哪些目錄進行resolve,在這裏jest會去node_modulessrc(或者你本身的源代碼根目錄)裏面進行resolve,這個應該要和你項目的webpack.config.js的resolve部分配置保持一致。
  • moduleFileExtensions: 告訴jest在找不到對應文件的時候應該嘗試哪些文件後綴。

React hooks testing library

React-hooks-testing-library,是一個專門用來測試React hook的庫。咱們知道雖然hook是一個函數,但是咱們卻不能用測試普通函數的方法來測試它們,由於它們的實際運行會涉及到不少React運行時(runtime)的東西,所以不少人爲了測試本身的hook會編寫一些TestComponent來運行它們,這種方法十分不方便並且很難覆蓋到全部的情景。爲了簡化開發者測試hook的流程,React社區有人開發了這個叫作react-hooks-testing-library的庫來容許咱們像測試普通函數同樣測試咱們定義的hook,這個庫其實背後也是將咱們定義的hook運行在一個TestComponent裏面,只不過它封裝了一些簡易的API來簡化咱們的測試。在開始使用這個庫以前,咱們先來看一下它對外暴露的一些經常使用的API。

經常使用API

renderHook

renderHook這個函數顧名思義就是用來渲染hook的,它會在調用的時候渲染一個專門用來測試的TestComponent來使用咱們的hook。renderHook的函數簽名是renderHook(callback, options?),它的第一個參數是一個callback函數,這個函數會在TestComponent每次被從新渲染的時候調用,所以咱們能夠在這個函數裏面調用咱們想要測試的hook。renderHook的第二個參數是一個可選的options,這個options能夠帶兩個屬性,一個是initialProps,它是TestComponent的初始props參數,而且會被傳遞給callback函數用來調用hook。options的另一個屬性是wrapper,它用來指定TestComponent的父級組件(Wrapper Component),這個組件能夠是一些ContextProvider等用來爲TestComponent的hook提供測試數據的東西。

renderHook的返回值是RenderHookResult對象,這個對象會有下面這些屬性:

  • result:result是一個對象,它包含兩個屬性,一個是current,它保存的是renderHook callback的返回值,另一個屬性是error,它用來存儲hook在render過程當中出現的任何錯誤。
  • rerender: rerender函數是用來從新渲染TestComponent的,它能夠接收一個newProps做爲參數,這個參數會做爲組件從新渲染時的props值,一樣renderHookcallback函數也會使用這個新的props來從新調用。
  • unmount: unmount函數是用來卸載TestComponent的,它主要用來覆蓋一些useEffect cleanup函數的場景。
act

這函數和React自帶的test-utils的act函數是同一個函數,咱們知道組件狀態更新的時候(setState),組件須要被從新渲染,而這個重渲染是須要React進行調度的,所以是個異步的過程,咱們能夠經過使用act函數將全部會更新到組件狀態的操做封裝在它的callback裏面來保證act函數執行完以後咱們定義的組件已經完成了從新渲染。

安裝

直接把react-hooks-testing-library做爲咱們的項目devDependencies

yarn add -D @testing-library/react-hooks
複製代碼

注意:要使用react-hooks-testing-library咱們要確保咱們安裝了16.9.0版本及其以上的reactreact-test-renderer

yarn add react@^16.9.0
yarn add -D react-test-renderer@^16.9.0
複製代碼

例子

如今就讓咱們看一個簡單的同時使用Jestreact-hooks-testing-library來測試hook的例子,假如咱們在項目裏面定義了一個叫作useCounter的Hook:

// somewhere/useCounter.js
import { useState, useCallback } from 'react'

function useCounter() {
  const [count, setCount] = useState(0)

  const increment = useCallback(() => setCount(x => x + 1), [])
  const decrement = useCallback(() => setCount(x => x - 1), [])

  return {count, increment, decrease}
}
複製代碼

在上面的代碼中我定義了一個叫作useCounter的hook,這個hook是用來封裝一個叫作count的狀態而且對外暴露對count進行操做的一些updater包括incrementdecrement。若是你們對useStateuseCallback不夠熟悉的話能夠看一下個人上一篇文章React Hook實戰指南。接着就讓咱們編寫這個hook的測試用例:

// somewhere/useCounter.spec.js
import { renderHook, act } from '@testing-library/react-hooks'
import useCounter from 'somewhere/useCounter'

describe('Test useCounter', () => {
  describe('increment', () => {
     it('increase counter by 1', () => {
      const { result } = renderHook(() => useCounter())

      act(() => {
        result.current.increment()
      })

      expect(result.current.count).toBe(1)
    })
  })

  describe('decrement', () => {
    it('decrease counter by 1', () => {
      const { result } = renderHook(() => useCounter())

      act(() => {
        result.current.decrement()
      })

      expect(result.current.count).toBe(-1)
    })
})
})
複製代碼

上面的代碼中咱們寫了一個測試大組(describe)Test useCounter並在這個大組裏面定義了兩個測試小組分別用來測試useCounter返回的incrementdecrement方法。咱們具體看一下描述爲increase counter by 1的測試用例的代碼,首先咱們要用renderHook函數來渲染要被測試的hook,這裏咱們須要將useCounter的返回值做爲callback函數的返回值,這是由於咱們須要在外面拿到這個hook的返回結果{count, increment, decrement}。接着咱們使用act函數來調用改變組件狀態countincrement函數,act函數完成以後咱們的組件也就完成了重渲染,後面就能夠判斷更新後的count是否是咱們想要的結果了。

總結

在本篇文章中我給你們介紹了什麼叫作單元測試,爲何咱們須要在本身的項目裏面引入單元測試以及教你們如何使用Jestreact-hooks-testing-library來測試咱們自定義的hook。

這篇文章是個人React hook系列文章的最後一篇了,後面我還會持續爲你們分享一些和hook相關的內容,你們敬請期待。若是你們以爲對你有幫助,歡迎點贊和關注!

參考文獻

我的技術動態

文章始發於個人我的博客

歡迎關注公衆號進擊的大蔥一塊兒學習成長

相關文章
相關標籤/搜索