在React为何须要Hook中咱们探讨了React为何须要引入Hook这个属性,在React Hook实战指南中咱们深刻了解了各类Hook的详细用法以及会遇到的问题,在本篇文章中我将带你们了解一下如何经过为自定义hook编写单元测试来提升咱们的代码质量,它会包含下面的内容:javascript
要理解单元测试,咱们先来给测试下个定义。用最简单的话来讲测试就是:咱们给被测试对象一些输入(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),这就包括项目单元测试用例的运行。试想一下在一个比较大型的项目里面单元测试用例的数量每每是不少的,少则几百个,多则上千个,若是所有运行全部测试用例的时间须要十几分钟甚至一两小时,这就会影响到代码集成的进度。为了不这个问题,咱们就须要确保每一个单元测试用例执行的时间不能过长,例如避免在测试代码里面进行一些耗时的计算等。
在React Hook实战指南中咱们提到Hook就是一些函数,因此对Hook进行单元测试实际上是对一个函数进行测试,只不过这个函数和普通函数的区别是它拥有React给它赋予的特殊功能。在讲如何对Hook进行测试以前咱们先来了解一下咱们要用到的测试框架Jest和hook测试库react-hook-testing-library。
Jest是Facebook开源的一个单元测试框架,它的使用率和知名度都很是高,一些著名的开源项目例如webpack, babel和react等都是使用Jest来进行单元测试的,因为这篇文章的重点不是Jest的使用,因此我在这里将不为你们作具体的介绍,这里主要介绍一下咱们经常使用到的Jest API:
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(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)
})
})
复制代码
咱们在刚开始的时候就提到所谓的测试就是要比较被测试对象的输出和咱们期待的输出是否是一致的,也就涉及到一个比较的过程,在Jest框架中咱们能够经过expect
函数来访问一系列matcher
来进行这个比较的过程
,例如上面的expect(sum).toEqual(3)
就是一个用matcher来判断输出结果是否是咱们想要的值的过程。关于更加详细的matcher信息你们能够参考jest的官方文档。
在Jest框架中用来进行mock的方法有不少,主要用到的是jest.fn()
和jest.spyOn()
。
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)
})
})
复制代码
咱们源代码中的函数可能使用了另一个文件或者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的一些基本API以后咱们再来看一下如何在咱们的项目里面引入jest。
首先使用下面命令安装jest
yarn add -D jest
复制代码
若是你项目使用的是Typescript,则还须要安装ts-jest
做为依赖:
yarn add -D ts-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"
]
}
}
复制代码
上面各个配置项的意思分别是:
node_modules
和src
(或者你本身的源代码根目录)里面进行resolve,这个应该要和你项目的webpack.config.js的resolve部分配置保持一致。React-hooks-testing-library,是一个专门用来测试React hook的库。咱们知道虽然hook是一个函数,但是咱们却不能用测试普通函数的方法来测试它们,由于它们的实际运行会涉及到不少React运行时(runtime)的东西,所以不少人为了测试本身的hook会编写一些TestComponent
来运行它们,这种方法十分不方便并且很难覆盖到全部的情景。为了简化开发者测试hook的流程,React社区有人开发了这个叫作react-hooks-testing-library
的库来容许咱们像测试普通函数同样测试咱们定义的hook,这个库其实背后也是将咱们定义的hook运行在一个TestComponent
里面,只不过它封装了一些简易的API来简化咱们的测试。在开始使用这个库以前,咱们先来看一下它对外暴露的一些经常使用的API。
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
是一个对象,它包含两个属性,一个是current
,它保存的是renderHook
callback
的返回值,另一个属性是error
,它用来存储hook在render过程当中出现的任何错误。rerender
函数是用来从新渲染TestComponent
的,它能够接收一个newProps做为参数,这个参数会做为组件从新渲染时的props值,一样renderHook
的callback
函数也会使用这个新的props来从新调用。unmount
函数是用来卸载TestComponent
的,它主要用来覆盖一些useEffect cleanup
函数的场景。这函数和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
版本及其以上的react
和react-test-renderer
:
yarn add react@^16.9.0
yarn add -D react-test-renderer@^16.9.0
复制代码
如今就让咱们看一个简单的同时使用Jest
和react-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包括increment
和decrement
。若是你们对useState
和useCallback
不够熟悉的话能够看一下个人上一篇文章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
返回的increment
和decrement
方法。咱们具体看一下描述为increase counter by 1
的测试用例的代码,首先咱们要用renderHook
函数来渲染要被测试的hook,这里咱们须要将useCounter
的返回值做为callback
函数的返回值,这是由于咱们须要在外面拿到这个hook的返回结果{count, increment, decrement}
。接着咱们使用act
函数来调用改变组件状态count
的increment
函数,act
函数完成以后咱们的组件也就完成了重渲染,后面就能够判断更新后的count
是否是咱们想要的结果了。
在本篇文章中我给你们介绍了什么叫作单元测试,为何咱们须要在本身的项目里面引入单元测试以及教你们如何使用Jest
和react-hooks-testing-library
来测试咱们自定义的hook。
这篇文章是个人React hook系列文章的最后一篇了,后面我还会持续为你们分享一些和hook相关的内容,你们敬请期待。若是你们以为对你有帮助,欢迎点赞和关注!
文章始发于个人我的博客
欢迎关注公众号进击的大葱一块儿学习成长