在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.jsexport const functionWithCallback = (callback) => {  callback(1, 2, 3)}// somewhere/functionWithCallback.spec.jsimport { 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.jsimport { validateNumber } from 'somewhere/validates'export default (n1, n2) => {  validateNumber(n1)  validateNumber(n2)  return n1 + n2}// somewhere/sum.spec.jsimport 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.0yarn add -D react-test-renderer@^16.9.0

例子

当初就让咱们看一个简略的同时应用Jestreact-hooks-testing-library来测试hook的例子,如果咱们在我的项目外面定义了一个叫做useCounter的Hook:

// somewhere/useCounter.jsimport { 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.jsimport { 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相干的内容,大家敬请期待。如果大家感觉对你有帮忙,欢送点赞和关注!

参考文献

  • https://jestjs.io/
  • https://react-hooks-testing-l...

集体技术动静

文章始发于我的集体博客

欢送关注公众号进击的大葱一起学习成长