React의 사용자 정의 훅(Custom Hook)은
useState
,
useEffect
,
useMemo
등 기본 훅들을 조합해 특정 로직을 재사용하도록 만든 훅입니다. 일반적으로 UI와는 직접적으로 무관하므로, 컴포넌트 없이도 이 훅의 로직만 독립적으로 테스트하고 싶다는 상황이 생길 수 있습니다.
(1) 왜 Custom Hook 테스트가 필요한가?
-
복잡한 비즈니스 로직이 들어 있는 훅을 재사용할 때, 매번 컴포넌트로 감싸 테스트하기는 번거롭습니다. Form 처리, API 요청, 웹소켓 연결 등을 예로 들 수 있지요.
-
UI와 로직을 분리한 구조라면, 훅만 독립적으로 테스트해 로직이 올바른지 검증합니다.
-
UI 변경과 상관없이 훅의 로직이 잘 동작하는지 빠르게 확인할 수 있으므로, 유지보수 및 리팩토링에 유리합니다.
(2) Custom Hook 테스트 도구
1) 훅 테스팅 라이브러리 추가
@testing-library/react-hooks
(
react-testing-library
)의
renderHook
을 사용하는 것입니다.
@testing-library
에
react-hooks
는 기본 포함되어 있지 않으므로 별도 설치합니다.
pnpm add @testing-library/react-hooks -D
2) 실습 컴포넌트 작성
프론트엔드 실습계의 아이돌, 카운터 컴포넌트로 실습해보겠습니다.
// Counter.jsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>현재 카운트: {count}</p>
<button
type="button"
role="button"
aria-label="증가"
onClick={() => setCount((prev) => prev + 1)}>
증가
</button>
</div>
);
}
기본적인 테스트도 작성해봅니다.
// Counter.test.jsx
import { describe, test, expect } from 'vitest';
import { render } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import { Counter } from './Counter';
describe('Counter 컴포넌트 테스트', () => {
test('버튼을 클릭하면 카운트가 증가한다.', async () => {
const user = userEvent.setup();
const { getByLabelText, getByText } = render(<Counter />);
const button = getByLabelText('증가');
const countText = getByText(/현재 카운트: 0/);
// 초기 상태 확인
expect(countText).toBeInTheDocument();
// 버튼 클릭 이벤트 시뮬레이션
await user.click(button);
// 다시 렌더링된 상태 확인
expect(getByText(/현재 카운트: 1/)).toBeInTheDocument();
});
3) useCounter() 사용자 정의 훅 작성
카운터 컴포넌트의 값을 증가 또는 감소하는
비즈니스 로직
을
useCounter()
훅으로 분리하겠습니다.
// useCounter.js
import { useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((prev) => prev + 1);
const decrement = async () => setCount((prev) => prev - 1);
return { count, increment, decrement };
}
특이사항으로
decrement
함수가 비동기 함수라는 점입니다. 이는 의도한 것으로 이 함수가 비동기 동작(예를 들면, API 호출을 하는 등)으로 비즈니스 로직을 수행한다고 가상의 상황을 산정한 것입니다.
이제
useCounter()
사용자 정의 훅을 Counter 컴포넌트에 반영합니다.
import { useCounter } from './useCounter';
export function Counter() {
const { count, increment, decrement } = useCounter(0);
return (
<div>
<p>현재 카운트: {count}</p>
<button
type="button"
role="button"
aria-label="감소"
onClick={decrement}>
감소
</button>
<button
type="button"
role="button"
aria-label="증가"
onClick={increment}>
증가
</button>
</div>
);
}
4) 사용자 정의 훅 테스트
사용자 정의 훅을 테스트하는 데 주요하게
renderHook()
과
act()
함수가 사용됩니다.
// useCounter.test.js
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react-hooks';
import { useCounter } from './useCounter';
describe('useCounter 사용자 정의 훅 테스트', () => {
test('초기값을 잘 설정해야 한다.', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
});
test('increment 함수를 호출하면 count가 1 증가한다.', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrement 함수를 호출하면 count가 1 감소한다.', async () => {
const { result } = renderHook(() => useCounter(2));
renderHook()
함수로 사용자 정의 훅을 직접 렌더링하고, 그 결과를 RenderResult 객체로 반환하며,
renderHook()
이 반환하는 결과에서
result
키의 객체로 접근할 수 있습니다. RenderResult 객체는
현재
렌더링 결과로 훅이 반환하는 값을
current
로 제공합니다. 그래서 훅이 반환하는
count
,
increment
,
decrement
에 접근할 수 있지요.
훅 내부에서
useState()
가 변화를 일으키는 부분은 리액트의 렌더링 사이클에 영향을 미치므로,
act()
로 감싸야 합니다. act() 안에서 상태 변화가 일어나 리렌더링이 일어나면 해당 변화까지 프로그램 진행 사이클을 진행시켜서
result.current
에 변화가 반영됩니다. 이는 useEvent로 UI 상 상호작용을 하고, 그로 인해 리렌더링이 일어나 렌더링 사이클을 거쳐야 하는 경우에도
act()
를 사용해야 합니다. 그렇지 않아도 테스트를 통과하기도 하지만,
act()
관련 경고 메시지를 표시하지요.
맨 마지막 테스트를 보면
act()
를 사용하는 부분을 비동기로 처리했습니다. 이는
decrement()
가 비동기 함수이고, 이를 비동기 처리하기 위해
act()
의 인자로 비동기 함수로써 전달하고,
act()
자체도 비동기로 실행했습니다. 단, 이는
act()
의 비동기 동작 예를 들기 위함이며,
act()
는 상태 변화로 렌더링 사이클을 사용자 정의 훅 결과에 반영하는 것이 주 용도입니다.
5) 훅 테스트 시 유의점
비동기 로직이 포함된 훅 중에서 시간 소요가 들어가는 경우는
await
비동기 처리와 함께
waitForNextUpdate()
나
waitFor()
함수를 사용해 상태 변화가 끝날 때까지 기다려야 합니다.
또, Context가 필요한 경우,
renderHook()
함수의 옵션에서
wrapper
옵션을 사용해 Provider로 감싸줄 수 있습니다. 예를 들어, 인증 여부를 다루는
useAuth()
훅이 있고, 이 훅은
AuthProvider
라는 Provider를 의존한다고 하는 경우 다음과 같이 테스트 하면 됩니다.
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }) => <AuthProvider>{children}</AuthProvider>,
})
(3) Custom Hook 안에서 Mocking이 필요한 경우
사용자 정의 훅에서 API 호출이나 타이머, 브라우저 API 등을 사용하는 경우가 있습니다. 이럴 때도 Mocking 기법을 동일하게 적용할 수 있습니다.
예를 들면,
useFetchUser
훅이
fetch
를 써서 유저 정보를 가져온다고 할 때, 테스트에서는 실제 네트워크를 호출하지 않도록
global.fetch = vi.fn()
으로 Mock 처리하거나, MSW(Mock Service Worker) 등을 사용해 가짜 응답을 제공합니다. 이에 대해서는 다음 편에서 다루겠습니다.
(4) 이외 챙길 요소
1) 훅과 컴포넌트 분리
-
훅에 대한 테스트를 충분히 작성해두면, 컴포넌트 테스트를 간소화할 수 있습니다. UI 배치와 이벤트 연결만 확인하고, 비즈니스 로직은 훅에 대해서만 테스트하는 것입니다.
-
단, 통합 관점에서 전체 화면의 흐름도 별도로 테스트(통합 테스트, E2E 테스트)해봐야 합니다.
2) Mocking과 의존성 주입
-
훅 내부에서 사용하는 함수나 모듈을 분리해 놓으면(예: apis/puddingcamp.js ), 훅 테스트 시 vi.mock(...) 을 통해 원하는 시나리오(성공/실패/지연 등)를 자유롭게 구성 가능합니다.
3) Lifecycle 주기 확인
-
useEffect 는 의존 배열의 변화를 감지해 재실행됩니다. 상태나 props가 바뀌었을 때도 원하는 타이밍에 호출되는지, 불필요한 호출이 없는지 확인해야 합니다.
-
예를 들어, 부수효과를 일으키는 useEffect() 가 여러 개인 경우, 검증하려는 시점에 다른 컴포넌트 상태일 수 있는 거죠.
4) React 18 이후의 이중 호출(Strict Mode)
-
개발 모드에서 Strict Mode가 켜져 있으면, useEffect 가 마운트 시점에 2번씩 불릴 수 있습니다. 이로 인해 훅 테스트도 예상치 못한 사이드 이펙트가 2번 발생할 수 있습니다.
-
테스트에는 일반적으로 Strict Mode가 적용되지 않거나, 일관성 있게 처리할 수 있도록 설정하는 경우가 많습니다.
목차
다른 컨텐츠 더 보기
-
[할 일 관리 서비스 만들며 FastAPI에 입문하기 [연재 완료]]할 일 그룹 목록 구현2024. 7. 3.
-
[사수가 없는 저는 어떻게 학습하고 성장하나요? [연재 중]]새로운 언어를 배우는 좋은 방법: 실전 프로젝트로 시작하기2024. 12. 31.
-
피드백을 근간으로 소통하는 협업을 중요시 하는 개발자2024. 11. 17.
-
[React에 입문하기 [연재 중]]리액트에서 상태 관리란?2024. 11. 13.
-
[맥OS에서 지셸(zsh, Z Shell) 시작하기 [연재 완료]]디렉터리 만들기와 지우기, 그리고 명령어 옵션2024. 4. 10.