如何测试自定义的React钩子(附代码示例)

98 阅读8分钟

如果你使用react@>=16.8 ,那么你可以使用钩子,你可能已经自己写了几个自定义的钩子。你可能已经想知道如何保证你的钩子在你的应用程序的生命周期内继续工作。我说的不是你为了使你的组件主体更小和组织你的代码而拉出的一次性自定义钩子(这些应该由你的组件测试来覆盖),我说的是你已经发布到github/npm的那个可重复使用的钩子(或者你一直在和你的法律部门讨论这个问题)。

比方说,我们有一个叫做useUndo 的自定义钩子(灵感来自于useUndoHomer Chen的灵感)。

(注意,你理解它的作用并不超级重要,但如果你好奇的话,你可以扩展一下)。

useUndo的实现

import * as React from 'react'

const UNDO = 'UNDO'
const REDO = 'REDO'
const SET = 'SET'
const RESET = 'RESET'

function undoReducer(state, action) {
  const {past, present, future} = state
  const {type, newPresent} = action

  switch (action.type) {
    case UNDO: {
      if (past.length === 0) return state

      const previous = past[past.length - 1]
      const newPast = past.slice(0, past.length - 1)

      return {
        past: newPast,
        present: previous,
        future: [present, ...future],
      }
    }

    case REDO: {
      if (future.length === 0) return state

      const next = future[0]
      const newFuture = future.slice(1)

      return {
        past: [...past, present],
        present: next,
        future: newFuture,
      }
    }

    case SET: {
      if (newPresent === present) return state

      return {
        past: [...past, present],
        present: newPresent,
        future: [],
      }
    }

    case RESET: {
      return {
        past: [],
        present: newPresent,
        future: [],
      }
    }
    default: {
      throw new Error(`Unhandled action type: ${type}`)
    }
  }
}

function useUndo(initialPresent) {
  const [state, dispatch] = React.useReducer(undoReducer, {
    past: [],
    present: initialPresent,
    future: [],
  })

  const canUndo = state.past.length !== 0
  const canRedo = state.future.length !== 0
  const undo = React.useCallback(() => dispatch({type: UNDO}), [])
  const redo = React.useCallback(() => dispatch({type: REDO}), [])
  const set = React.useCallback(
    newPresent => dispatch({type: SET, newPresent}),
    [],
  )
  const reset = React.useCallback(
    newPresent => dispatch({type: RESET, newPresent}),
    [],
  )

  return {...state, set, reset, undo, redo, canUndo, canRedo}
}

export default useUndo

假设我们想为此写一个测试,这样我们就可以保持信心,当我们进行修改和错误修复时,不会破坏现有的功能。为了获得我们需要的最大信心,我们应该确保我们的测试与软件的使用方式相似。 记住,软件是所有关于自动化的事情,我们不想或不能手动完成。测试也不例外,所以要考虑你将如何手动测试,然后编写你的测试来做同样的事情。

我看到很多人犯的一个错误是认为 "好吧,这只是一个函数,这就是我们喜欢钩子的原因。所以我不能直接调用这个函数并对输出进行断言吗?单元测试太棒了!"他们没有错。它只是一个函数,但从技术上讲,它不是一个函数(虽然你的钩子应该是empotent的)。 如果这个函数是纯的,那么调用它并对输出进行断言将是一个简单的任务。

如果你试图在测试中简单地调用这个函数,你就违反了钩子的规则,你会得到这个错误。

Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:
  1. You might have mismatching versions of React and the renderer (such as React DOM)
  2. You might be breaking the Rules of Hooks
  3. You might have more than one copy of React in the same app
  See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.

(我因为上述三个原因都得到了这个错误🙈)

现在,你可能会开始想:"嘿,如果我只是模拟我正在使用的内置React钩子,如useStateuseEffect ,那么我仍然可以像一个函数一样测试它。"但是,为了所有纯粹的东西,请不要这样做。你这样做会丢掉很多的信心。

但不要担心,如果你要手动测试,而不是简单地调用函数,你可能会写一个使用钩子的组件,然后与渲染到页面上的组件进行交互(也许使用storybook)。所以让我们这样做吧。

import * as React from 'react'
import useUndo from '../use-undo'

function UseUndoExample() {
  const {present, past, future, set, undo, redo, canUndo, canRedo} =
    useUndo('one')
  function handleSubmit(event) {
    event.preventDefault()
    const input = event.target.elements.newValue
    set(input.value)
    input.value = ''
  }

  return (
    <div>
      <div>
        <button onClick={undo} disabled={!canUndo}>
          undo
        </button>
        <button onClick={redo} disabled={!canRedo}>
          redo
        </button>
      </div>
      <form onSubmit={handleSubmit}>
        <label htmlFor="newValue">New value</label>
        <input type="text" id="newValue" />
        <div>
          <button type="submit">Submit</button>
        </div>
      </form>
      <div>Present: {present}</div>
      <div>Past: {past.join(', ')}</div>
      <div>Future: {future.join(', ')}</div>
    </div>
  )
}

export {UseUndoExample}

这就是渲染的结果。

很好,所以现在我们可以使用使用钩子的例子组件来手动测试这个钩子,所以为了使用软件来自动化我们的手动过程,我们需要写一个测试来完成我们手动做的同样的事情。下面是这样的情况。

import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {UseUndoExample} from '../use-undo.example'

test('allows you to undo and redo', () => {
  render(<UseUndoExample />)
  const present = screen.getByText(/present/i)
  const past = screen.getByText(/past/i)
  const future = screen.getByText(/future/i)
  const input = screen.getByLabelText(/new value/i)
  const submit = screen.getByText(/submit/i)
  const undo = screen.getByText(/undo/i)
  const redo = screen.getByText(/redo/i)

  // assert initial state
  expect(undo).toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past:`)
  expect(present).toHaveTextContent(`Present: one`)
  expect(future).toHaveTextContent(`Future:`)

  // add second value
  input.value = 'two'
  userEvent.click(submit)

  // assert new state
  expect(undo).not.toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past: one`)
  expect(present).toHaveTextContent(`Present: two`)
  expect(future).toHaveTextContent(`Future:`)

  // add third value
  input.value = 'three'
  userEvent.click(submit)

  // assert new state
  expect(undo).not.toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past: one, two`)
  expect(present).toHaveTextContent(`Present: three`)
  expect(future).toHaveTextContent(`Future:`)

  // undo
  userEvent.click(undo)

  // assert "undone" state
  expect(undo).not.toBeDisabled()
  expect(redo).not.toBeDisabled()
  expect(past).toHaveTextContent(`Past: one`)
  expect(present).toHaveTextContent(`Present: two`)
  expect(future).toHaveTextContent(`Future: three`)

  // undo again
  userEvent.click(undo)

  // assert "double-undone" state
  expect(undo).toBeDisabled()
  expect(redo).not.toBeDisabled()
  expect(past).toHaveTextContent(`Past:`)
  expect(present).toHaveTextContent(`Present: one`)
  expect(future).toHaveTextContent(`Future: two, three`)

  // redo
  userEvent.click(redo)

  // assert undo + undo + redo state
  expect(undo).not.toBeDisabled()
  expect(redo).not.toBeDisabled()
  expect(past).toHaveTextContent(`Past: one`)
  expect(present).toHaveTextContent(`Present: two`)
  expect(future).toHaveTextContent(`Future: three`)

  // add fourth value
  input.value = 'four'
  userEvent.click(submit)

  // assert final state (note the lack of "third")
  expect(undo).not.toBeDisabled()
  expect(redo).toBeDisabled()
  expect(past).toHaveTextContent(`Past: one, two`)
  expect(present).toHaveTextContent(`Present: four`)
  expect(future).toHaveTextContent(`Future:`)
})

我喜欢这种方法,因为测试相对来说比较容易遵循和理解。在大多数情况下,这就是我推荐的测试这种钩子的方式。

然而,有时你需要编写的组件是相当复杂的,你最终得到的测试失败不是因为钩子坏了,而是因为你写的例子是相当令人沮丧的。

这个问题因另一个问题而变得更加复杂。在某些情况下,有时你有一个钩子,很难为它所支持的所有用例创建一个单一的例子,所以你最终做了一堆不同的例子组件来测试。

现在,拥有这些例子组件可能是一个好主意(例如,它们对故事书来说是很好的),但有时创建一个小助手也是不错的,它实际上没有任何与之相关的用户界面,你可以直接与钩子的返回值进行交互。

下面是一个例子,说明我们的useUndo 钩子会是什么样的。

import * as React from 'react'
import {render, act} from '@testing-library/react'
import useUndo from '../use-undo'

function setup(...args) {
  const returnVal = {}
  function TestComponent() {
    Object.assign(returnVal, useUndo(...args))
    return null
  }
  render(<TestComponent />)
  return returnVal
}

test('allows you to undo and redo', () => {
  const undoData = setup('one')

  // assert initial state
  expect(undoData.canUndo).toBe(false)
  expect(undoData.canRedo).toBe(false)
  expect(undoData.past).toEqual([])
  expect(undoData.present).toEqual('one')
  expect(undoData.future).toEqual([])

  // add second value
  act(() => {
    undoData.set('two')
  })

  // assert new state
  expect(undoData.canUndo).toBe(true)
  expect(undoData.canRedo).toBe(false)
  expect(undoData.past).toEqual(['one'])
  expect(undoData.present).toEqual('two')
  expect(undoData.future).toEqual([])

  // add third value
  act(() => {
    undoData.set('three')
  })

  // assert new state
  expect(undoData.canUndo).toBe(true)
  expect(undoData.canRedo).toBe(false)
  expect(undoData.past).toEqual(['one', 'two'])
  expect(undoData.present).toEqual('three')
  expect(undoData.future).toEqual([])

  // undo
  act(() => {
    undoData.undo()
  })

  // assert "undone" state
  expect(undoData.canUndo).toBe(true)
  expect(undoData.canRedo).toBe(true)
  expect(undoData.past).toEqual(['one'])
  expect(undoData.present).toEqual('two')
  expect(undoData.future).toEqual(['three'])

  // undo again
  act(() => {
    undoData.undo()
  })

  // assert "double-undone" state
  expect(undoData.canUndo).toBe(false)
  expect(undoData.canRedo).toBe(true)
  expect(undoData.past).toEqual([])
  expect(undoData.present).toEqual('one')
  expect(undoData.future).toEqual(['two', 'three'])

  // redo
  act(() => {
    undoData.redo()
  })

  // assert undo + undo + redo state
  expect(undoData.canUndo).toBe(true)
  expect(undoData.canRedo).toBe(true)
  expect(undoData.past).toEqual(['one'])
  expect(undoData.present).toEqual('two')
  expect(undoData.future).toEqual(['three'])

  // add fourth value
  act(() => {
    undoData.set('four')
  })

  // assert final state (note the lack of "third")
  expect(undoData.canUndo).toBe(true)
  expect(undoData.canRedo).toBe(false)
  expect(undoData.past).toEqual(['one', 'two'])
  expect(undoData.present).toEqual('four')
  expect(undoData.future).toEqual([])
})

我觉得这个测试允许我们与钩子进行更直接的交互(这就是为什么需要act ),这使我们能够涵盖更多可能难以编写组件示例的情况。

现在,有时你有更复杂的钩子,你需要等待模拟的HTTP请求完成,或者你想用不同的道具 "重新渲染 "使用钩子的组件,等等。这些用例中的每一个都会使你的setup 函数或你的真实世界的例子变得更加复杂,这将使它更加具有领域的特殊性,并且难以遵循。

这就是为什么renderHook@testing-library/react 存在。如果我们使用@testing-library/react ,这个测试会是这样的。

import {renderHook, act} from '@testing-library/react'
import useUndo from '../use-undo'

test('allows you to undo and redo', () => {
  const {result} = renderHook(() => useUndo('one'))

  // assert initial state
  expect(result.current.canUndo).toBe(false)
  expect(result.current.canRedo).toBe(false)
  expect(result.current.past).toEqual([])
  expect(result.current.present).toEqual('one')
  expect(result.current.future).toEqual([])

  // add second value
  act(() => {
    result.current.set('two')
  })

  // assert new state
  expect(result.current.canUndo).toBe(true)
  expect(result.current.canRedo).toBe(false)
  expect(result.current.past).toEqual(['one'])
  expect(result.current.present).toEqual('two')
  expect(result.current.future).toEqual([])

  // add third value
  act(() => {
    result.current.set('three')
  })

  // assert new state
  expect(result.current.canUndo).toBe(true)
  expect(result.current.canRedo).toBe(false)
  expect(result.current.past).toEqual(['one', 'two'])
  expect(result.current.present).toEqual('three')
  expect(result.current.future).toEqual([])

  // undo
  act(() => {
    result.current.undo()
  })

  // assert "undone" state
  expect(result.current.canUndo).toBe(true)
  expect(result.current.canRedo).toBe(true)
  expect(result.current.past).toEqual(['one'])
  expect(result.current.present).toEqual('two')
  expect(result.current.future).toEqual(['three'])

  // undo again
  act(() => {
    result.current.undo()
  })

  // assert "double-undone" state
  expect(result.current.canUndo).toBe(false)
  expect(result.current.canRedo).toBe(true)
  expect(result.current.past).toEqual([])
  expect(result.current.present).toEqual('one')
  expect(result.current.future).toEqual(['two', 'three'])

  // redo
  act(() => {
    result.current.redo()
  })

  // assert undo + undo + redo state
  expect(result.current.canUndo).toBe(true)
  expect(result.current.canRedo).toBe(true)
  expect(result.current.past).toEqual(['one'])
  expect(result.current.present).toEqual('two')
  expect(result.current.future).toEqual(['three'])

  // add fourth value
  act(() => {
    result.current.set('four')
  })

  // assert final state (note the lack of "third")
  expect(result.current.canUndo).toBe(true)
  expect(result.current.canRedo).toBe(false)
  expect(result.current.past).toEqual(['one', 'two'])
  expect(result.current.present).toEqual('four')
  expect(result.current.future).toEqual([])
})

你会发现它与我们的自定义setup 函数非常相似。在引擎盖下,@testing-library/react 正在做一些与我们上面的原始setup函数非常相似的事情。我们从@testing-library/react 中得到的其他一些东西是:

  • 用于 "重新渲染 "正在渲染钩子的组件的工具(例如,测试依赖性变化的效果
  • 渲染钩子的组件的 "卸载 "实用程序(例如,测试效果清理功能
  • 几个异步工具来等待一个未指定的时间(测试异步逻辑)。

注意,你可以测试不止一个钩子,只需在传递给renderHook 的回调函数中调用所有你想要的钩子。

编写一个 "纯测试 "的组件来支持其中的一些,需要相当多的容易出错的模板,你可能会花更多的时间来编写和测试你的测试组件而不是你要测试的钩子。

总结

明确地说,如果我在编写和测试具体的useUndo 钩子,我将采用真实世界的例子用法。我认为这是在可理解性和覆盖我们的用例之间做出的最佳权衡。但肯定有一些更复杂的钩子,使用@testing-library/react 会更有用。