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

4,660 阅读12分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

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

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

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

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

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)
  })
}) 

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

这是因为在useCounter中,我们运用了useState,而React规定只有在组件中才能使用Hooks所以会报如下错误,我们可以通过renderHookact解决这个问题

renderHook 和 act

renderHook

renderHook:顾名思义,这个函数就是用来渲染hooks,它会帮助我们解决Hooks只能在组件中使用的问题(生成一个专门用来测试的TestComponent)

用法:

function renderHook<Result, Props>(
    render: (props: Props) => Result,
    options?: RenderHookOptions<Props>,
): RenderHookResult<Result, Props>

入参

  • rendercallBack 函数,这个函数会在TestComponent每次被重新渲染的时候调用,所以这个函数放入我们想测试的Hooks就行
  • options: 可选的options,有两个属性,分别是initialPropswrapper

options 的参数:

  • initialPropsTestComponent初始的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-utilsact函数是同一个函数,通过这个函数,我们可以将所有会更新到组件状态的操作 封装在它的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)
  })
}) 

结果:

image.png

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

image.png

实战演练

useEventListener

上述的例子中,我们已经了解了renderHookresult,接下来我们来看看renderunmount的用法。

在之前我们详细讲过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. 通过beforeEachafterEach创建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,我们可以打开这个页面,看到对应的数据,如:

image.png

对应的代码为:

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);
  });

覆盖率报告

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

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;

效果:

img6.gif

render、fireEvent 测试

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

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

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

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: 按数据测试查找属性

一般而言,会用到getByTextgetByRole来获取对应的元素

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.jsx

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')

  });

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

image.png

第一反应是异步引起的,所以加入了waiteFor,但watiFor内也获取不到

renderHookwaitForNextUpdate也获取不到(这里的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的方式更加像测试组件的方法,并且两者的文件名不同,分别是tstsx

其次,我们应该善用模拟的数据来进行测试,总的来说,还是应该多加练习

调试bug

我们在写测试用例的时候,可能会出现各种各样的问题,我们需要打印些数据来帮助我们(如一开始的result),原本的cli并不会打印出console,我们需要在命令行上加入--debug,就ok了,如:npx jest --debug

可以直接使用vscode的插件,也是种不错的选择~

image.png

End

关于 HooksJest的同款文章可以看看, 助你玩转React

参考

结语

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

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

这个专栏会不断记录有关React的文章,以进阶为目的,详细讲解React相关的原理、源码、实战,有感兴趣的可以关注下,一起学习,一起进步~