[譯] 如何測試 React Hooks ?

江米小棗tonylua發表於2019-01-12

原文:blog.kentcdodds.com/react-hooks…

我們該如何準備好 React 新特性 hooks 的測試呢?

對於即將來臨的 React Hooks 特性,我聽到最常見的問題都是關於測試的。我都能想像出你測試這種時的焦慮:

// 借用另一篇博文中的例子:
// https://kcd.im/implementation-details
test('setOpenIndex sets the open index state properly', () => {
  const wrapper = mount(<Accordion items={[]} />)
  expect(wrapper.state('openIndex')).toBe(0)
  wrapper.instance().setOpenIndex(1)
  expect(wrapper.state('openIndex')).toBe(1)
})
複製程式碼

該 Enzyme 測試用例適用於一個存在真正例項的類元件 Accordion,但當元件為函式式時卻並沒有 instance 的概念。所以當你把有狀態和生命週期的類元件重構成用了 hooks 的函式式元件後,再呼叫諸如 .instance().state() 等就不能如願了。

一旦你把類元件 Accordion 重構為函式式元件,那些測試就會掛掉。所以為了確保我們的程式碼庫能在不推倒重來的情況下準備好 hooks 的重構,我們能做些什麼呢?可以從繞開上例中涉及元件例項的 Enzyme API 開始。

* 閱讀這篇文章 “關於實現細節” 以瞭解更多相關內容。

來看個簡單的類元件,我喜歡的一個例子是 <Counter /> 元件:

// counter.js
import React from 'react'
class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return (
      <button onClick={this.increment}>{this.state.count}</button>
    )
  }
}
export default Counter
複製程式碼

現在我們瞧瞧用一種什麼方式對其測試,可以在用 hooks 重構後也能應對:

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'
test('用 counter 增加計數', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})
複製程式碼

測試將會通過。現在我們來將其重構為 hooks 版本:

// counter.js
import React, {useState} from 'react'
function Counter() {
  const [count, setCount] = useState(0)
  const incrementCount = () => setCount(c => c + 1)
  return <button onClick={incrementCount}>{count}</button>
}
export default Counter
複製程式碼

你猜怎麼著?!因為我們的測試用例規避了關於實現的細節,所以 hooks 也沒問題!多麼的優雅~ :)

useEffect 可不是 componentDidMount + componentDidUpdate + componentWillUnmount

另一件要顧及的事情是 useEffect hook,因為要用獨一無二、特別、與眾不同、了不得來形容它,還真都有那麼一點。當你從類重構到 hooks 後,通常是把邏輯從 componentDidMountcomponentDidUpdatecomponentWillUnmount 中移動到一個或多個 useEffect 回撥中(取決於你元件生命週期中關注點的數量)。但其實這並不算真正的重構,我們還是看看“重構”該有的樣子吧。

所謂重構程式碼,就是在不改變使用者體驗的情況下將程式碼的實現加以改動。wikipedia 上關於 “code refactoring” 的解釋:

程式碼重構(Code refactoring) 是重組既有計算機程式碼結構的過程 — 改變 因子(factoring) — 而不改變其外部行為。

Ok,我們來試驗一下這個想法:

const sum = (a, b) => a + b
複製程式碼

對於該函式的一種重構:

const sum = (a, b) => b + a
複製程式碼

它依然會一摸一樣的執行,但其自身的實現卻有了一點不同。基本上這也算得上是個“重構”。Ok,現在看看什麼是錯誤的重構:

const sum = (...args) => args.reduce((s, n) => s + n, 0)
複製程式碼

看起來很牛,sum 更神通廣大了。但從技術上說這不叫重構,而是一種增強。比較一下:

| call         | result before | result after |
|--------------|---------------|--------------|
| sum()        | NaN           | 0            |
| sum(1)       | NaN           | 1            |
| sum(1, 2)    | 3             | 3            |
| sum(1, 2, 3) | 3             | 6            |
複製程式碼

為什麼說這不叫重構呢?因為雖說我們的改變令人滿意,但也“改變了其外部行為”。

那麼這一切和 useEffect 有何關係呢?讓我們看看有關計數器元件的另一個例子,這次這個類元件有一個新特性:

class Counter extends React.Component {
  state = {
    count: Number(window.localStorage.getItem('count') ||  0)
  }
  increment = () => this.setState(({count}) => ({count: count + 1}))
  componentDidMount() {
    window.localStorage.setItem('count', this.state.count)
  }
  componentDidUpdate(prevProps, prevState) {
    if (prevState.count !== this.state.count) {
      window.localStorage.setItem('count', this.state.count)
    }
  }
  render() {
    return (
      <button onClick={this.increment}>{this.state.count}</button>
    )
  }
}
複製程式碼

Ok, 我們在 componentDidMountcomponentDidUpdate 中把 count 儲存在了 localStorage 裡面。以下是我們的“與實現細節無關”的測試用例:

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent, cleanup} from 'react-testing-library'
import Counter from '../counter.js'
afterEach(() => {
  window.localStorage.removeItem('count')
})
test('用 counter 增加計數', () => {
  const {container} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})
test('讀和改 localStorage', () => {
  window.localStorage.setItem('count', 3)
  const {container, rerender} = render(<Counter />)
  const button = container.firstChild
  expect(button.textContent).toBe('3')
  fireEvent.click(button)
  expect(button.textContent).toBe('4')
  expect(window.localStorage.getItem('count')).toBe('4')
})
複製程式碼

好麼傢伙的!測試又通過啦!現在再對這個有著新特性的元件“重構”一番:

import React, {useState, useEffect} from 'react'
function Counter() {
  const [count, setCount] = useState(() =>
    Number(window.localStorage.getItem('count') || 0),
  )
  const incrementCount = () => setCount(c => c + 1)
  useEffect(
    () => {
      window.localStorage.setItem('count', count)
    },
    [count],
  )
  return <button onClick={incrementCount}>{count}</button>
}
export default Counter
複製程式碼

很棒,對於使用者來說,元件用起來和原來一樣。但其實它的工作方式異於從前了;真正的門道在於 useEffect 回撥被預定在稍晚的時間執行。所以在之前,是我們在渲染之後同步的設定 localStorage 的值;而現在這個動作被安排到渲染之後的某個時候。為何如此呢?讓我們查閱 React Hooks 文件中的這一段

不像 componentDidMountcomponentDidUpdate,用 useEffect 排程的副作用不會阻塞瀏覽器更新螢幕。這使得你的應用使用起來更具響應性。多數副作用不需要同步發生。而在不常見的情況下(比如要度量佈局的尺寸),另有一個單獨的 useLayoutEffect Hook,其 API 和 useEffect 一樣。

Ok, 用了 useEffect 就是好!效能都進步了!我們增強了元件的功能,程式碼也更簡潔了!爽!

但是...說回來,這不叫重構。實際上這是改變行為了。對於終端使用者來說,改變難以察覺;但從我們的測試視角可以觀察到這種改變。這也解釋了為何原來的測試一旦執行就會這樣 :-(

FAIL  __tests__/counter.js
  ✓ counter increments the count (31ms)
  ✕ reads and updates localStorage (12ms)
  ● reads and updates localStorage
    expect(received).toBe(expected) // Object.is equality
    Expected: "4"
    Received: "3"
      23 |   fireEvent.click(button)
      24 |   expect(button.textContent).toBe('4')
    > 25 |   expect(window.localStorage.getItem('count')).toBe('4')
         |                                                ^
      26 | })
      27 | 
      at Object.toBe (src/__tests__/05-testing-effects.js:25:48)
複製程式碼

我們的問題在於,測試用例試圖在使用者和元件互動(並且 state 被更新、元件被渲染)後同步的讀取 localStorage 的新值,但現在卻變成了非同步行為。

要解決這個問題,這裡有一些方法:

  1. 按照上面提過的官網文件把 React.useEffect 改為 React.useLayoutEffect。這是最簡單的辦法了,但除非你真的需要相關行為同步發生才能那麼做,因為實際上這會傷及效能。

  2. 使用 react-testing-library 庫的 wait 工具並把測試設定為 async。這招被認為是最好的解決之道,因為操作實際上就是非同步的,可從功效學的角度並不盡善盡美 -- 因為當前在 jsdom(工作在瀏覽器中) 中這樣嘗試的話實際上是有 bug 的。我還沒特別調查 bug 的所在(我猜是在 jsdom 中),因為我更喜歡下面一種解決方式。

  3. 實際上你可以通過 ReactDOM.render 強制副作用同步的重新整理。react-testing-library 提供一個實驗性的 API flushEffects 以方便的實現這一目的。這也是我推薦的選項。

那麼來看看我們的測試為這項新增強特性所需要考慮做出的改變:

@@ -1,6 +1,7 @@
 import React from 'react'
 import 'react-testing-library/cleanup-after-each'
-import {render, fireEvent} from 'react-testing-library'
+import {render, fireEvent, flushEffects} from 'react-testing-library'
 import Counter from '../counter'
 
 afterEach(() => {
   window.localStorage.removeItem('count')
@@ -21,5 +22,6 @@ test('讀和改 localStorage', () => {
   expect(button.textContent).toBe('3')
   fireEvent.click(button)
   expect(button.textContent).toBe('4')
+  flushEffects()
   expect(window.localStorage.getItem('count')).toBe('4')
 })
複製程式碼

Nice! 每當我們想讓斷言基於副作用回撥函式執行,只要呼叫 flushEffects() ,就可以一切如常了。

等會兒… 這難道不是測試了實現細節麼? YES! 恐怕是這樣的。如果不喜歡,那就如你所願的把每個互動都做成非同步的好了,因為事實上任何事情都同步發生也是關乎一些實現細節的。相反,我通過把元件的測試寫成同步,雖然付出了一點實現細節上的代價,但取得了功效學上的權衡。軟體無絕對,我們要在這種事情上權衡利弊。我只是覺得在這個領域稍加研究以利於得到更好的測試功效。

render props 元件又如何?

大概真是我的愛好了,這裡還有個簡單的計數器 render prop 元件:

class Counter extends React.Component {
  state = {count: 0}
  increment = () => this.setState(({count}) => ({count: count + 1}))
  render() {
    return this.props.children({
      count: this.state.count,
      increment: this.increment,
    })
  }
}
// 用法:
// <Counter>
//   {({ count, increment }) => <button onClick={increment}>{count}</button>}
// </Counter>
複製程式碼

這是我的測試方法:

// __tests__/counter.js
import React from 'react'
import 'react-testing-library/cleanup-after-each'
import {render, fireEvent} from 'react-testing-library'
import Counter from '../counter.js'

function renderCounter(props) {
  let utils
  const children = jest.fn(stateAndHelpers => {
    utils = stateAndHelpers
    return null
  })
  return {
    ...render(<Counter {...props}>{children}</Counter>),
    children,
    // 這能讓我們訪問到 increment 及 count
    ...utils,
  }
}

test('用 counter 增加計數', () => {
  const {children, increment} = renderCounter()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0}))
  increment()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1}))
})
複製程式碼

Ok,再將元件重構為使用 hooks 的:

function Counter(props) {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return props.children({
    count: count,
    increment,
  })
}
複製程式碼

很酷~ 並且由於我們按照既定的方法寫出了測試,也是順利通過。BUT! 按我們從 “React Hooks: 對 render props 有何影響?” 中學到過的,自定義 hooks 才是在 React 中分享程式碼的更好的一種原生方法。所以我們照葫蘆畫瓢的重寫一下:

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}
export default useCounter

// 用法:
// function Counter() {
//   const {count, increment} = useCounter()
//   return <button onClick={increment}>{count}</button>
// }
複製程式碼

棒極了… 但是如何測試 useCounter 呢?並且等等!總不能為了新的 useCounter 更新整個程式碼庫吧!正在使用的 <Counter /> render prop 元件可能被普遍引用,這樣的重寫是行不通的。

好吧,其實只要這樣替代就可以了:

function useCounter() {
  const [count, setCount] = useState(0)
  const increment = () => setCount(currentCount => currentCount + 1)
  return {count, increment}
}
const Counter = ({children, ...props}) => children(useCounter(props))
export default Counter
export {useCounter}
複製程式碼

最新版的 <Counter /> render prop 元件才真正和原來用起來一樣,所以這才是真正的重構。並且如果現在誰有時間升級的話,也可以直接用我們的 useCounter 自定義 hook。

測試又過了,爽翻啦~

等到大家都升級完,我們就可以移除函式式元件 Counter 了吧?你當然可以那麼做,但實際上我會把它挪到 __tests__ 目錄中,因為這就是我喜歡測試自定義 hooks 的原因。我寧願用沒有自定義 hooks 的 render-prop 元件,真實的渲染它,並對函式被如何呼叫寫斷言。

結論

在重構程式碼前可以做的最好的一件事就是有個良好的測試套件/型別定義,這樣當你無意中破壞了某些事情時可以快速定位問題。同樣要謹記 如果你在重構時把之前的測試套件丟在一邊,那些用例將變得毫無助益。將我關於避免實現細節的忠告用在你的測試中,讓在當今的類元件上工作良好的類,在之後重構為 hooks 時照樣能發揮作用。祝你好運!



--End--

[譯] 如何測試 React Hooks ?

搜尋 fewelife 關注公眾號

轉載請註明出處

相關文章