React Hook測試指南

進擊的大蔥發表於2020-07-22

React為什麼需要Hook中我們探討了React為什麼需要引入Hook這個屬性,在React Hook實戰指南中我們深入瞭解了各種Hook的詳細用法以及會遇到的問題,在本篇文章中我將帶大家瞭解一下如何通過為自定義hook編寫單元測試來提高我們的程式碼質量,它會包含下面的內容:

  • 什麼是單元測試

    • 單元測試的定義
    • 為什麼需要編寫單元測試
    • 單元測試需要注意什麼
  • 如何對自定義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),我們一般不推薦將某個類或者某個模組直接作為單元測試的單元,因為這會使被測試的邏輯過於龐大,而且問題出現時不容易進行定位。

為什麼需要編寫單元測試

瞭解了單元測試的定義後,我們再來探討一下為什麼我們要在程式碼裡面進行單元測試。

我們之所以要在專案中編寫單元測試,主要是因為對程式碼進行單元測試有下面這些好處:

提高程式碼質量

單元測試可以提高我們的程式碼質量主要體現在它可以在我們開發某個功能的時候提前幫我們發現自己編寫的程式碼的bug。舉個例子,假如A同學寫了一個叫做useOptions的hook它接受一個叫做options的引數,這個引數既可以是一個物件也可以是一個陣列。A同學自己開發的過程中他只試過給useOptions傳物件而沒有試過給它傳陣列。同一個專案的B同學在使用useOptions的時候給它傳了個陣列發現程式碼掛了,這個時候B同學就得找A同學確認並等待A同學修復這個問題,這不但會影響B同學的開發進度而且還會讓B同學覺得A同學不靠譜,或者覺得A同學的程式碼很爛。如果A同學有對useOptions進行單元測試的話,這個悲劇可能就不會發生了,因為A同學在為useOptions編寫單元測試的時候就考慮了options為陣列的情況,並且在B同學使用之前就修復了這個問題。因此編寫單元測試可以讓我們在開發的過程中提前考慮到很多後面使用才會發現的問題,進而提高我們的程式碼質量。

方便程式碼重構和新功能新增

編寫單元測試的過程其實是我們給程式碼編寫使用說明書的過程(specification)。這個使用說明書十分重要,它相當於程式碼生產者(producer)與程式碼消費者(consumer)之間的合約(contract),生產者需要保證在消費者使用程式碼沒錯的前提下程式碼要有使用說明書上面的效果。這其實會對程式碼生產者起到一定的制約作用,因為生產者必須保證無論是給原來的程式碼新增新的功能還是對它進行重構,它都要滿足原來使用說明書上的要求。

繼續上面那個例子,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將會更難被定位和修復,如果我們大部分的程式碼都有單元測試的話,無論是對程式碼增加新的功能還是對原來的程式碼進行重構我們都會更有信心。

完善我們程式碼的設計

在軟體工程裡面有個概念叫做測試驅動開發(Test-driven Development),它鼓勵我們在實際開始編碼之前先為我們的程式碼編寫測試用例。這樣做的目的是讓我們在開發之前就以程式碼使用者的角度去評判我們的程式碼設計。如果我們的程式碼設計很糟糕,我們就會發現我們很難為它們編寫詳盡的單元測試用例,相反如果我們的程式碼設計得很好(低耦合高內聚),各個函式的引數和功能都設計得十分合理,我們就十分容易就為它們編寫對應的單元測試。我們要記住一句話:高質量的程式碼一定是可以被測試的(testable)。那麼為什麼是在還沒開始寫程式碼之前就編寫測試用例呢?這是因為如果我們在程式碼寫完之後再編寫測試的話,即使我們發現程式碼設計得再不合理,我們也沒有動力去改了,因為對設計的改動可能會讓我們重寫所有的程式碼,所以我們需要在實際編碼之前進行單元測試的編寫,因為這個時候的改程式碼阻力是最小的。

提供文件功能

我們在為程式碼編寫單元測試的時候實際上是在為程式碼編寫一個個使用例子,因此別的開發者在使用我們程式碼的時候可以通過我們的單元測試來快速掌握我們定義的各種函式的用法。另外教大家一個實用的技巧:如果我們發現某個庫的文件不是很全面的話,可以通過檢視這個庫的單元測試來快速掌握這個庫的用法。

單元測試需要注意的問題

隔離性

上面我們說到單元測試是對程式碼獨立的單元進行測試,這個獨立的意思不是說這個函式(單元)不會呼叫另外一個函式(單元),而是說我們在測試這個函式的時候如果它有呼叫到其它的函式我們就需要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相關的內容,大家敬請期待。如果大家覺得對你有幫助,歡迎點贊和關注!

參考文獻

個人技術動態

文章始發於我的個人部落格

歡迎關注公眾號進擊的大蔥一起學習成長

相關文章