커스텀 훅 테스트 — renderHook으로 로직 검증하기
커스텀 Hook은 컴포넌트 없이는 호출할 수 없습니다. 그렇다면 Hook의 로직만 독립적으로 테스트하려면 어떻게 해야 할까요?
커스텀 Hook은 React 컴포넌트 안에서만 호출할 수 있다는 제약이 있습니다. renderHook은 이 제약을 해결하여, Hook을 감싸는 임시 컴포넌트를 자동으로 만들어 줍니다. Hook의 반환값과 상태 변화를 직접 검증할 수 있게 됩니다.
renderHook 기본 사용법
import { renderHook, act } from '@testing-library/react';
// 테스트할 커스텀 Hook
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
// 테스트
describe('useCounter', () => {
test('초기값으로 시작한다', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increment를 호출하면 count가 증가한다', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('reset을 호출하면 초기값으로 돌아간다', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(7);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
result.current
result.current는 Hook의 현재 반환값을 가리킵니다. 상태가 업데이트되면 result.current도 자동으로 최신 값을 반영합니다.
act()가 필요한 이유
React의 상태 업데이트는 비동기적으로 배치됩니다. act()로 감싸면 모든 업데이트가 처리된 후에 assertion이 실행됩니다.
// act 없이 — 경고 발생, 결과가 불안정
result.current.increment(); // 상태 업데이트가 아직 적용되지 않았을 수 있음
// act로 감싸기 — 안전
act(() => {
result.current.increment();
});
// 이 시점에서 상태 업데이트가 완료됨
인자 변경 테스트 (rerender)
function useDocumentTitle(title) {
useEffect(() => {
document.title = title;
}, [title]);
}
test('title이 변경되면 document.title이 업데이트된다', () => {
const { rerender } = renderHook(
({ title }) => useDocumentTitle(title),
{ initialProps: { title: '초기 제목' } }
);
expect(document.title).toBe('초기 제목');
// 새 인자로 리렌더
rerender({ title: '변경된 제목' });
expect(document.title).toBe('변경된 제목');
});
비동기 Hook 테스트
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then((res) => res.json())
.then((json) => {
if (!cancelled) {
setData(json);
setLoading(false);
}
})
.catch((err) => {
if (!cancelled) {
setError(err);
setLoading(false);
}
});
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// MSW 핸들러 설정 필요
import { server } from '../mocks/server';
import { http, HttpResponse } from 'msw';
test('데이터를 성공적으로 가져온다', async () => {
server.use(
http.get('/api/data', () => {
return HttpResponse.json({ name: '테스트' });
})
);
const { result } = renderHook(() => useFetch('/api/data'));
// 초기 로딩 상태
expect(result.current.loading).toBe(true);
// 비동기 완료 대기
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.data).toEqual({ name: '테스트' });
expect(result.current.error).toBeNull();
});
test('에러가 발생하면 error를 반환한다', async () => {
server.use(
http.get('/api/data', () => {
return HttpResponse.error();
})
);
const { result } = renderHook(() => useFetch('/api/data'));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.error).toBeTruthy();
expect(result.current.data).toBeNull();
});
Context 의존 Hook 테스트
const AuthContext = createContext();
function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
test('인증된 사용자 정보를 반환한다', () => {
const mockAuth = {
user: { id: 1, name: '홍길동' },
isAuthenticated: true,
logout: jest.fn(),
};
// wrapper로 Provider 제공
const wrapper = ({ children }) => (
<AuthContext.Provider value={mockAuth}>
{children}
</AuthContext.Provider>
);
const { result } = renderHook(() => useAuth(), { wrapper });
expect(result.current.user.name).toBe('홍길동');
expect(result.current.isAuthenticated).toBe(true);
});
test('Provider 없이 사용하면 에러를 던진다', () => {
// 에러 출력을 억제
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
expect(() => {
renderHook(() => useAuth());
}).toThrow('useAuth must be used within AuthProvider');
consoleSpy.mockRestore();
});
타이머 Hook 테스트
function useDebounce(value, delay) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
test('지정한 시간 후에 값이 업데이트된다', () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
);
expect(result.current).toBe('initial');
// 값 변경
rerender({ value: 'updated', delay: 500 });
// 500ms 전에는 아직 이전 값
act(() => {
jest.advanceTimersByTime(400);
});
expect(result.current).toBe('initial');
// 500ms 후에 업데이트
act(() => {
jest.advanceTimersByTime(100);
});
expect(result.current).toBe('updated');
jest.useRealTimers();
});
test('delay 내에 값이 다시 변경되면 타이머가 리셋된다', () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'a', delay: 500 } }
);
// 첫 번째 변경
rerender({ value: 'b', delay: 500 });
act(() => { jest.advanceTimersByTime(300); });
// 타이머 리셋 — 두 번째 변경
rerender({ value: 'c', delay: 500 });
act(() => { jest.advanceTimersByTime(300); });
// 아직 'a' (두 번째 타이머가 아직 완료되지 않음)
expect(result.current).toBe('a');
act(() => { jest.advanceTimersByTime(200); });
// 이제 'c'
expect(result.current).toBe('c');
jest.useRealTimers();
});
Interval Hook 테스트
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// 테스트
test('지정된 간격으로 콜백이 호출된다', () => {
jest.useFakeTimers();
const callback = jest.fn();
renderHook(() => useInterval(callback, 1000));
expect(callback).not.toHaveBeenCalled();
act(() => { jest.advanceTimersByTime(1000); });
expect(callback).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(3000); });
expect(callback).toHaveBeenCalledTimes(4);
jest.useRealTimers();
});
test('delay가 null이면 interval이 멈춘다', () => {
jest.useFakeTimers();
const callback = jest.fn();
const { rerender } = renderHook(
({ delay }) => useInterval(callback, delay),
{ initialProps: { delay: 1000 } }
);
act(() => { jest.advanceTimersByTime(2000); });
expect(callback).toHaveBeenCalledTimes(2);
// delay를 null로 변경 → interval 정지
rerender({ delay: null });
act(() => { jest.advanceTimersByTime(3000); });
expect(callback).toHaveBeenCalledTimes(2); // 추가 호출 없음
jest.useRealTimers();
});
언마운트 테스트
function useEventListener(event, handler) {
useEffect(() => {
window.addEventListener(event, handler);
return () => window.removeEventListener(event, handler);
}, [event, handler]);
}
test('언마운트 시 이벤트 리스너가 제거된다', () => {
const addSpy = jest.spyOn(window, 'addEventListener');
const removeSpy = jest.spyOn(window, 'removeEventListener');
const handler = jest.fn();
const { unmount } = renderHook(() => useEventListener('resize', handler));
expect(addSpy).toHaveBeenCalledWith('resize', handler);
unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', handler);
addSpy.mockRestore();
removeSpy.mockRestore();
});
정리
커스텀 Hook 테스트의 핵심 패턴입니다.
- renderHook 으로 Hook을 독립적으로 실행하고
result.current로 반환값을 검증합니다 - act() 로 상태 업데이트를 감싸 안정적인 assertion을 보장합니다
- rerender 로 인자 변경에 따른 동작을 테스트합니다
- wrapper 로 Context Provider를 주입하여 Context 의존 Hook을 테스트합니다
- jest.useFakeTimers() 로 타이머 기반 Hook의 시간을 직접 제어합니다
- unmount 로 클린업 함수(이벤트 리스너 제거 등)가 정상 동작하는지 확인합니다
주의할 점
act() 없이 상태 업데이트를 테스트하면 경고 발생
Hook의 state를 변경하는 코드는 act()로 감싸야 합니다. 감싸지 않으면 React가 "state update not wrapped in act()" 경고를 출력하고, assertion 시점에 상태가 아직 반영되지 않을 수 있습니다.
Context 의존 훅을 wrapper 없이 테스트하면 에러
useContext를 사용하는 훅은 Provider 밖에서 호출하면 기본값이나 에러를 반환합니다. renderHook의 wrapper 옵션으로 필요한 Provider를 주입해야 합니다.
Hook 테스트가 복잡해진다면, Hook이 너무 많은 책임을 지고 있는 것은 아닌지 검토해야 합니다. 잘 분리된 Hook은 테스트하기도 쉽습니다.
댓글 로딩 중...