Please enable JavaScript to view the comments powered by Disqus.

React 커스텀 훅 함수의 테스트 코드 작성

커스텀 훅은 React 함수형 컴포넌트 API를 사용하는 함수

React에서 사용하는 커스텀 훅(custom hook)은 함수 형태로 구현한다. 하지만 일반적인 함수처럼 테스트 코드를 작성할 수는 없다. 왜냐하면 훅 안에서는 React 함수형 컴포넌트에서 사용하는 API를 사용하기 때문이다. 아래는 간단한 커스텀 훅으로써 useState API를 사용한다.

import { useState } from 'react';

export default function useSample() {
  const [isOpen, setIsOpen] = useState(false);

  return {
    isOpen,
    setIsOpen,
  };
}

함수형 컴포넌트 안에서 사용하는 API를 포함하고 있기 때문에 커스텀 훅을 일반적인 함수처럼 호출하면 에러가 발생한다.

import useIsOpen from './useIsOpen';

test('isOpen의 초기값은 false다', () => {
  const result = useIsOpen();
  expect(result.isOpen).toBe(false);
});
Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons:

      3 | export default function useSample() {
    > 4 |   const [isOpen, setIsOpen] = useState(false);

통합 테스트를 중심으로 작성하고 있다면 커스텀 훅을 사용하고 있는 컴포넌트의 테스트를 작성하는 과정에서 자연스럽게 테스트가 이뤄지긴 할 것이다. 하지만 통합 테스트는 애초에 목적이 다르며, 커스텀 훅의 기능 하나하나를 확인하기에는 적절하지 않다. 커스텀 훅의 유닛 테스트를 위해서는 테스트 코드 안에서 간단한 래퍼 컴포넌트를 작성해줘야 한다.

import React from 'react';
import { render } from '@testing-library/react';
import useIsOpen from './useIsOpen';

test('isOpen의 초기값은 false다', () => {
  let result = {} as ReturnType<typeof useIsOpen>;

  const Wrapper = () => {
    result = useIsOpen();
    return null;
  };

  render(<Wrapper />);

  expect(result.isOpen).toBe(false);
});
  1. result 변수를 선언한 후
  2. Wrapper 컴포넌트를 선언한다. useIsOpen 의 리턴 값을 할당받게 했다.
  3. render 함수로 Wrapper를 마운트한다. result에 훅 함수가 리턴한 값이 할당된 상태가 된다.
  4. result 객체에 기대한 값이 들어있는지 확인한다.

이 기본 형태를 바탕으로 커스텀 훅 테스팅을 위한 유틸리티를 만들어서 써도 되겠지만, 언제나 먼저 앞서 나가는 사람들은 있는 법이다. 오픈소스인 react-hooks-testing-library가 있으니 활용하도록 하자.

react-hooks-testing-library를 사용한 커스텀 훅 테스팅

react-hooks-testing-library는 커스텀 훅 테스팅을 위한 렌더링 API로 renderHook 함수를 제공한다. renderHook 콜백 함수를 파라미터로 받으며, 그 함수는 1개 이상의 훅 함수의 실행을 포함하고 있어야 한다. 그리고 콜백 함수가 리턴하는 값은 renderHook 리턴 값의 result 속성을 통해 참조 가능하다.

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

test('isOpen의 초기값은 false다', () => {
  const { result } = renderHook(() => useIsOpen());
  expect(result.current.isOpen).toBe(false);
});

result.current 필드를 통해 콜백 함수가 마지막으로 리턴한 값(여기서는 useIsOpen 훅이 마지막으로 실행되어서 리턴한 값)을 참조할 수 있다. current라는 값이 있는 이유는, 커스텀 훅은 컴포넌트 안에서 실행되는 만큼 한번 이상 호출될 수 있기 때문이다.

커스텀 훅의 상태(state) 업데이트 테스트

커스텀 훅은 여러 컴포넌트에서 재활용하기 위해 복잡한 로직을 포함하고 있는 경우가 많고, 그 로직이 잘 구현되었는지 확인하는 과정도 반드시 필요하다. useIsOpen 훅으로 예시를 들자면 setIsOpen 메소드 호출을 통해 isOpen 상태가 변경되는지 확인이 필요하다는 말이다.

import { renderHook, act } from '@testing-library/react-hooks';
import useIsOpen from './useIsOpen';

test('setIsOpen 호출을 통해 isOpen 상태를 업데이트할 수 있다.', () => {
  const { result } = renderHook(() => useIsOpen());

  act(() => {
    result.current.setIsOpen(true);
  });

  expect(result.current.isOpen).toBe(true);
});

act 함수는 react-dom/test-utils 모듈에서 제공하고 있는 그것이다. 테스트 코드 내부에서 컴포넌트 렌더링 및 상태 변경은 act 함수 내부에서 실행해야 안전하다(여기서는 컴포넌트 렌더링이 없으므로 act로 래핑하지 않아도 테스트는 성공하긴 한다).

useEffect 실행 확인 테스트

커스텀 훅 안의 useEffect 훅이 실행되는지 확인하기 위해서는 useEffect가 의존하고 있는 변수가 업데이트되도록 만들어야 한다. 테스트를 위해 props를 받아서 상태를 업데이트하는 usePage라는 간단한 커스텀 훅을 구현했다.

import { useState, useEffect } from 'react';

export default function usePage({ initialPage = 1 }: { initialPage?: number }) {
  const [page, setPage] = useState(initialPage);

  useEffect(() => {
    if (initialPage) {
      setPage(initialPage);
    }
  }, [initialPage]);

  return {
    page,
    setPage,
  };
}

renderHook 함수는 두 번째 파라미터로 커스텀 훅의 props를 제공할 수 있다. 그리고 renderHook 함수의 리턴 값이 주는 rerender 함수를 사용하면, 마운트된 컴포넌트가 한번 더 렌더링되면서 커스텀 훅도 한번 더 실행된다.

아래 테스트에서 rerender를 호출할 때 처음과 다른 initialPage 값을 전달했는데, 그 값이 커스텀 훅의 상태(page)에 제대로 반영되었음을 확인할 수 있다.

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

test('initialPage의 변경은 page 상태에 반영된다.', () => {
  const { result, rerender } = renderHook((props) => usePage(props), {
    initialProps: {
      initialPage: 1,
    },
  });

  expect(result.current.page).toBe(1);

  rerender({
    initialPage: 5,
  });

  expect(result.current.page).toBe(5);
});

Context를 사용하는 커스텀 훅의 테스트

커스텀 훅이 Redux의 useDispatch 훅을 사용하거나, Recoil의 useRecoilState같은 훅을 사용한다면 테스트 코드도 거기에 맞는 Provider를 제공해야 한다. react-hooks-testing-library는 그를 위한 API를 제공한다.

import { RecoilRoot } from 'recoil';
import { useData } from './hooks'; // recoil을 사용하는 커스텀 훅

const Wrapper: React.FC = ({ children }) => {
  return (
    /** Recoil의 훅 사용을 위해 RecoilRoot로 컴포넌트를 래핑한다  */
    <RecoilRoot>{children}</RecoilRoot>
  );
};

test('some state', () => {
  const { result } = renderHook(() => useData(), {
    wrapper: Wrapper,
  });

  expect(result.current.data).toBe(null);
});

비동기 코드가 포함된 커스텀 훅의 테스트

실무에서는 백엔드 API 호출 데이터를 상태에 반영하는 커스텀 훅도 구현할 일이 많이 생긴다. 그런 커스텀 훅의 테스트는 testing-library를 사용해봤다면 익숙할 waitFor 함수로 가능하다.

그리고 react-hooks-testing-library가 제공하는 waitForNextUpdate라는 기능이 있는데, 이는 waitFor와는 다르게 콜백 함수가 없다. 말 그대로 비동기적으로 실행되는 코드에 의한 태스크가 실행될 때까지 대기하는 역할을 한다. 예제를 위해 setTimeout을 사용해 상태를 업데이트하는 훅을 살펴보자.

import { useState, useEffect } from 'react';

export default function useAsyncCounter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(1);
    setCount(2);
    setCount(3);
  }, [setCount]);

  useEffect(() => {
    setTimeout(() => {
      setCount(1000);
    }, 300);
  }, []);

  return {
    count,
  };
}

첫 번째 useEffect 안에서 setCount를 세 번 호출했지만 모두 동기적으로 업데이트된다. 그러므로 테스트 코드에서 count 는 처음부터 3으로 확인될 것이다.

expect(result.current.count).toEqual(3);

그리고 300ms 후에 count 상태가 1000이 되도록 작성했다. 이 코드의 동작을 확인하기 위해서는 테스트 코드도 상태가 업데이트될 때까지 기다려야 한다. 이런 경우에 waitForNextUpdate 를 사용한다. 달리 말하자면 waitForNextUpdate가 리턴하는 Promise는 비동기 코드에 의해 컴포넌트가 다시 렌더링된 직후에 resolve된다.

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

test('setTimeout을 사용하면 비동기적으로 상태가 업데이트된다', async () => {
  const { result, waitForNextUpdate } = renderHook(() => useAsyncCounter());

  expect(result.current.count).toEqual(3);

  await waitForNextUpdate(); // 호출하지 않으면 테스트가 실패한다

  expect(result.current.count).toEqual(1000);
});

다만 커스텀 훅의 로직이 복잡하고 비동기 코드에 의해 업데이트되는 상태가 여러 개라면 waitFor를 사용하는 편이 더 간단하고 확실하게 테스트 결과를 확인할 수 있을 것이다.

관련 자료