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);
});
result
변수를 선언한 후Wrapper
컴포넌트를 선언한다.useIsOpen
의 리턴 값을 할당받게 했다.render
함수로 Wrapper를 마운트한다.result
에 훅 함수가 리턴한 값이 할당된 상태가 된다.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
를 사용하는 편이 더 간단하고 확실하게 테스트 결과를 확인할 수 있을 것이다.