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相关的内容,你们敬请期待。若是你们以为对你有帮助,欢迎点赞和关注!

参考文献

我的技术动态

文章始发于个人我的博客

欢迎关注公众号进击的大葱一块儿学习成长

相关文章
相关标签/搜索