大厂技术 坚持周更 精选好文
大家好,俗话说得好,光说不练假把式,我始终认为实践才是最好的老师,上面一章我们已经详细的了解了Jest
相关概念,以及如何搭建一个简单的测试环境(花一个小时,迅速掌握Jest的全部知识点~),今天就来详细讲讲React Hooks
的单元测试
在网上我们可以搜到很多React
的单元测试,但有关React Hooks
的单元测试却很少,或者说并不全面,所以今天就来详细讲讲有关React Hooks
如何进行单元测试。
如果你希望做为一个开源的产品,那么你的代码必须具备单元测试,所以这是进阶React
的必经之路,所以本节内容通过具体的例子来讲解React Hooks
,这样可以告别繁琐的知识点,又能融会贯通,岂不美哉?
跟以往一样,先来一张知识图,还请各位小伙伴多多支持~
自定义Hooks该如何测试?
疑问?
我们知道 react hooks
的本质是 纯函数
,那么我们可不可以通过测试纯函数来测试react hooks
呢 ?
我们先看这样一个例子:
import { useState } from "react";
function useCounter(initialValue = 0) {
const [current, setCurrent] = useState(initialValue);
const add = (number = 1) => setCurrent(v => v + number)
const dec = (number = 1) => setCurrent(v => v - number)
const set = (number = 1) => setCurrent(number)
return [
current,
{
add,
dec,
set,
},
] as const;
}
export default useCounter;
我定义了一个简单的useCounter
, 功能也是很简单,有增加
、减少
和设置
三个功能
测试结果
来进行下测试:
import useCounter from './index'
describe("useCounter 测试", () => {
it('数字加1', () => {
const [counter, { add }] = useCounter(7)
expect(counter).toEqual(7)
add()
expect(counter).toEqual(8)
})
})
乍一看,这么测试并没有什么问题,接下来看看测试结果:
这是因为在useCounter
中,我们运用了useState
,而React
规定只有在组件中才能使用Hooks
所以会报如下错误,我们可以通过renderHook
和 act
解决这个问题
renderHook 和 act
renderHook
renderHook:顾名思义,这个函数就是用来渲染hooks
,它会帮助我们解决Hooks
只能在组件中使用的问题(生成一个专门用来测试的TestComponent
)
用法:
function renderHook<Result, Props>(
render: (props: Props) => Result,
options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props>
入参
render:
callBack
函数,这个函数会在TestComponent
每次被重新渲染的时候调用,所以这个函数放入我们想测试的Hooks
就行options: 可选的
options
,有两个属性,分别是initialProps
和wrapper
options 的参数:
initialProps:
TestComponent
初始的props
wrapper:用来指定
TestComponent
的父级组件(Wrapper Component)
,这个组件可以是一些ContextProvider
等用来为TestComponent
的hook提供测试数据的东西
出参
renderHook:共返回三个参数,分别是:
result:结果,是一个对象结构,包含
current
(保存TestComponent
返回的callback
值)和error
(所有错误存放的值)render:用来重新渲染
TestComponent
,并且可以接受一个newProps
(参数)传递给TestComponent
unmount:用来卸载
TestComponent
, 主要用来覆盖一些useEffect cleanup
函数的场景。
act
act:这个函数和React
自带的test-utils
的act
函数是同一个函数,通过这个函数,我们可以将所有会更新到组件状态的操作 封装在它的callback
下,简单的说,我们如果对TestComponent
有操作,改变result
的值,就需要放到act下
解决问题
我们通过上面的renderHook 和 act
进行下改装
import { act, renderHook } from "@testing-library/react";
describe("useCounter 测试", () => {
it('数字加1', async () => {
const { result } = renderHook(() => useCounter(7))
expect(result.current[0]).toEqual(7);
act(() => {
result.current[1].add()
})
expect(result.current[0]).toEqual(8)
})
})
结果:
至于测试的报告,就看写的测试用例覆盖度了,当所有情况都涉及上就会显示100%
实战演练
useEventListener
上述的例子中,我们已经了解了renderHook
的result
,接下来我们来看看render
和unmount
的用法。
在之前我们详细讲过useEventListener
的实现,这里就不做过多的介绍(有感兴趣的可以看一下具体的实现:搞懂这12个Hooks,保证让你玩转React-useEventListener)
为了更好的进行单元测试,我在原先的基础上去除SSR
的部分,做个简单的优化和改动,代码如下:
import { useEffect } from 'react';
import { useLatest } from '../useLatest';
const useEventListener = (event: string, handler: (...e:any) => void, target: any = window) => {
const handlerRef = useLatest(handler);
useEffect(() => {
// 支持useRef 和 DOM节点
let targetElement:any;
if(!target){
targetElement = window
}else if ('current' in target) {
targetElement = target.current;
} else {
targetElement = target;
}
// 防止没有 addEventListener 这个属性
if(!targetElement?.addEventListener) return;
const useEventListener = (event: Event) => {
return handlerRef.current(event)
}
targetElement.addEventListener(event, useEventListener)
return () => {
targetElement.removeEventListener(event, useEventListener)
}
}, [event, target])
};
export default useEventListener;
测试点击事件
我们想要测试useEventListener
,首先需要创建一个DOM节点
,用来模拟点击事件,我们可以用document.createElement('div')
来创建一个div
并将它绑定在body
,然后在绑定到useEventListener
上,来进行测试
所以index.test.ts
可以这样书写:
import { renderHook } from "@testing-library/react";
import useEventListener from './';
describe('useEventListener', () => {
it('should be defined', () => {
expect(useEventListener).toBeDefined();
});
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement('div'); // 创建一个div
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container); // 卸载
});
it('测试监听点击事件', async () => {
let count: number = 0;
const onClick = () => {
count++;
};
const { rerender, unmount } = renderHook(() =>
useEventListener('click', onClick, container),
);
document.body.click(); // 点击 document应该无效
expect(count).toEqual(0);
container.click(); // 点击 container count + 1
expect(count).toEqual(1);
rerender(); // 重新渲染
container.click(); // 点击 container count + 1
expect(count).toEqual(2);
unmount(); // 卸载
container.click(); // 点击 container 应该无效
expect(count).toEqual(2);
});
})
做个简单的解释:
通过
beforeEach
和afterEach
创建DOM
元素(container)并卸载用
renderHook
监听对应的元素的点击事件,如果点击了,count + 1
首先在
body
上进行点击,不应该触发click
事件,count = 0
然后点击
container
,触发click
事件,count = 1
通过
rerender()
将hooks
重新渲染一遍,再点container
,看看会不会有影响,此时会触发click
事件,count = 2
最后
unmount
卸载函数,再点击container
,此时已经卸载,所以无法出发,触发click
事件,count
应该等于2
覆盖率报告
之后我们可以看一下覆盖率报告:
文件位置:coverage/lcov-report/index.html
,我们可以打开这个页面,看到对应的数据,如:
对应的代码为:
其中标红的代表未执行的语句(在coverage/lcov-report
)也会生成对应的useEventListener
文件,同时vscode
也可以看到为执行的代码,只是觉得index.html
更加直观
接下来,我们逐一解决未执行的代码,和一些遇到的问题
全局点击
我们只要不传入第三个参数,就能解决,所以
it('全局点击', async () => {
let count: number = 0;
const onClick = () => {
count++;
};
renderHook(() => useEventListener('click', onClick));
document.body.click(); // 点击 container count + 1
expect(count).toEqual(1);
container.click(); // 点击 container count + 1
expect(count).toEqual(2);
});
useRef的解决
if ('current' in target) {
targetElement = target.current;
}
上面这段代码处理的是useRef
的对象,那么我们在测试的时候是不是要利用useRef
,在通过ref
对象绑定到对应的DOM
节点上呢?
实际上,并不用,因为我们useRef
存储的对象都在current
下,所以我们只需要进行对应的模拟就OK了
如:
let containerRef;
beforeEach(() => {
...
containerRef = {
current: container,
};
});
it('模拟useRef点击事件', async () => {
let count: number = 0;
const onClick = () => {
count++;
};
const { rerender, unmount } = renderHook(() =>
useEventListener('click', onClick, containerRef),
);
document.body.click(); // 点击 document应该无效
expect(count).toEqual(0);
container.click(); // 点击 container count + 1
expect(count).toEqual(1);
rerender(); // 重新渲染
container.click(); // 点击 container count + 1
expect(count).toEqual(2);
unmount(); // 卸载
container.click(); // 点击 container 应该无效
expect(count).toEqual(2);
});
覆盖率报告
第三个也是同理,就不列举了,只要全部覆盖到就测试完毕了,如:
useHover
效果演示
我们根据useEventListener
再延伸一个useHover
useHover
:监听 DOM 元素是否有鼠标悬停。
代码也非常简单:
import { useState } from 'react';
import useEventListener from '../useEventListener';
interface Options {
onEnter?: () => void;
onLeave?: () => void;
onChange?: (isHover: boolean) => void;
}
const useHover = (target: any, options?: Options) => {
const { onEnter, onLeave, onChange } = options || {};
const [isHover, setHover] = useState<boolean>(false);
useEventListener(
'mouseenter',
() => {
onEnter?.();
onChange?.(true);
setHover(true);
},
target,
);
useEventListener(
'mouseleave',
() => {
onLeave?.();
onChange?.(false);
setHover(false);
},
target
);
return isHover;
};
export default useHover;
效果:
render、fireEvent 测试
我们在测试useEventListener
的时候通过document.createElement
创建元素,除了这种方式,我们可以通过测试组件的方式来测试,这里使用'@testing-library/react'
测试
可能有许多小伙伴喜欢用enzyme
做单元测试,但enzyme
测试也有很多问题(如:组件触发后,但触发后不能改变组件useState
的值),所以还是使用官方推荐的'@testing-library/react'
测试比较好
在这里主要介绍下 '@testing-library/react'
的render
和fireEvent
的方法,掌握这两个,一般的单元测试就OK
了
render
render
主要返回三类分别是:getBy...
、queryBy...
、findBy...
getBy...
:用于定位页面已经存在
的DOM元素,如果不存在,则抛出异常queryBy...
:用于定位页面不存在
的DOM元素,如果不存在,则返回null,不会抛出异常findBy...
:定位页面中的异常元素
,如果不存在,则抛出异常
三者的方法都一样,这里以getBt...
为例:
getByText: 按元素查找文本内容
getByRole: 按角色去查找
getByLabelText: 按标签或aria标签文本内容查找
getByPlaceholderText: 按输入placeholder查找
getByAltText: 按img的alt属性查找
getByTitle: 按标题属性或svg标题标记查找
getByDisplayValue: 按表单元素查找当前值
getByTestId: 按数据测试查找属性
一般而言,会用到getByText
和getByRole
来获取对应的元素
fireEvent
fireEvent:用于实际的操作,也就是模拟点击、键盘、表单等操作
用法:
// 两种写法
fireEvent(node: HTMLElement, event: Event)
fireEvent[eventName](node: HTMLElement, eventProperties: Object)
接下来看看 fireEvent
拥有哪些方法
export type EventType =
| 'keyDown'
| 'keyPress'
| 'keyUp'
| 'focus'
| 'blur'
| 'change'
| 'input'
| 'invalid'
| 'submit'
| 'reset'
| 'click'
| 'drag'
| 'dragEnd'
| 'dragEnter'
| 'dragExit'
| 'dragLeave'
| 'dragOver'
| 'dragStart'
| 'drop'
| 'mouseDown'
| 'mouseEnter'
| 'mouseLeave'
| 'mouseMove'
| 'mouseOut'
| 'mouseOver'
| 'mouseUp'
| 'scroll'
...
通常这样使用:
fireEvent.click(getByText('Hover'), () => {
....
});
测试用例
通过上面的了解,我们写useHover
的测试用例就简单了许多,首先用render
创建一个按钮,然后用fireEvent
模拟移入
和移出
效果即可
值得注意一点,我们这里测试的是组件,所以我们应该用index.test.jex
import { render, fireEvent, renderHook, act } from '@testing-library/react';
import useHover from '.';
describe('useHover', () => {
it('should be defined', () => {
expect(useHover).toBeDefined();
});
it('测试Hover', () => {
const { getByText } = render(<button>Hover</button>);
const { result } = renderHook(() => useHover(getByText('Hover')));
act(() => {
fireEvent.mouseEnter(getByText('Hover'), () => {
expect(result.current[0]).toBe(true);
});
});
act(() => {
fireEvent.mouseLeave(getByText('Hover'), () => {
expect(result.current[0]).toBe(false);
});
});
});
it('测试功能', () => {
const { getByText } = render(<button>Hover</button>);
let count = 0;
let flag = false;
const { result } = renderHook(() =>
useHover(getByText('Hover'), {
onEnter: () => {
count++;
},
onChange: (flag) => {
flag = flag;
},
onLeave: () => {
count++;
},
}),
);
expect(result.current).toBe(false);
act(() => {
fireEvent.mouseEnter(getByText('Hover'), () => {
expect(result.current).toBe(true);
expect(count).toBe(1);
expect(flag).toBe(true);
});
});
act(() => {
fireEvent.mouseLeave(getByText('Hover'), () => {
expect(result.current).toBe(false);
expect(count).toBe(2);
expect(flag).toBe(false);
});
});
});
});
useMouse
接下来,我们在通过useEventListener
来延伸一个useMouse
useMouse: 获取鼠标的位置,这块代码也非常简单,具体来看看测试用例
js 代码:
import { useState } from 'react';
import useEventListener from '../useEventListener';
const initState = {
screenX: NaN,
screenY: NaN,
clientX: NaN,
clientY: NaN,
pageX: NaN,
pageY: NaN,
elementX: NaN,
elementY: NaN,
elementH: NaN,
elementW: NaN,
elementPosX: NaN,
elementPosY: NaN,
};
export default (target?: any) => {
const [state, setState] = useState(initState);
useEventListener(
'mousemove',
(event: MouseEvent) => {
const { screenX, screenY, clientX, clientY, pageX, pageY } = event;
const newState = {
screenX,
screenY,
clientX,
clientY,
pageX,
pageY,
elementX: NaN,
elementY: NaN,
elementH: NaN,
elementW: NaN,
elementPosX: NaN,
elementPosY: NaN,
};
setState(newState);
},
{
target: document,
},
);
return state;
};
dispatchEvent 问题
我们也可以通过document.dispatchEvent
去模拟一些事件,比如说鼠标移动
但使用 dispatchEvent
无法模拟出具体的鼠标位置,如:
const moveMosuse = (x: number, y: number) => {
act(() => {
document.dispatchEvent(
new MouseEvent('mousemove', {
clientX: x,
clientY: y,
screenX: x,
screenY: y,
}),
);
});
it('鼠标移动', async () => {
const { result } = renderHook(() => useMouse(container));
expect(result.current.pageX).toEqual(NaN);
expect(result.current.pageY).toEqual(NaN);
moveMosuse(210, 210);
console.log(result, '111')
});
但很惊奇的发现,获取不到结果:
第一反应是异步引起的,所以加入了waiteFor
,但watiFor
内也获取不到
renderHook
的 waitForNextUpdate
也获取不到(这里的renderHook
是@testing-library/react-hooks
)
找了半天也没有找到原因,最后的猜想是 document.dispatchEvent
是真实的DOM事件,而我们的环境是模拟的js-dom
,所以在Jest
中可能并没有实际的触发,所以导致获取不到(有知道原因的,麻烦在评论区留言告知~)
使用 fireEvent 解决
最终还是通过fireEvent
去模拟事件,达到测试效果,这里就不做过多的介绍,直接上下代码~
it('鼠标移动', async () => {
const { result } = renderHook(() => useMouse());
expect(result.current.pageX).toEqual(NaN);
expect(result.current.pageY).toEqual(NaN);
fireEvent.mouseMove(document, {
clientX: 50,
clientY: 70,
screenX: 50,
screenY: 70,
});
expect(result.current.clientX).toEqual(50);
expect(result.current.clientY).toEqual(70);
expect(result.current.screenX).toEqual(50);
expect(result.current.screenY).toEqual(70);
});
总结
环境问题
jest
默认的环境为node
,我们测试hooks
的环境是浏览器环境,所以我们需要设置"testEnvironment": "jsdom"
renderHook 的问题
在上述的例子中,直接从@testing-library/react
拿出的,这是因为@testing-library/react@13.1.0
以上,把renderHook
内置了
并且这个版本,必须要配合 react18一起使用才行
如果你的react
版本在18版本以下,可以单独使用 @testing-library/react-hooks
测试Dom的方法
在本文中主要讲解了两种方式来模拟DOM
元素,分别是利用document.createElement
和@testing-library/react
中的render
实际上两种方式不太相同,render
的方式更加像测试组件的方法,并且两者的文件名不同,分别是ts
和tsx
其次,我们应该善用模拟的数据来进行测试,总的来说,还是应该多加练习
调试bug
我们在写测试用例的时候,可能会出现各种各样的问题,我们需要打印些数据来帮助我们(如一开始的result
),原本的cli并不会打印出console
,我们需要在命令行上加入--debug
,就ok了,如:npx jest --debug
可以直接使用vscode
的插件,也是种不错的选择~
End
关于 Hooks
和Jest
的同款文章可以看看, 助你玩转React
:
花一个小时,迅速掌握Jest的全部知识点~
搞懂这12个Hooks,保证让你玩转React
参考
Testing Library
结语
本文讲解如何通过Jest
测试自定义hooks
,合理的利用renderHook
,利用render
或document.createElement
创建dom
元素,通过fireEvent
去模拟事件,相信在测试hooks
就足够了
通过本文的介绍,可以看出Jest
是一个非常大的模块,掌握的秘诀还是多加练习,有感兴趣的同学可以自己尝试尝试
如果想学习更多H5游戏, webpack,node,gulp,css3,javascript,nodeJS,canvas数据可视化等前端知识和实战,欢迎在《趣谈前端》加入我们的技术群一起学习讨论,共同探索前端的边界。
基于Koa + React + TS从零开发全栈文档编辑器(进阶实战
点个在看你最好看