前端单测学习(11)—— react hook 进阶

757 阅读9分钟

前言

在之前的一期博客中已经有介绍过react hook相关的单测,不过只是简单的介绍了一下,简单写了一个例子,笔者最近在写hook相关的单测的时候也学习补充了一些新的知识,这里对在之前react hook的单测基础上继续一些进阶的学习。

renderHook 参数

在之前的博客中我们有用到renderHook,这个应该是我们在写hook单测的时候肯定会用到的,但其实renderHook还有其他参数,函数类型声明如下,这次我们就来看看其他参数的应用场景

function renderHook(callback: (props?: any) => any, options?: RenderHookOptions): RenderHookResult

initialProps

这个参数由参数名就大概可以猜出具体用途,就是用于hook的初始化参数,如上面的函数类型声明可知,第一个参数是callback,用于渲染hook,在上篇博客中,我们是显式地传参,就是直接写在Hook调用中,没有用到props,实际上callback是支持props的,这个props就是从initialProps传过来的。

这里我们单独写一个hook,用于后面的一些测试学习
src/hooks目录下新建一个useCounter.ts的文件,具体内容如下:

import { useState } from "react";

export interface UseCounterProp {
    initCount?: number;
}

export function useCounter({ initCount = 0 }: UseCounterProp) {
    const [count, setCount] = useState(initCount);

    function add(value: number) {
        setCount(count + value);
    }

    function minus(value: number) {
        setCount(count - value);
    }

    return {
        count,
        add,
        minus,
    };
}

还是老样子我们简单走读一下,就是一个简单计数器的hook,用state来记录当前数据,这里加了一个initCount的参数来初始化state,用0来兜底。
我们针对这个initCount来做一下单测,在src/hooks/__tests__目录下新建一个useCounter.test.ts文件作为我们针对这个hook的单测文件

import { renderHook } from "@testing-library/react-hooks";
import { useCounter, UseCounterProp } from "../useCounter";


describe('hook useCounter', () => {
    it('should return init Value', () => {
        const initialProps: UseCounterProp = {
            initCount: 9
        };
        const { result } = renderHook(props => useCounter(props), {
            initialProps
        });
        expect(result.current.count).toBe(initialProps.initCount);
    });
});

还是简单走读一下我们的单测代码,这里我们是用到了initialProps这个参数来设置初始化参数,然后通过props传给useCounter这个hook,断言初始值和我们设置的这个值是一样的
运行下看看结果如何

pnpm test src/hooks/__tests__/useCounter.test.ts

单测运行成功,case也是符合预期的 image.png
当然如果对props没有太多要求的话,直接在useCounter的时候传入变量也可以,如下

it('init Value should equal init prop value', () => {
    const initialProps: UseCounterProp = {
        initCount: 9
    };
    const { result } = renderHook(() => useCounter(initialProps));
    expect(result.current.count).toBe(initialProps.initCount);
});

wrapper

这个主要是给我们的Hook提供一个包裹的React组件,例如我们的Hook依赖的context,除了可以用mock的方式来处理这些context依赖之外,还可以考虑用wrapper来提供搞一个类似的context组件,达到模拟上下文的作用。

我们先来写一个context,用于模拟上面的这种情况
src/context下新建一个counter-context.tsx,提供一个变量作为上下文

import { createContext, FC, PropsWithChildren, useState } from "react";

interface CounterContextProp {
    baseNum: number;
    setBaseNum: (value: number) => void;
}

export const CounterContext = createContext<CounterContextProp>({
    baseNum: 1,
    setBaseNum: (_value) => {}
});

export const CounterContextWrapper: FC<PropsWithChildren<any>> =  (children) => {
    const [baseNum, setBaseNum] = useState(1);
    return (
        <CounterContext.Provider value={{ baseNum, setBaseNum }}>
            {children}
        </CounterContext.Provider>
    );
}

简单走读一下,这里就是最常见的createContext,然后我们提供一个CounterContextWrapper组件提供value
在业务组件中我们只需要用CouterContextWrapper包裹一下即可使用useContext获到这个上下文,例如下面这种

<CounterContextWrapper>
  <Card title={<CardHeader {...rest} />} style={{ width: 400 }}>
    <span>{content}</span>
  </Card>
</CounterContextWrapper>

有了context,我们也尝试在上面的useCounter里面也用一下,增加一个方法

export function useCounter({ initCount = 0 }: UseCounterProp) {
    const { baseNum } = useContext(CounterContext);
    ...
    function addWithBase(value: number) {
        setCount(count + baseNum * value);
    }
    ...
    
    return {
      ...
      addWithBase
    };
    
    
}

接下来我们就是要针对这个新增的方法来写一下单测,这里因为用到了context,我们可以针对context的上下文的值做一个wrapper

import { renderHook, WrapperComponent } from "@testing-library/react-hooks";

descript('xxxx', () => {
    it('should call addWithBase function as expect', () => {
        const baseNum = 10;
        const wrapper: WrapperComponent<{
            children: any;
          }> = ({ children }) => <CounterContext.Provider value={{ baseNum, setBaseNum: () => {} }}>{children}</CounterContext.Provider>;

        const { result } = renderHook(() => useCounter({}), {
            wrapper
        });
        act(() => {
            result.current.addWithBase(1)
        });
        expect(result.current.count).toBe(baseNum);
    });
});

还是简单走读一下,这里我们就是增加了一个wrapper,然后指定了我们的baseNum,后续就是调用方法,断言我们的最终值
运行一下看看效果

pnpm test src/hooks/__tests__/useCounter.test.ts

image.png

我们新增的case也是运行通过,当然,这里除了用wrapper之外,还可以用我们最常见的mock能力来做这个baseNum的设置

renderHook Result

renderHook这个方法我们在上面和之前的博客中都有了解过,我们用到的基本上都是result.current,这里我们会针对renderHook返回的其他做讲解

rerender

function rerender(newProps?: any): void

这个方法顾名思义就是根据新的props来重新渲染hook,可以触发一些useEffect的事件或者重新计算的逻辑

我们还是用上面的useCounter这个Hook,增加一些逻辑

export function useCounter({ initCount = 0 }: UseCounterProp) {
    ...
     useEffect(() => {
        setCount(initCount ?? 0);
    }, [initCount]);
    ...

}

这里我们就是增加一个useEffect,根据initCount这个prop来执行一些逻辑,就是对prop的变更有依赖,我们需要针对这个逻辑写一个case

it('test rerender useCounter hook', () => {
    const initialProps: UseCounterProp = {
        initCount: 9
    };
    const { result, rerender } = renderHook(props => useCounter(props), {
        initialProps
    });
    expect(result.current.count).toBe(initialProps.initCount);
    rerender({ initCount: 10 });
    expect(result.current.count).toBe(10);
});

还是和之前的case差不多,只不过这一次我们用上了rerender,我们传入了一个新的prop,然后断言一下新的值是否符合预期
看下运行结果,可以看到我们的case也是运行通过

image.png

unmount

function unmount(): void

这个方法是在测试一些卸载组件之后的事件,比如我们在react中会在组件销毁的时候清除一些副作用或者做一些请求之类的,这个unmount就是用在这种情况

在原先的基础上增加一些清除副作用的操作

import { useContext, useEffect, useState } from "react";
import { CounterContext } from "../context/counter-context";

export interface UseCounterProp {
    initCount?: number;
    onDestroy?: () => void;
}

export function useCounter({ initCount = 0, onDestroy }: UseCounterProp) {
    const [count, setCount] = useState(initCount);
    const { baseNum } = useContext(CounterContext);

    useEffect(() => {
        setCount(initCount ?? 0);
        return () => onDestroy?.();
    }, [initCount, onDestroy]);

    function add(value: number) {
        setCount(count + value);
    }

    function addWithBase(value: number) {
        setCount(count + baseNum * value);
    }

    function minus(value: number) {
        setCount(count - value);
    }

    return {
        count,
        add,
        minus,
        addWithBase,
    };
}

在原先的基础上我们增加了一个onDestroy方法,在销毁的时候调用清除副作用
接下来就是测试这个清除副作用的测试用例

it('test umount function', () => {
    const onDestroy = jest.fn();
    const initialProps: UseCounterProp = {
        onDestroy,
    };
    const { unmount } = renderHook(() => useCounter(initialProps));
    unmount();
    expect(onDestroy).toBeCalledTimes(1);
});

简单走读就是定义一个方法,断言在组件卸载的时候有调用我们的方法,看下运行结果

image.png

waitForNextUpdate

function waitForNextUpdate(options?: { timeout?: number | false }): Promise<void>

返回一个下一次Hook重新渲染的promise方法,通常用于异步结果更新state的场景

我们增加一个异步的方法,用来更新数据,看下我们的改造后完整的hook

import { useContext, useEffect, useState } from "react";
import { CounterContext } from "../context/counter-context";

export interface UseCounterProp {
    initCount?: number;
    onDestroy?: () => void;
    // 是否通过请求初始化数据
    initUpdateByRequest?: boolean;
}

const mockRequest = function() {
    return new Promise<number>(resolve => {
        setTimeout(() => {
            resolve(5);
        }, 500);
    })
};

export function useCounter({ initCount = 0, onDestroy, initUpdateByRequest }: UseCounterProp) {
    const [count, setCount] = useState(initCount);
    const { baseNum } = useContext(CounterContext);

    useEffect(() => {
        setCount(initCount ?? 0);
        return () => onDestroy?.();
    }, [initCount, onDestroy]);

    useEffect(() => {
        if (initUpdateByRequest) {
            initByRequest();
        }
    }, [initUpdateByRequest]);

    async function initByRequest() {
        const value = await mockRequest();
        setCount(value);
    }

    function add(value: number) {
        setCount(count + value);
    }

    function addWithBase(value: number) {
        setCount(count + baseNum * value);
    }

    function minus(value: number) {
        setCount(count - value);
    }

    return {
        count,
        add,
        minus,
        addWithBase,
    };
}

主要改动是增加了一个initUpdateByRequest参数,如果是true的话我们就执行一个异步函数,500ms之后返回数据然后更新state
我们来写对应的case

it('test wait for next update', async () => {
    const initialProps: UseCounterProp = {
        initUpdateByRequest: true,
    };
    const { result, waitForNextUpdate } = renderHook(() => useCounter(initialProps));
    expect(result.current.count).toBe(0);
    await waitForNextUpdate();
    expect(result.current.count).toBe(5);
});

思路就是先断言初始化的值,然后在下一个更新的时候断言新的值,我们看下运行结果

image.png

这里得记住一个点,这里有个超时时间,默认是1s的,我们的promise是设置了500ms,这时候返回是没有问题,如果超过了1s的话就需要自行设置timeout,我们的promise改为1100ms之后看下结果

image.png

这时候看到有明显的提示说超时报错,这时候我们可以设置一下timeout的值,只要超过就不会报错

await waitForNextUpdate({ timeout: 1500 });

waitForValueToChange

返回一个promise,当指定的值变更之后返回,可以用在一些异步变更之后做断言测试

我们改一下hook

import { useContext, useEffect, useState } from "react";
import { CounterContext } from "../context/counter-context";

export interface UseCounterProp {
    initCount?: number;
    onDestroy?: () => void;
    // 是否通过请求初始化数据
    initUpdateByRequest?: boolean;
}

const mockRequest = function() {
    return new Promise<number>(resolve => {
        setTimeout(() => {
            resolve(5);
        }, 1100);
    })
};

export function useCounter({ initCount = 0, onDestroy, initUpdateByRequest }: UseCounterProp) {
    const [count, setCount] = useState(initCount);
    const [finishInitReqUpdate, setFinishInitReqUpdate] = useState(false);
    const { baseNum } = useContext(CounterContext);

    useEffect(() => {
        setCount(initCount ?? 0);
        return () => onDestroy?.();
    }, [initCount, onDestroy]);

    useEffect(() => {
        if (initUpdateByRequest) {
            initByRequest();
        }
    }, [initUpdateByRequest]);

    async function initByRequest() {
        const value = await mockRequest();
        setCount(value);
        setFinishInitReqUpdate(true);
    }

    function add(value: number) {
        setCount(count + value);
    }

    function addWithBase(value: number) {
        setCount(count + baseNum * value);
    }

    function minus(value: number) {
        setCount(count - value);
    }

    return {
        count,
        add,
        minus,
        addWithBase,
        finishInitReqUpdate,
    };
}

主要是增加了一个finishInitReqUpdate状态,记录初始化请求是否结束,我们针对这个属性写一下case

it('test wait for value to change', async () => {
    const initialProps: UseCounterProp = {
        initUpdateByRequest: true,
    };
    const { result, waitForValueToChange } = renderHook(() => useCounter(initialProps));
    expect(result.current.count).toBe(0);
    await waitForValueToChange(() => result.current.finishInitReqUpdate, {
        timeout: 1500
    });
    expect(result.current.count).toBe(5);
});

我们断言finishInitReqUpdate值变化之后就开始才继续执行后续逻辑,记住,这里也可以设置timeout参数,还要interval参数,这里就只试下timeout
看下结果

image.png

cleanup

清除卸载hook渲染的相关东西,相当于清空副作用,实际上jest框架也会主动在afterEach生命周期做,所以实际上笔者基本上没有去主动使用cleanup相关的能力,这里就不展开细讲。

结尾

react hook的单测的技巧其实不多,通过今天的几个常见的方法学习我们基本也覆盖到了大部分的场景,配合上其他jest的能力就可以满足hook相关的单测

传送门

前端单测学习(1)—— 单测入门之react单测项目初步
前端单测学习(2)—— react 组件单测初步
前端单测学习(3)—— react组件单测进阶
前端单测学习(4)—— react 组件方法&fireEvent
前端单测学习(5)—— 快照
前端单测学习(6)—— 定时器
前端单测学习(7)—— mock
前端单测学习(8)—— react hook
前端单测学习(9)—— 覆盖率报告
前端单测学习(10)—— 状态管理redux
前端单测学习(11)—— react hook 进阶
前端单测学习(12)—— 性能优化
前端单测学习(13)—— 自动化测试

代码仓库:github.com/liyixun/rea…