大家好,俗话说得好,光说不练假把式,我始终认为实践才是最好的老师,上面一章我们已经详细的了解了​​Jest​​​相关概念,以及如何搭建一个简单的测试环境(花一个小时,迅速掌握Jest的全部知识点~),今天就来详细讲讲​​React Hooks​​的单元测试

在网上我们可以搜到很多​​React​​​的单元测试,但有关​​React Hooks​​​的单元测试却很少,或者说并不全面,所以今天就来详细讲讲有关​​React Hooks​​如何进行单元测试。

如果你希望做为一个开源的产品,那么你的代码必须具备单元测试,所以这是​​进阶React​​​的必经之路,所以本节内容通过具体的例子来讲解​​React Hooks​​,这样可以告别繁琐的知识点,又能融会贯通,岂不美哉?

跟以往一样,先来一张知识图,还请各位小伙伴多多支持~


「React 深入」一文玩转React Hooks的单元测试_js

hooks 单元测试.png

自定义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)
})
})

乍一看,这么测试并没有什么问题,接下来看看测试结果:

「React 深入」一文玩转React Hooks的单元测试_js_02

这是因为在​​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)
})
})

结果:


「React 深入」一文玩转React Hooks的单元测试_js_03

image.png

至于测试的报告,就看写的测试用例覆盖度了,当所有情况都涉及上就会显示​​100%​


「React 深入」一文玩转React Hooks的单元测试_javascript_04

image.png

实战演练

useEventListener

上述的例子中,我们已经了解了​​renderHook​​​的​​result​​​,接下来我们来看看​​render​​​和​​unmount​​的用法。

「React 深入」一文玩转React Hooks的单元测试_vue_05

在之前我们详细讲过​​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);
});
})

做个简单的解释:

  1. 通过​​beforeEach​​和​​afterEach​​创建​​DOM​​元素(container)并卸载
  2. 用​​renderHook​​监听对应的元素的点击事件,如果点击了,​​count + 1​
  3. 首先在​​body​​上进行点击,不应该触发​​click​​事件,​​count = 0​
  4. 然后点击​​container​​,触发​​click​​事件,​​count = 1​
  5. 通过​​rerender()​​将​​hooks​​重新渲染一遍,再点​​container​​,看看会不会有影响,此时会触发​​click​​事件,​​count = 2​
  6. 最后​​unmount​​卸载函数,再点击 ​​container​​,此时已经卸载,所以无法出发,触发​​click​​事件,​​count​​ 应该等于​​2​

覆盖率报告

之后我们可以看一下覆盖率报告:

文件位置:​​coverage/lcov-report/index.html​​,我们可以打开这个页面,看到对应的数据,如:


「React 深入」一文玩转React Hooks的单元测试_vue_06

image.png

对应的代码为:


「React 深入」一文玩转React Hooks的单元测试_javascript_07

image.png

其中标红的代表未执行的语句(在​​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);
});

覆盖率报告

第三个也是同理,就不列举了,只要全部覆盖到就测试完毕了,如:


「React 深入」一文玩转React Hooks的单元测试_可视化_08

image.png

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;

效果:


「React 深入」一文玩转React Hooks的单元测试_javascript_09

img6.gif

render、fireEvent 测试

我们在测试​​useEventListener​​​的时候通过​​document.createElement​​​创建元素,除了这种方式,我们可以通过测试组件的方式来测试,这里使用​​'@testing-library/react'​​测试

可能有许多小伙伴喜欢用​​enzyme​​​做单元测试,但​​enzyme​​​测试也有很多问题(如:组件触发后,但触发后不能改变组件​​useState​​​的值),所以还是使用官方推荐的​​'@testing-library/react'​​测试比较好

在这里主要介绍下 ​​'@testing-library/react'​​​ 的​​render​​​和​​fireEvent​​​的方法,掌握这两个,一般的单元测试就​​OK​​了

「React 深入」一文玩转React Hooks的单元测试_单元测试_10

render

​render​​​主要返回三类分别是:​​getBy...​​​、​​queryBy...​​​、​​findBy...​

  • ​getBy...​​​:用于定位页面​​已经存在​​的DOM元素,如果不存在,则抛出异常
  • ​queryBy...​​​:用于定位页面​​不存在​​的DOM元素,如果不存在,则返回null,不会抛出异常
  • ​findBy...​​​:定位页面中的​​异常元素​​,如果不存在,则抛出异常

三者的方法都一样,这里以​​getBt...​​为例:

  1. getByText: 按元素查找文本内容
  2. getByRole: 按角色去查找
  3. getByLabelText: 按标签或aria标签文本内容查找
  4. getByPlaceholderText: 按输入placeholder查找
  5. getByAltText: 按img的alt属性查找
  6. getByTitle: 按标题属性或svg标题标记查找
  7. getByDisplayValue: 按表单元素查找当前值
  8. 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')

});

但很惊奇的发现,获取不到结果:


「React 深入」一文玩转React Hooks的单元测试_vue_11

image.png

第一反应是异步引起的,所以加入了​​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​​的插件,也是种不错的选择~


「React 深入」一文玩转React Hooks的单元测试_js_12

image.png

End

关于 ​​Hooks​​​ 和​​Jest​​​的同款文章可以看看, 助你玩转​​React​​:

  • 花一个小时,迅速掌握Jest的全部知识点~
  • 搞懂这12个Hooks,保证让你玩转React

参考

  • Testing Library

结语

本文讲解如何通过​​Jest​​​测试自定义​​hooks​​​,合理的利用​​renderHook​​​,利用​​render​​​或​​document.createElement​​​创建​​dom​​​元素,通过​​fireEvent​​​去模拟事件,相信在测试​​hooks​​就足够了

通过本文的介绍,可以看出​​Jest​​是一个非常大的模块,掌握的秘诀还是多加练习,有感兴趣的同学可以自己尝试尝试

如果想学习更多H5游戏webpacknodegulpcss3javascriptnodeJScanvas数据可视化等前端知识和实战,欢迎在《趣谈前端》加入我们的技术群一起学习讨论,共同探索前端的边界。