前言
在之前的一期博客中已经有介绍过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也是符合预期的
当然如果对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
我们新增的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也是运行通过
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);
});
简单走读就是定义一个方法,断言在组件卸载的时候有调用我们的方法,看下运行结果
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);
});
思路就是先断言初始化的值,然后在下一个更新的时候断言新的值,我们看下运行结果
这里得记住一个点,这里有个超时时间,默认是1s的,我们的promise是设置了500ms,这时候返回是没有问题,如果超过了1s的话就需要自行设置timeout,我们的promise改为1100ms之后看下结果
这时候看到有明显的提示说超时报错,这时候我们可以设置一下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
看下结果
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)—— 自动化测试