React Hooks我的测试会发生什么

57 阅读8分钟

关于即将到来的React Hooks功能,我听到的最常见的问题之一是关于测试。当你的测试看起来像这样时,我可以理解你的担忧:

// borrowed from a previous blog post:
// 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)
})

instanceAccordion 是一个实际存在的类组件时,酶测试是有效的,但当你的组件是函数组件时,没有组件 "实例 "的概念。因此,当你把你的组件从具有状态/生命周期的类组件重构为具有钩子的函数组件时,像.instance().state() 这样的事情就不会起作用。

因此,如果你将Accordion 组件重构为一个函数组件,这些测试就会中断。那么,我们可以做什么来确保我们的代码库为钩子重构做好准备,而不必丢弃我们的测试或重写它们?你可以从避免引用组件实例的酵素API开始,就像上面的测试。你可以在我的 "实现细节 "博文中读到更多关于这方面的内容。

让我们来看看一个更简单的类组件的例子。我最喜欢的例子是一个<Counter /> 组件。

// counter.js
import * as 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

现在让我们看看我们如何以一种为重构它使用钩子做准备的方式来测试它。

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

test('counter increments the count', () => {
  render(<Counter />)
  const button = screen.getByRole('button')
  expect(button).toHaveTextContent('0')
  userEvent.click(button)
  expect(button).toHaveTextContent('1')
})

这个测试将通过。现在,让我们将其重构为同一组件的钩子版本。

// counter.js
import * as React from 'react'

function Counter() {
  const [count, setCount] = useState(0)
  const incrementCount = () => setCount(c => c + 1)
  return <button onClick={incrementCount}>{count}</button>
}

export default Counter

你猜怎么着?因为我们的测试避免了实现细节,我们的钩子通过了!这多好啊!:)

useEffect不是componentDidMount + componentDidUpdate + componentWillUnmount。

另一件要考虑的事情是useEffect 钩子,因为它实际上有点独特/特殊/不同/厉害。当你从类组件重构到钩子时,你通常会把逻辑从componentDidMountcomponentDidUpdatecomponentWillUnmount移到一个或多个useEffect的回调中(取决于你的组件在这些生命周期中的关注点的数量)。但这实际上不是一个重构。让我们快速回顾一下 "重构 "到底是什么。

当你重构代码时,你是在对实现进行修改,而不做用户可察觉的修改。下面是维基百科对 "代码重构 "的描述

代码重构是重组现有计算机代码的过程--在不改变其外部行为的情况下改变因子

好吧,让我们用一个例子来试试这个想法。

const sum = (a, b) => a + b

下面是这个函数的重构。

const sum = (a, b) => b + a

它的工作原理仍然完全相同,但实现本身却有些不同。从根本上说,这就是 "重构 "的含义。好了,现在,我们来看看什么是*"*重构"。

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>
  }
}

好的,所以我们要用componentDidMountcomponentDidUpdatelocalStorage 中保存count 的值。 下面是我们的无实现细节的测试的样子。

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

import Counter from '../counter'

afterEach(() => {
  window.localStorage.removeItem('count')
})

test('counter increments the count', () => {
  render(<Counter />)
  const button = screen.getByRole('button')
  expect(button).toHaveTextContent('0')
  userEvent.click(button)
  expect(button).toHaveTextContent('1')
})

test('reads and updates localStorage', () => {
  window.localStorage.setItem('count', 3)
  render(<Counter />)
  const button = screen.getByRole('button')
  expect(button).toHaveTextContent('3')
  userEvent.click(button)
  expect(button).toHaveTextContent('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 钩子,其API与useEffect 相同。

好的,所以通过使用useEffect ,这对性能来说是更好的!棒极了!我们已经对我们的组件进行了改进,而且我们的组件代码实际上更简单了!很好!

然而,这并不是一个重构。它实际上是一种行为的改变。就终端用户而言,这种变化是不可察觉的。在我们努力确保我们的测试没有实现细节的时候,这种变化也应该是不可察觉的。

这要归功于新的 act 的新工具,我们可以实现这一点。 react-dom/test-utils 的新工具,我们可以实现这一点。因此,React测试库与该工具集成,使我们所有的测试继续通过编写,允许我们编写的测试没有实现细节,并继续尽可能接近我们的软件使用方式。

那渲染道具组件呢?

这可能是我最喜欢的。这里有一个简单的计数器渲染道具组件。

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,
    })
  }
}
// usage:
// <Counter>
//   {({ count, increment }) => <button onClick={increment}>{count}</button>}
// </Counter>

下面是我测试这个的方法。

// __tests__/counter.js
import * as React from 'react'
import {render} from '@testing-library/react'

import Counter from '../counter'

function renderCounter(props) {
  let utils
  const children = jest.fn(stateAndHelpers => {
    utils = stateAndHelpers
    return null
  })
  return {
    ...render(<Counter {...props}>{children}</Counter>),
    children,
    // this will give us access to increment and count
    ...utils,
  }
}

test('counter increments the count', () => {
  const {children, increment} = renderCounter()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 0}))
  increment()
  expect(children).toHaveBeenCalledWith(expect.objectContaining({count: 1}))
})

好的,让我们把这个计数器重构为一个使用钩子的组件。

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

很好,因为我们用这种方式写了测试,它实际上还是通过了。但是!正如我们从"React Hooks:渲染道具会发生什么?"自定义钩子是React中代码共享的一个更好的原始方法。所以让我们把这个重写成一个自定义钩子。

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

export default useCounter

// usage:
// function Counter() {
//   const {count, increment} = useCounter()
//   return <button onClick={increment}>{count}</button>
// }

真棒......但我们如何测试useCounter ?还有,等等!我们不能更新我们的整个代码。我们不能把我们的整个代码库更新到新的useCounter!我们在大约三百个地方使用了基于<Counter /> 渲染道具的组件!?重写是最糟糕的!

不,我明白你的意思。改成这样。

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 /> 基于渲染道具的组件实际上与我们以前的组件完全一样。所以这是一个真正的重构。但是现在任何能够花时间升级的人都可以使用我们的useCounter自定义挂钩。

哦,你猜怎么着。我们的测试仍然通过!!!什么?多么整洁啊,对吗?

所以,当每个人都升级后,我们可以删除Counter函数组件,对吗? 你也许可以这样做,但我实际上会把它移到__tests__,因为这就是我喜欢测试自定义钩子的方式!我更喜欢从自定义钩子中制作一个基于渲染参数的组件,然后实际渲染,并断言该函数是用什么调用的。

有趣的技巧,对吗?我在egghead.io的新课程中告诉你如何做到这一点。请欣赏

钩子库怎么样?

如果你正在编写一个通用的或开源的钩子,那么你可能想在没有特定组件的情况下测试它。在这种情况下,我建议使用renderHook ,从@testing-library/react.

总结

在你重构代码之前,你能做的最好的事情之一就是有一个好的测试套件/类型定义,所以当你无意中破坏了一些东西时,你可以马上意识到这个错误。但是,**如果你在重构时不得不扔掉你的测试套件,那就没有任何好处了。**接受我的建议:避免在你的测试中出现实现细节。编写的测试今天可以使用类,将来如果这些类被重构为带有钩子的函数,也可以使用。祝您好运!