「React 深入」一文玩转React Hooks的单元测试
大家好,我是小杜杜,俗话说得好,光说不练假把式,我始终认为实践才是最好的老师,上面一章我们已经详细的了解了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测试自定义hooks,合理的利用renderHook,利用render或document.createElement创建dom元素,通过fireEvent去模拟事件,相信在测试hooks就足够了
通过本文的介绍,可以看出Jest是一个非常大的模块,掌握的秘诀还是多加练习,有感兴趣的同学可以自己尝试尝试
这个专栏会不断记录有关React的文章,以进阶为目的,详细讲解React相关的原理、源码、实战,有感兴趣的可以关注下,一起学习,一起进步~
转载自:https://juejin.cn/post/7152709386752753701