세상에 나쁜 테스트는 없다.
그런 테스트를 작성하게 하는 나쁜 코드만 있다.

-세나테-


useInterval.ts, useInterval.test.ts 는 다음의 링크에서 확인하실 수 있습니다.
https://gist.github.com/bigsaigon333/e72c71d316fabfb03c53702f85be7e97

0. useInterval hook

useInterval은 주기적으로 실행시킬 콜백함수와 몇 초 간격으로 실행시킬지를 나타내는 숫자(ms)를 인자로 받아, 이를 주기적으로 실행하는 역할을 수행한다.

useIntervalsignature는 다음과 같다.

type UseInterval = (callback: () => unknown, delay: number | null): void;

useInterval의 상세한 코드는 다음과 같다.

import { useEffect, useRef } from "react";

const useInterval = (callback: () => unknown, delay: number | null) => {
  const savedCallback = useRef(callback);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    if (delay == null) {
      return;
    }

    const timeId = setInterval(() => savedCallback.current(), delay);

    // eslint-disable-next-line consistent-return
    return () => clearInterval(timeId);
  }, [delay]);
};

export default useInterval;

※ 위의 코드는 최초에 작성 후 다음 링크의 코드를 참고로 하여 수정하였습니다. 결과적으로 다음 링크의 코드와 동일하게 되었습니다.
useInterval | useHooks(🔥).ts

1. useInterval hook을 테스트하기로 마음 먹었다.

작성할 수 없는 테스트는 존재하지 않고, 테스트 작성이 까다롭다면 테스트 대상 코드가 제대로 작성되었는지를 살펴봐야 한다. 주기적으로 background 에서 API call을 수행할 목적으로 만든 useInterval hook은 상당히 중요한 비즈니스 로직을 담당하게 되므로, 반드시 테스트 코드를 작성해야겠다고 마음 먹었다.

2. jest.useFakeTimers() 를 이용한다

custom hook 에 대한 테스트는 react-hooks-testing-library를 이용하여 작성하고 있다. 대부분의 custom hook은 useState(useDispatch)와 useEffect의 조합으로 이루어져, custom hook의 반환값의 일부를 실행하여 상태가 변경되는 것을 확인하는 방식으로 테스트를 구성할 수 있었다.
그러나, useInterval의 경우 useState 계열의 상태 변경 로직을 하나도 품고 있지 않다. (인자로 받는 callback 함수에는 상태 변경 로직이 포함되어 있을 수도 있겠다)
또한 Web API 인 setInterval을 사용하여 주기적으로 콜백함수를 호출하고 있어, 기존의 React 의 특성을 이용한 테스트는 참고할 수 없음을 깨달았다.

주어진 간격만큼 반복해서 실행하는 것을 어떻게 테스트 할 것인가를 구글링하다가, jest에서 이미 타이머 관련 Web API를 모두 mocking 한 Timer Mocks를 제공하고 있는 것을 알게 되었다. jest.useFakeTimers() 를 호출하면 setTimeout, setInterval 등이 모두 자동으로 mocking 된다.

※ 2021-08-18 Wed 기준, 아래의 Reference의 설명과 동일하게 테스트하려면 jest.useFakeTimers("legacy") 를 사용해야 한다.

아래의 예제를 참고한다면 jest.useFakeTimers("modern")을 활용할 수 있다. jest 27.x 에서는 기존의 jest.useFakeTimers() 에서 breaking changes가 있었다. Timer Mocks를 관찰하기 위해서는 필요로 하는 Web API를 각각 spy 하여야 한다. 또한, jest.useFakeTimers() 는 global으로 적용되기 때문에 mocking한 함수들을 각 테스트마다 독립적으로 사용하려면 beforeEach 또는 afterEach에서 jest.clearAllMocks();를 호출하여야 한다.

3. 첫번째 테스트: delay 가 null 인 경우, callback은 호출되지 않는다

useInterval을 유연하게 사용하기 위해 delay가 number가 아닌 null인 경우에는 callback이 호출되지 않도록 설계하였다.

이를 테스트한 코드는 다음과 같다.

  test("delay 가 null 인 경우, callback은 호출되지 않는다", () => {
    const callback = jest.fn();
    renderHook(() => useInterval(callback, null));

    jest.runAllTimers();

    expect(setInterval).not.toHaveBeenCalled();
  });

jest.runAllTimers()를 실행하면 시간이 지나면 실행예정인 모든 task들을 다 실행시킨다.

참고로 만약 delay에 number 타입을 전달하여 setInterval 이 실행된다면, jest.runAllTimers() 실행시 아래와 같이 무한루프가 발생하여 테스트가 실패하게 된다.

Aborting after running 100000 timers, assuming an infinite loop!

따라서 setInterval은 아래에서 설명할 jest.runOnlyPendingTimers()를 사용하여 타이머의 실행을 세밀하게 컨트롤해가며 테스트하여야 한다.

4. 두번째 테스트: delay ms 간격으로 callback이 주기적으로 호출된다

  test("delay ms 간격으로 callback이 주기적으로 호출된다", () => {
    const callback = jest.fn();
    const delay = 10_000;
    renderHook(() => useInterval(callback, delay));

    expect(setInterval).toHaveBeenCalledWith(expect.any(Function), delay);

    jest.advanceTimersByTime(delay);

    expect(callback).toHaveBeenCalledTimes(1);

    jest.advanceTimersByTime(delay);

    expect(callback).toHaveBeenCalledTimes(2);
  });

jest.runOnlyPendingTimers()를 호출하면 다음 실행예정인 task를 곧장 실행시키며, 이는 시간을 빨리 감는다는 개념으로 공식문서에 설명되어 있다.

우리는 실제 delayms 만큼 시간이 지났을때 callback 이 호출되는지를 테스트하고 싶은 것이므로, delayms 만큼 시간을 흐르게 하여야 한다.
이 때 사용되는 것이 jest.advanceTimersByTime()이다.
jest.advanceTimersByTime()delay를 인자로 전달하면 delayms 만큼 시간이 흐르게 할 수 있다. 이 때 callback이 호출되었는지 여부를 테스트한다.

5. 세번째 테스트: callback 함수가 변경되어도 delay ms 간격으로 callback이 호출된다

이 부분은 useInterval 작성시에도 까다로웠던 부분이다.

인자로 전달받은 callback 함수가 useCallback 등을 이용한 memoized 함수가 아닌 이상 동일한 callback 함수를 인자로 전달받더라도 참조값이 다르기 때문에 항상 다른 callback 함수로 인식된다. 즉, useEffect hook의 dependencies 에 callback 함수를 넣으면, useInterval을 사용하는 컴포넌트가 re-rendering 될 때마다 useEffect가 실행된다는 뜻이다. 이를 방지하기 위해서 useRef 를 사용하여, useEffect hook의 dependencies 에서 callback 함수를 넣지 않아도 되도록 하였다.

그러면 이것을 어떻게 테스트할까?

 test("callback 함수가 변경되어도 delay ms 간격으로 callback이 호출된다", () => {
    const callback = jest.fn();
    const delay = 15_000;
    const initialProps = { callback, delay };

    const { rerender } = renderHook<typeof initialProps, void>(
      (props) => useInterval(props.callback, props.delay),
      { initialProps }
    );

    // useInterval 이 실행된 직후 setInterval이 1회 호출된다
    expect(setInterval).toHaveBeenCalledTimes(1);

    // delay/2 ms 가 지난 이후에 어떤 이유로 useInterval 이 재호출되며 callback 의 참조값이 변경되었다
    const newCallback = jest.fn();
    jest.advanceTimersByTime(delay / 2);
    rerender({ callback: newCallback, delay });

    // callback이 변경되어도 clearInterval(useEffect cleanup function)과 setInterval은 호출되지 않는다.
    expect(clearInterval).not.toBeCalled();
    expect(setInterval).toHaveBeenCalledTimes(1);

    // 추가로 delay/2 ms 가 지나, setInterval의 interval만큼 경과되어 newCallback이 실행된다.
    jest.advanceTimersByTime(delay / 2);
    expect(newCallback).toHaveBeenCalledTimes(1);

    // delay/2 ms 가 지나도 newCallback은 실행되지 않는다.
    jest.advanceTimersByTime(delay / 2);
    expect(newCallback).toHaveBeenCalledTimes(1);
  });

react-hooks-testing-library 에서는 renderHook 실행의 반환값으로 rerender 프로퍼티를 제공하여, 컴포넌트가 re-rendering 되는 것을 mocking 할 수 있게 해준다.

6. 마무리

세상에 나쁜 테스트는 없다. 그런 테스트가 나올 수 밖에 없게 하는 나쁜 코드, 그리고 그걸 작성한 나쁜 나만이 있다. 이번 테스트는 여태까지 작성한 테스트와는 약간 결이 다른 테스트여서 처음에는 조금 까다롭게 느껴졌지만, jest에서 충분히 테스트할 수 있는 도구를 지원해주고 있어서 수월하게 테스트할 수 있었다.

profile
작은 실패, 빠른 피드백, 다시 시도

0개의 댓글