dev.toMatti Bar-Zeev 님이 작성한 Creating a React Custom Hook using TDD 번역 자료입니다. 번역에 문제가 있다면 댓글 달아주시구요. 원문을 보시기를 추천드립니다

이 포스트에서 간단한 페이지네이션 컴포넌트의 로직을 캡슐화하는 리엑트 커스텀 훅(Hook)을 만들겠습니다.

페이지네이션 컴포넌트는 사용자가 콘텐츠 “페이지”를 탐색하게 하는 컴포넌트입니다. 사용자는 페이지 목록을 앞뒤로 이동할 수 있고 다음과 같이 원하는 페이지로 직접 이동할 수도 있습니다.:

Image description

이 훅에 대한 요구 사항 리스트에서 시작합니다.:

  • 총 페이지수를 받아야 합니다.
  • 초기 커서를 받을 수 있지만 그렇지 않은 경우 초기 커서가 첫 번째 인덱스입니다.
  • 다음을 반환해야 합니다. :
    • 총 페이지 수
    • 현재 커서 위치
    • 다음 페이지로 이동하기 위한 goNext() 메서드
    • 이전 페이지로 이동하기 위한 goPrev() 메서드
    • 커서를 특정 인덱스로 설정하는 setCursor() 메서드
  • “onChange” 콜백 핸들러가 훅에 전달되면 현재 커서 위치를 인수로 사용하여 커서가 변경될 때 호출됩니다.

2개의 파일을 만듭니다.: 커스텀 훅이 되는 UsePagination.js와 테스트인 UsePagination.test.js입니다. Jest를 워치 모드로 실행하고 뛰어드시죠.

react-hooks-testing-library를 사용하여 컴포넌트로 감싸지 않고도 훅을 테스트할 수 있습니다. 테스트를 훨씬 더 쉽게 유지(maintain)하고 집중할 수 있습니다.

우선 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로 테스트하지 않고 있습니다. 아직 그럴 필요가 없기 때문입니다. 또한 테스트 통과를 위해 최소한의 코드를 작성하고 있다는 것을 기억하세요.

오케이, 다음으로 첫 번째 요구 사항을 테스트하고 싶습니다. 총 페이지(totalPages)가 주어지지 않으면 훅이 작동하지 않는다는 것을 알고 있기에 총 페이지 수가 주어지지 않으면 오류를 던지고 싶습니다. 다음을 테스트해 보겠습니다.:

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

지금은 오류가 발생하지 않습니다. 훅에 코드에 추가하겠습니다. 훅이 객체 형식의 인수(args)를 수신하도록 결정하고 다음을 수행합니다.:

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를 통과하지 못했고 오류를 던졌기(throw) 때문입니다. 고쳐보겠습니다.:

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

NOTE: 테스트를 충족하기 위한 최소 코드가 totalPages로 하드 코딩된 10을 반환하기 때문에 한 걸음 나아갔지만 로직은 정말 간단하고 이 경우 중복됩니다.

이제 훅이 현재 커서 위치를 반환하는지 확인하고 싶습니다. “커서 위치를 인수로 받지 않았다면 0으로 초기화해야 합니다 (should return 0 as the cursor position if no cursor was given to it)”라는 요구 사항부터 시작하겠습니다. :

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

그러나 “훅이 커서를 수신할 때 기본값이 아닌 해당 커서를 반환해야 합니다(should return the received cursor position if it was given to it)”라는 또 다른 요구 사항이 있습니다.

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

일단 좋습니다.

훅에서 몇 가지 메서드를 반환해야 합니다. 지금은 그 메서드의 호출(invoking) 의도 없이 메서드를 반환하는지 테스트할 뿐입니다.

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

NOTE: 더 나은 순서와 가독성을 위해 중첩된 “describe”로 만들었습니다.

그리고 테스트는 실패합니다! 훅의 setCursor 노출 메서드에서 커서 값을 설정하는 것과 같은 간단한 작업을 시도하면 훅이 이 값을 유지하지 못하기 때문에 여전히 작동하지 않습니다. 여기에 상태 저장(stateful) 코드가 필요합니다. :) 훅에 대한 커서 상태(cursor state)를 만들기 위해 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};
};

여기에는 몇 가지 설명이 필요합니다. 우선 useState 리턴 변수와 충돌하지 않도록 커서 인수(arg) 이름을 initialCursor로 변경했습니다. 두 번째로 내 setCursor 메서드를 제거하고 useState 훅에서 반환되는 setCursor 메서드를 노출(exposed)했습니다.

테스트를 다시 실행하고 마지막 테스트는 통과하지만 첫 번째와 다섯 번째 테스트는 모두 실패합니다. 다섯 번째는 “initialCursor”가 아닌 “cursor”를 전달하기 때문에 실패하고 첫 번째는 “Invalid hook call”에 대해 실패합니다. 훅 함수 컴포넌트의 내부에서만 호출할 수 있습니다.” 따라서 renderHook()으로 감싸야 합니다. 이제 이렇게 보입니다.

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

여기에 총 페이지 수 범위를 벗어나는 커서는 설정할 수 없다는 것을 확인하는 테스트를 추가합시다. 다음은 이를 확인하는 2가지 테스트입니다.

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 메서드에서 일부 로직을 실행할 수 없다는 것입니다. 내가 할 수 있는 것은 이것을 useReducer 훅으로 변환하는 것입니다. 코드가 진화함에 따라 최근에 setCursor 메서드로 수행한 작업을 취소합니다.

const SET_CURSOR_ACTION = 'setCursorAction';
...

const [cursor, dispatch] = useReducer(reducer, initialCursor || 0);

   const setCursor = (value) => {
       dispatch({value, totalPages});
   };

그리고 리듀서 함수는 다음과 같이 훅 함수의 외부에 있습니다 (걱정하지 마세요. 전체 코드를 이 포스트 하단에 붙여넣을 것입니다) :

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

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

   return result;
}

여기에는 케이스가 없으므로 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);
       });

다행스럽게도 상태 리듀서(state reducer) 내부의 로직이 이를 처리합니다. :) 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 hook을 사용합니다.

const isHookInitializing = useRef(true);

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

우리는 가지고 있습니다. TDD를 사용하여 만든 커스텀 훅 :)

도전하세요 - TDD를 사용하여 페이지네이션을 위한 순환 모드(cyclic mode)를 구현할 수 있는지 확인해보세요 (예: 끝에 도달하면 처음으로 돌아갑니다) 🤓

완전한 훅 코드는 다음과 같습니다.

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;

항상 그렇듯이 이 방법이나 다른 기술을 개선하는 방법에 대한 아이디어가 있으면 나머지 사람들과 공유하십시오!

치얼스

이봐! 방금 읽은 내용이 마음에 든다면 Twitter에서 @mattibarzeev를 확인하세요 🍻