使用 TDD 创建 React 自定义钩子

2025-05-27

使用 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();
   });
});
Enter fullscreen mode Exit fullscreen mode

我们的测试当然会失败。我会写最少的代码来满足它。

const usePagination = () => {
   return {};
};

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

我还没有用 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');
   });
Enter fullscreen mode Exit fullscreen mode

目前没有抛出任何错误。我会把它添加到钩子的代码中。我决定让钩子以对象格式接收它的参数,因此:

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error('The UsePagination hook must receive a totalPages argument for it to work');
   }
   return {};
};

export default usePagination;
Enter fullscreen mode Exit fullscreen mode

测试运行了,但出了点问题。我写的第一个测试现在失败了,因为我没有传入 totalPages 参数,所以它抛出了错误。我会修复这个问题:

it('should exist', () => {
       const result = usePagination({totalPages: 10});
       expect(result).toBeDefined();
   });
Enter fullscreen mode Exit fullscreen mode

太好了。现在我们稍微重构一下。我不喜欢把错误字符串写成那样,而应该把它写成一个可以共享的常量,这样就能确保测试始终与钩子保持一致。重构很简单:

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;
Enter fullscreen mode Exit fullscreen mode

我的测试可以使用它:

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);
   });
});
Enter fullscreen mode Exit fullscreen mode

还有其他强制参数需要验证吗?没有,我想就是这个了。

接下来,我想测试一下这个钩子是否返回了 totalPages。这里我开始使用 renerHook 方法来确保我的钩子能够像在“现实世界”中一样运行:

it('should return the totalPages that was given to it', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10}));
       expect(result.current.totalPages).toEqual(10);
   });
Enter fullscreen mode Exit fullscreen mode

测试失败,因此我们编写代码来修复它:

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }
   return {totalPages};
};
Enter fullscreen mode Exit fullscreen mode

注意:我在这里跳过了一步,因为满足测试的最小代码将返回硬编码的 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);
   });
Enter fullscreen mode Exit fullscreen mode

通过这个测试的代码很简单。我会从钩子中返回一个硬编码的 0 作为游标 ;)

const usePagination = ({totalPages} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }
   return {totalPages, cursor: 0};
};
Enter fullscreen mode Exit fullscreen mode

但是我们还有另一个要求,即“当钩子接收到一个游标时,它应该返回该游标,而不是默认值”:

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);
   });
Enter fullscreen mode Exit fullscreen mode

显然,测试失败了,因为我们返回的是硬编码的 0。这是我调整代码使其通过的方法:

const usePagination = ({totalPages, cursor} = {}) => {
   if (!totalPages) {
       throw new Error(NO_TOTAL_PAGES_ERROR);
   }

   cursor = cursor || 0;

   return {totalPages, cursor};
};
Enter fullscreen mode Exit fullscreen mode

目前来说还好。

这个钩子必须返回一些方法。目前我们只测试它是否返回了这些方法,并且不打算调用它们:

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');
   });
Enter fullscreen mode Exit fullscreen mode

满足该要求的代码如下:

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};
};
Enter fullscreen mode Exit fullscreen mode

我们的自定义钩子的框架已经准备好了。现在我需要开始将钩子的逻辑添加到其中。

我先从最简单的逻辑开始,即使用 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);
       });
   });
Enter fullscreen mode Exit fullscreen mode

注意:为了获得更好的顺序和可读性,我将其创建在嵌套的“描述”中。

测试失败了!如果我尝试做一些简单的操作,比如在钩子的 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};
};
Enter fullscreen mode Exit fullscreen mode

这需要一些解释——首先,我将游标参数名称改为 initialCursor,这样它就不会与 useState 返回的变量冲突。其次,我删除了我自己的 setCursor 方法,并公开了从 useState hook 返回的 setCursor 方法。

再次运行测试,最后一个测试通过了,但第一个和第五个测试都失败了。第五个测试失败是因为我传递了“cursor”而不是“initialCursor”,而第一个测试失败是因为“无效的钩子调用。钩子只能在函数组件的主体内调用”,所以我们需要用 renderHook() 包装它,现在它看起来像这样:

it('should exist', () => {
       const {result} = renderHook(() => usePagination({totalPages: 10}));
       expect(result.current).toBeDefined();
   });
Enter fullscreen mode Exit fullscreen mode

除此之外,我们再添加一个测试,检查游标是否超出了总页数的界限。以下是两个测试,分别用于检查:

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);
       });
Enter fullscreen mode Exit fullscreen mode

哇...这里的挑战是 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});
   };
Enter fullscreen mode Exit fullscreen mode

我的 reducer 函数位于钩子函数外部,如下所示(不用担心,我会将整个代码粘贴在文章底部):

function reducer(state, action) {
   let result = state;

   if (action.value > 0 && action.value < action.totalPages) {
       result = action.value;
   }

   return result;
}
Enter fullscreen mode Exit fullscreen mode

这里没有 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);
       });
   });
Enter fullscreen mode Exit fullscreen mode

以下是使其通过的代码:

const goNext = () => {
       const nextCursor = cursor + 1;
       setCursor(nextCursor);
   };
Enter fullscreen mode Exit fullscreen mode

但这还没完。我想确保当我们到达最后一页时, 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);
       });
Enter fullscreen mode Exit fullscreen mode

我很高兴状态减速器内部的逻辑解决了这个问题:)
我将对 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);
       });
   });
Enter fullscreen mode Exit fullscreen mode

为此,我将使用useEffect 钩子来监视光标状态的变化,当这些变化发生并且定义了回调时,钩子将使用当前光标作为参数来调用它:

useEffect(() => {
       onChange?.(cursor);
   }, [cursor]);
Enter fullscreen mode Exit fullscreen mode

但我们还没完。我怀疑回调处理程序也会在钩子初始化时被调用,这是错误的。我将添加一个测试来确保这种情况不会发生:

it('should not be invoked when the hook is initialized', () => {
           const onChangeSpy = jest.fn();
           renderHook(() => usePagination({totalPages: 5, onChange: onChangeSpy}));

           expect(onChangeSpy).not.toHaveBeenCalled();
       });
Enter fullscreen mode Exit fullscreen mode

正如我所料,测试失败了。为了确保钩子初始化时不会调用 onChange 处理程序,我将使用一个标志来指示钩子是否正在初始化,并且仅在未初始化时调用该处理程序。为了在渲染过程中持久化它,但不会在它发生变化时强制进行新的渲染(就像状态变化一样),我将使用 useRef 钩子:

const isHookInitializing = useRef(true);

   useEffect(() => {
       if (isHookInitializing.current) {
           isHookInitializing.current = false;
       } else {
           onChange?.(cursor);
       }
   }, [cursor]);
Enter fullscreen mode Exit fullscreen mode

好了,一个完全使用 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;
Enter fullscreen mode Exit fullscreen mode

与往常一样,如果您对如何改进这一点或任何其他技术有任何想法,请务必与我们分享!

干杯

嘿!如果你喜欢刚才读到的内容,可以在推特上关注@mattibarzeev 🍻

照片由Todd QuackenbushUnsplash上拍摄

文章来源:https://dev.to/mbarzeev/creating-a-react-custom-hook-using-tdd-2o
PREV
明智地设计你的 React 组件
NEXT
如何记录一切