使用 TDD 创建 React 自定义钩子
在这篇文章中,我将与你一起创建一个 React Custom Hook,它将封装一个简单分页组件背后的逻辑。
分页组件允许用户在内容“页面”之间导航。用户可以在页面列表中上下移动,也可以直接跳转到他们想要的页面,例如:
(图片取自Material UI)
我从这个钩子的要求列表开始:
- 它应该接收总页数
- 它可以接收初始游标,但如果没有,则初始游标是第一个索引
- 它应该返回以下内容:
- 总页数
- 当前光标位置
- goNext() 方法用于进入下一页
- goPrev() 方法用于返回上一页
- setCursor() 方法将光标设置到特定索引
- 如果将“onChange”回调处理程序传递给钩子,它将在光标以当前光标位置作为参数改变时被调用
我创建了两个文件:UsePagination.js是我的自定义钩子,以及UsePagination.test.js用来测试它。我以监视模式启动 Jest,然后开始深入研究。
为了测试钩子逻辑,我将使用react-hooks-testing-library,它允许我测试钩子而无需用组件包装它。这使得测试更易于维护和集中。
首先,让我们确保有一个 UsePagination 自定义钩子:
import {renderHook, act} from '@testing-library/react-hooks';
import usePagination from './UsePagination';
describe('UsePagination hook', () => {
it('should exist', () => {
const result = usePagination();
expect(result).toBeDefined();
});
});
我们的测试当然会失败。我会写最少的代码来满足它。
const usePagination = () => {
return {};
};
export default usePagination;
我还没有用 react-hooks-testing-library 进行测试,因为我暂时不需要它。另外,我只需要编写最少的代码来让测试通过,仅此而已。
好的,接下来我想测试一下第一个需求。我意识到如果没有指定总页数,钩子就无法工作,所以我想在指定页数时抛出一个错误。让我们测试一下:
it('should throw if no total pages were given to it', () => {
expect(() => {
usePagination();
}).toThrow('The UsePagination hook must receive a totalPages argument for it to work');
});
目前没有抛出任何错误。我会把它添加到钩子的代码中。我决定让钩子以对象格式接收它的参数,因此:
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error('The UsePagination hook must receive a totalPages argument for it to work');
}
return {};
};
export default usePagination;
测试运行了,但出了点问题。我写的第一个测试现在失败了,因为我没有传入 totalPages 参数,所以它抛出了错误。我会修复这个问题:
it('should exist', () => {
const result = usePagination({totalPages: 10});
expect(result).toBeDefined();
});
太好了。现在我们稍微重构一下。我不喜欢把错误字符串写成那样,而应该把它写成一个可以共享的常量,这样就能确保测试始终与钩子保持一致。重构很简单:
export const NO_TOTAL_PAGES_ERROR = 'The UsePagination hook must receive a totalPages argument for it to work';
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
return {};
};
export default usePagination;
我的测试可以使用它:
import usePagination, {NO_TOTAL_PAGES_ERROR} from './UsePagination';
describe('UsePagination hook', () => {
it('should exist', () => {
const result = usePagination({totalPages: 10});
expect(result).toBeDefined();
});
it('should throw if no total pages were given to it', () => {
expect(() => {
usePagination();
}).toThrow(NO_TOTAL_PAGES_ERROR);
});
});
还有其他强制参数需要验证吗?没有,我想就是这个了。
接下来,我想测试一下这个钩子是否返回了 totalPages。这里我开始使用 renerHook 方法来确保我的钩子能够像在“现实世界”中一样运行:
it('should return the totalPages that was given to it', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(result.current.totalPages).toEqual(10);
});
测试失败,因此我们编写代码来修复它:
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
return {totalPages};
};
注意:我在这里跳过了一步,因为满足测试的最小代码将返回硬编码的 10 作为 totalPages,但在这种情况下它是多余的,因为这里的逻辑非常简单。
现在我想检查钩子是否返回了当前光标位置。我先从“如果它没有接收到光标位置作为参数,则应将其初始化为 0”这个要求开始:
it('should return 0 as the cursor position if no cursor was given to it
', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(result.current.cursor).toEqual(0);
});
通过这个测试的代码很简单。我会从钩子中返回一个硬编码的 0 作为游标 ;)
const usePagination = ({totalPages} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
return {totalPages, cursor: 0};
};
但是我们还有另一个要求,即“当钩子接收到一个游标时,它应该返回该游标,而不是默认值”:
it('should return the received cursor position if it was given to it', () => {
const {result} = renderHook(() => usePagination({totalPages: 10, cursor: 5}));
expect(result.current.cursor).toEqual(5);
});
显然,测试失败了,因为我们返回的是硬编码的 0。这是我调整代码使其通过的方法:
const usePagination = ({totalPages, cursor} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
cursor = cursor || 0;
return {totalPages, cursor};
};
目前来说还好。
这个钩子必须返回一些方法。目前我们只测试它是否返回了这些方法,并且不打算调用它们:
it('should return the hooks methods', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(typeof result.current.goNext).toEqual('function');
expect(typeof result.current.goPrev).toEqual('function');
expect(typeof result.current.setCursor).toEqual('function');
});
满足该要求的代码如下:
const usePagination = ({totalPages, cursor} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
cursor = cursor || 0;
const goNext = () => {};
const goPrev = () => {};
const setCursor = () => {};
return {totalPages, cursor, goNext, goPrev, setCursor};
};
我们的自定义钩子的框架已经准备好了。现在我需要开始将钩子的逻辑添加到其中。
我先从最简单的逻辑开始,即使用 setCursor 方法设置光标。我想调用它并检查光标是否真的发生了变化。我通过将要检查的操作包装在 act() 方法中来模拟 React 在浏览器中的运行方式:
describe('setCursor method', () => {
it('should set the hooks cursor to the given value
', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
act(() => {
result.current.setCursor(4);
});
expect(result.current.cursor).toEqual(4);
});
});
注意:为了获得更好的顺序和可读性,我将其创建在嵌套的“描述”中。
测试失败了!如果我尝试做一些简单的操作,比如在钩子的 setCursor 暴露方法中设置游标值,它仍然会失败,因为我的钩子无法持久化这个值。这里我们需要一些有状态的代码 :)
我将使用 useState 钩子来为钩子创建游标状态:
const usePagination = ({totalPages, initialCursor} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
const [cursor, setCursor] = useState(initialCursor || 0);
const goNext = () => {};
const goPrev = () => {};
return {totalPages, cursor, goNext, goPrev, setCursor};
};
这需要一些解释——首先,我将游标参数名称改为 initialCursor,这样它就不会与 useState 返回的变量冲突。其次,我删除了我自己的 setCursor 方法,并公开了从 useState hook 返回的 setCursor 方法。
再次运行测试,最后一个测试通过了,但第一个和第五个测试都失败了。第五个测试失败是因为我传递了“cursor”而不是“initialCursor”,而第一个测试失败是因为“无效的钩子调用。钩子只能在函数组件的主体内调用”,所以我们需要用 renderHook() 包装它,现在它看起来像这样:
it('should exist', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
expect(result.current).toBeDefined();
});
除此之外,我们再添加一个测试,检查游标是否超出了总页数的界限。以下是两个测试,分别用于检查:
it('should not set the hooks cursor if the given value is above the total pages', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
act(() => {
result.current.setCursor(15);
});
expect(result.current.cursor).toEqual(0);
});
it('should not set the hooks cursor if the given value is lower than 0', () => {
const {result} = renderHook(() => usePagination({totalPages: 10}));
act(() => {
result.current.setCursor(-3);
});
expect(result.current.cursor).toEqual(0);
});
哇...这里的挑战是 useState 不允许我在它返回的 setCursor 方法中运行某些逻辑。
更新:以下操作也可以使用 useState 来实现,但仍然不够优雅。请参阅此主题以及hooks 包中的更新代码。
我能做的就是把它转换成useReducer hook。随着代码的演进,这有点取消了我最近对 setCursor 方法的操作:
const SET_CURSOR_ACTION = 'setCursorAction';
...
const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);
const setCursor = (value) => {
dispatch({value, totalPages});
};
我的 reducer 函数位于钩子函数外部,如下所示(不用担心,我会将整个代码粘贴在文章底部):
function reducer(state, action) {
let result = state;
if (action.value > 0 && action.value < action.totalPages) {
result = action.value;
}
return result;
}
这里没有 case,所以其实也不需要 switch-case 语句。
很好。所有测试都通过了,我们可以继续了。
接下来是从钩子中暴露出来的 goNext() 方法。我希望它首先移动到下一个光标位置:
describe('goNext method', () => {
it('should set the hooks cursor to the next value', () => {
const {result} = renderHook(() => usePagination({totalPages: 2}));
act(() => {
result.current.goNext();
});
expect(result.current.cursor).toEqual(1);
});
});
以下是使其通过的代码:
const goNext = () => {
const nextCursor = cursor + 1;
setCursor(nextCursor);
};
但这还没完。我想确保当我们到达最后一页时, goNext() 将不再影响光标位置。以下是测试代码:
it('should not set the hooks cursor to the next value if we reached the last page', () => {
const {result} = renderHook(() => usePagination({totalPages: 5, initialCursor: 4}));
act(() => {
result.current.goNext();
});
expect(result.current.cursor).toEqual(4);
});
我很高兴状态减速器内部的逻辑解决了这个问题:)
我将对 goPrev 方法执行相同的操作。
好了,我们已经讲解了这两个方法,现在我们来实现钩子的回调处理函数。当我们将回调处理函数传递给钩子时,它应该在光标发生变化时被调用,无论是通过移动 next/prev 还是显式设置。
以下是它的测试代码:
describe('onChange callback handler', () => {
it('should be invoked when the cursor changes by setCursor method', () => {
const onChangeSpy = jest.fn();
const {result} = renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));
act(() => {
result.current.setCursor(3);
});
expect(onChangeSpy).toHaveBeenCalledWith(3);
});
});
为此,我将使用useEffect 钩子来监视光标状态的变化,当这些变化发生并且定义了回调时,钩子将使用当前光标作为参数来调用它:
useEffect(() => {
onChange?.(cursor);
}, [cursor]);
但我们还没完。我怀疑回调处理程序也会在钩子初始化时被调用,这是错误的。我将添加一个测试来确保这种情况不会发生:
it('should not be invoked when the hook is initialized', () => {
const onChangeSpy = jest.fn();
renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));
expect(onChangeSpy).not.toHaveBeenCalled();
});
正如我所料,测试失败了。为了确保钩子初始化时不会调用 onChange 处理程序,我将使用一个标志来指示钩子是否正在初始化,并且仅在未初始化时调用该处理程序。为了在渲染过程中持久化它,但不会在它发生变化时强制进行新的渲染(就像状态变化一样),我将使用 useRef 钩子:
const isHookInitializing = useRef(true);
useEffect(() => {
if (isHookInitializing.current) {
isHookInitializing.current = false;
} else {
onChange?.(cursor);
}
}, [cursor]);
好了,一个完全使用 TDD 创建的自定义钩子就完成了 :)
挑战一下自己——看看你是否可以使用 TDD 实现分页的循环模式(例如,一旦到达末尾就会回到开头)🤓
以下是完整的钩子代码:
import {useEffect, useReducer, useRef, useState} from 'react';
export const NO_TOTAL_PAGES_ERROR = 'The UsePagination hook must receive a totalPages argument for it to work';
const usePagination = ({totalPages, initialCursor, onChange} = {}) => {
if (!totalPages) {
throw new Error(NO_TOTAL_PAGES_ERROR);
}
const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);
const setCursor = (value) => {
dispatch({value, totalPages});
};
const goNext = () => {
const nextCursor = cursor + 1;
setCursor(nextCursor);
};
const goPrev = () => {
const prevCursor = cursor - 1;
setCursor(prevCursor);
};
const isHookInitializing = useRef(true);
useEffect(() => {
if (isHookInitializing.current) {
isHookInitializing.current = false;
} else {
onChange?.(cursor);
}
}, [cursor]);
return {totalPages, cursor, goNext, goPrev, setCursor};
};
function reducer(state, action) {
let result = state;
if (action.value > 0 && action.value < action.totalPages) {
result = action.value;
}
return result;
}
export default usePagination;
与往常一样,如果您对如何改进这一点或任何其他技术有任何想法,请务必与我们分享!
干杯
嘿!如果你喜欢刚才读到的内容,可以在推特上关注@mattibarzeev 🍻
照片由Todd Quackenbush在Unsplash上拍摄
文章来源:https://dev.to/mbarzeev/creating-a-react-custom-hook-using-tdd-2o