# React Hook Test with Jest

Jest와 react-testing-library를 사용하여 비즈니스 로직이 포함된 훅을 간편하게(?) 테스트합니다.

hook test code를 작성하여 앱 안정성을 보장하려면, 컴포넌트는 하나의 커스텀 훅을 가져야하며,

그 커스텀훅에 해당 컴포넌트의 로직이 모두 포함되어있어야 합니다. 내부에서는 또 다른 훅이 존재하겠죠.

(그리고 템플릿엔 로직이 없어야합니다.)

커스텀 훅에서 리턴된 "모델"을 기반으로 컴포넌트 UI에 붙이기만하면, "모델렌더"를 달성할 수 있습니다.

반대로 뷰모델이 분리 안됐다면 테스트코드를 작성하기 힘들어집니다.

UI는 우리가 설계한 모델을 기반으로 동작할것이고, 모델과 UI가 확실히 분리되어있어 유지보수하기 좋은 구조입니다.

UI는 따로 테스트하거나 스토리북으로 커버하세요.

# 테스트할 hook

테스트하려는 훅 내부에 useContext와 백엔드 API 호출이 섞여있는 경우를 가정하고 작성해봤습니다.

data는 가상의 App 컴포넌트가 렌더링되면 API를 통해 내려오는 좋아요 관련 데이터입니다.

const useProductLike = ({ data }) => {
  const { prdNo } = useContext(ProductContext);
  const [count, setCount] = useState<number>(0);
  const [isLiked, setIsLiked] = useState(false);

  const onClickLike = useCallback(() => {
    setIsLiked((prev) => !prev);
    likeProductAPI({
      prdNo,
      isRemove: isLiked,
    }).then(({ likeCount }) => {
      setCount(likeCount);
    });
  }, [isLiked, prdNo]);

  useEffect(() => {
    setCount(Number(data?.likeCount ?? 0));
  }, [data?.likeCount]);
  useEffect(() => {
    setIsLiked(data?.like);
  }, [data?.like]);

  return { count, isLiked, onClickLike };
};

# 테스트코드

아래에서는 상품에 좋아요~ 하는 기능을 담당하는 훅을 테스트합니다.

우선 훅 내부에서 호출하는 api를 포함하는 디렉토리를 import합니다.

import * as api from '~/apis';

그리고 그 디렉토리(user module)를 mocking합니다.

jest.mock('~/apis', () => ({
  likeProduct: jest.fn(() => Promise.resolve({ likeCount: 100 })),
}));

테스트코드에는 서버 API를 직접 호출하는 부분이 있으면 안됩니다.

서버 API는 서버에서 테스트해야죠.

우리의 테스트는 서버 API 결과와 상관없이 독립적으로 동작해야합니다.

그리고 테스트할 훅에서 해당 API가 호출됐는지, 그 후에 훅이 의대로한대로 업데이트 됐는지 확인합니다.

@testing-library/react 패키지를 import합니다.

import { renderHook } from '@testing-library/react-hooks';

import * as api from '~/apis';

jest.mock('~/apis', () => ({
  likeProduct: jest.fn(() => Promise.resolve({ likeCount: 100 })),
}));

const productMock = {
  // ...생략
  like: {
    likeCount: 1,
    like: false,
  }
}

describe('hook testing', () => {
  it('useProductLike hook', async () => {
    // hook의 props는 아래와 같이 설정해야한다
    const { result, waitForNextUpdate, rerender } = renderHook(
      (initialProps: any) => useProductLike(initialProps),
      { initialProps: { data: productDetailMock.like } },
    );

    // productMock.like의 데이터 => useProductLike에서 가공하여 반환된 결과들
    expect(result.current?.isLiked).toEqual(false);
    expect(result.current?.count).toEqual(1);

    // hook 내부에서 likeProduct 함수를 호출하는지 감시할 수 있다.
    const spy = jest.spyOn(api, 'likeProduct');
    // 좋아요에 해당하는 함수 호출
    result.current?.onClickLike();
    // 렌더링 업데이트를 기다린다
    await waitForNextUpdate();
    // hook 내부에서 likeProduct 함수를 호출했는지 체크
    expect(spy).toHaveBeenCalled();
    // likeProduct 함수 호출 후 의도한대로 상태가 변경됐는지 확인
    expect(result.current?.isLiked).toEqual(true);
    expect(result.current?.count).toEqual(100);
    // useEffect hook들을 테스트하기 위해 리렌더
    rerender({ data: { like: true, likeCount: 10 } });
    expect(result.current?.isLiked).toEqual(true);
    expect(result.current?.count).toEqual(10);
  });
});

# 마지막으로..

이직했는데, 엄청 큰 프로젝트인데도 테스트코드가 하나도 없는 경우가 있어요... 제 얘기 맞습니다 ㅋㅋ

잘 쪼개지지 않은 큰 모듈을 테스트할 때, 테스트코드에 필요없는 모듈들에게서 일어나는 황당한 에러들을 많이 겪을 수 있습니다.

(큰 모듈엔 많은 import 구문이 있을텐데, 테스트코드에서 사용하지않는 친구들의 코드도 다 실행됩니다)

이럴땐 모듈 내부에서 사용하지않는, 내가 테스트할 코드에서 쓸모없는 모듈들을 무시하기 위해 mocking 해버립니다.

jest.mock('@/components/useless', () => null);

# 또 다른 예제

// 테스트용도의 최소한의 useFetch hook 구현체
export const useFetch = <T = any>({
  url = '',
  autoFetch = true,
  method = 'get',
}) => {
  const [data, setData] = useState<T | null>(null);
  const [isLoaded, setIsLoaded] = useState<boolean>(false);

  const fetchData = useCallback(() => {
    if (url) {
      httpRequest(url)
        [method]()
        .then((res) => {
          setData({ ...res, isLoaded: true });
        })
        .finally(() => {
          setIsLoaded(true);
        });
    }
  }, [url, method]);

  useEffect(() => {
    if (autoFetch && !data) {
      fetchData();
    }
  }, [autoFetch, data, fetchData]);

  return { data, isLoaded, fetchData };
};
import * as api from '~/apis';

// httpRequest 객체를 mocking 해줍니다.
jest.mock('~/apis', () => ({
  httpRequest: jest.fn(() => ({ get: () => Promise.resolve() })),
}));


describe('useFetch', () => {
  // url이 없으면 호출하지않습니다.
  it('useFetch empty string api => not fetch', async () => {
    const { result } = renderHook(() => useFetch({ url: '' }));
    expect(result.current?.data).toEqual(null);
    const spy = jest.spyOn(api, 'httpRequest');
    expect(spy).toHaveBeenCalledTimes(0);
  });

  it('useFetch', async () => {
    const { result, waitForNextUpdate } = renderHook(() =>
      useFetch({ url: 'some' }),
    );

    //처음엔 data의 초기값인 null이어야합니다.
    expect(result.current?.data).toEqual(null);
    await waitForNextUpdate(); // effect를 기다렸다가,
    // 데이터 로드가 끝남을 확인
    expect(result.current?.data).toEqual({ isLoaded: true });

    // 데이터 로드가 끝났으므로, httpRequest는 1번 호출되어야함
    const spy = jest.spyOn(api, 'httpRequest');
    expect(spy).toHaveBeenCalledTimes(1);
    // 의도적으로 다시 fetch 함수 호출
    result.current?.fetchData();
    await waitForNextUpdate();
    // httpRequest가 한번 더 호출됐는지 확인
    expect(spy).toHaveBeenCalledTimes(2);
  });
});

# jest spyOn

jest의 spy기능은 테스트코드를 더욱 정교하게 만들 수 있도록 도와줍니다.

jest.fn와 유사하지만, 테스트코드에서 mocking된 함수 호출을 감시할 수 있습니다.

다만 위의 useFetch 예시 2개의 테스트의 순서가 바뀔경우 에러가 발생할 수 있는데,

const spy = jest.spyOn(api, 'httpRequest');
expect(spy).toHaveBeenCalledTimes(1);

바로 이 부분을 주의해야합니다.

spyOn이 작동할때, 인스턴스가 아닌 내부 변수에 호출 횟수가 캐싱됩니다.

그래서 다른 테스트 케이스에서 spyOn의 대상이 되는 함수가 호출됐다면,

또 다른 테스트케이스에서 해당 함수를 호출하지않아도 호출횟수가 0이 아닐 수 있습니다.

# renderHook => result

위의 useFetch에서 짚고 넘어가야하는 부분이 있습니다.

const { result, waitForNextUpdate } = renderHook(() =>
  useFetch({ url: 'some' }),
);
const { data } = result.current ?? {};

이렇게 data를 destructuring 하고싶은 유혹이 드는데요,

그 전에 잘 생각해봐야합니다. 왜 result.current 인지.

hook 내부의 StateUpdater가 호출되면, result.current.data의 레퍼런스가 변경이 됩니다.

하지만 분해해버리면, StateUpdater 실행되고 나서도 실행전의 레퍼런스가 유지됩니다.

그래서 hook update 하고나서의 값이 반영되질않죠.

사실 이걸 써놓은 이유는 custom hook에서 배열을 리턴하는게 테스트코드의 가독성을 떨어지게 만든다는겁니다.

보세요.

expect(result.current?.[0]).toBe(1)

요런식이 되겠쥬? 그나마 이건 괜찮은데.. 함수인 경우는?

result.current?.[1](1, 0)
result.current?.[1](2, 0)

정말 이대로 괜찮을까요? 저는 useState처럼 이름을 정말 마음대로 자유롭게 짓고 싶은 경우가 아니라면..

테스트코드를 위해서라도 훅에서 배열 리턴하지않고 객체 리턴하겠습니다.

# it, test

it은 test의 alias입니다. 아무거나 편한거 쓰세요.

# act, waitForNextUpdate

우선 첫번째 act는 react testing library의 기능이 아닌, react-dom/test-utils 패키지에 포함된 기능입니다.

반면 waitForNextUpdate는 renderHook의 인스턴스 메소드입니다.

훅 내부에 useEffect를 사용하는 경우입니다.

간단한 예시를 들자면,

const useSomeHook = () => {
  const [data, setData] = useState(0);

  useEffect(() => {
    setData(1);
  }, []);

  return data;
}

describe('useSomeHook', () => {
  it('useSomeHook', async () => {
    const { result, waitForNextUpdate } = renderHook(() => useSomeHook());
    expect(result.current?.data).toBe(0);
    await waitForNextUpdate();
    expect(result.current?.data).toBe(1);
  });
});

위 예시는 act로 커버하기 어렵습니다.

StateUpdater => waitForNextUpdate

DOM Update => act

DOM Update의 예를 들자면.... history.pushState 등이 있겠쥬?

# waitForNextUpdate에서 jest timeout 떨어지는 경우

Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Timeout - Async callback was not invoked within the 5000 ms timeout specified by jest.setTimeout.Error:

이 경우는 보통 API등의 함수를 mocking해놓고, 반환값이 제대로 안떨어져서

StateUpdater가 실행이 안되는 경우입니다.

혹은 비동기함수가 기본타이머 세팅인 5000ms보다 느리게 수행되는경우겠지요

이럴땐 타이머를 더 늘려주면 됩니다.

가짜 비동기 함수를 만들었다면.. 그 비동기함수를 더 빨리 수행되도록 변경해도 괜찮고요.