20. 테스트코드 작성을 위해서는 책임분리가 더 필요하다.
2025년 10월 15일
현재는 아직까지 훅에 너무많은 문제점이 있습니다.
상태도 변경, 메세지도 변경, 네비게이트 시간도 하드코딩, 401일때, 아닐때 분리, 세션스토리지 제거 저장도 하드코딩 등등.. 많은 문제가 있습니다.
순수 유틸함수들을 다음과 같이 분리했습니다.
import { AxiosError } from 'axios'; /** * 카카오 로그인 비즈니스 로직 유틸리티 * 순수 함수로 구성하여 테스트 가능하도록 설계 */ /** * 신규 사용자 에러인지 확인 (401 에러) */ export const isNewUserError = (error: unknown): boolean => { const axiosError = error as AxiosError; return axiosError?.response?.status === 401; }; /** * 회원가입 플로우를 사용해야 하는지 확인 * sessionStorage에 닉네임이 저장되어 있는지 체크 */ export const shouldUseRegisterFlow = (): boolean => { return sessionStorage.getItem('temp_nickname') !== null; }; /** * 저장된 닉네임 가져오기 */ export const getSavedNickname = (): string | null => { return sessionStorage.getItem('temp_nickname'); }; /** * 닉네임 저장 (캐릭터 생성 페이지에서 사용) */ export const saveNickname = (nickname: string): void => { sessionStorage.setItem('temp_nickname', nickname); }; /** * 닉네임 삭제 (회원가입 완료 후) */ export const clearSavedNickname = (): void => { sessionStorage.removeItem('temp_nickname'); }; /** * 에러 발생 시 네비게이션 타겟 결정 * @param error - 발생한 에러 * @returns 리다이렉트할 경로 */ export const getErrorNavigationTarget = (error: unknown): string => { if (isNewUserError(error)) { return '/character-create'; } return '/login'; }; /** * 성공 시 네비게이션 타겟 결정 * @returns 리다이렉트할 경로 */ export const getSuccessNavigationTarget = (): string => { return '/home'; // 로그인/회원가입 모두 홈으로 이동 }; /** * 리다이렉트 지연 시간 결정 * @param target - 리다이렉트 타겟 경로 * @returns 지연 시간 (ms) */ export const getRedirectDelay = (target: string): number => { switch (target) { case '/home': return 2000; // 회원가입 성공 시 case '/character-create': return 2000; // 신규 사용자 case '/login': return 3000; // 에러 발생 시 default: return 3000; } }; /** * 로그인 성공 시 지연 시간 결정 * @param isRegistration - 회원가입 플로우인지 여부 * @returns 지연 시간 (ms) */ export const getLoginSuccessDelay = (isRegistration: boolean): number => { return isRegistration ? 2000 : 3000; }; /** * 성공 메시지 생성 * @param isRegistration - 회원가입 플로우인지 여부 * @returns 성공 메시지 */ export const getSuccessMessage = (isRegistration: boolean): string => { return isRegistration ? '회원가입이 완료되었습니다!' : '로그인이 완료되었습니다!'; }; /** * 에러 메시지 생성 * @param error - 발생한 에러 * @param kakaoErrorMessage - 카카오에서 받은 에러 메시지 * @returns 사용자에게 표시할 에러 메시지 */ export const getErrorMessage = (error: unknown, kakaoErrorMessage?: string | null): string => { if (isNewUserError(error)) { return '새로운 계정이 생성되었습니다!'; } if (kakaoErrorMessage) { return `로그인 실패: ${kakaoErrorMessage}`; } return '로그인에 실패했습니다. 다시 시도해주세요.'; }; /** * 상태 타입 결정 * @param error - 발생한 에러 * @returns 상태 ('success' 또는 'error') */ export const getStatusFromError = (error: unknown): 'success' | 'error' => { return isNewUserError(error) ? 'success' : 'error'; };
이에, 이를 조합해서 테스트 코드를 작성하고자 합니다.
이전 구조에서 테스트를 하기에는,
테스트를 하는데
쿠키 프로바이더, 쿼리클라이언트프로바이더, 라우터 모킹, api 모킹, 타이머 모킹 등 정말 답이없는 구조입니다.
사실 이러한 상황에서는 그냥 브라우저 테스트, 지금 저희가 확인한 것 처럼 실제 카카오 아이디 가지고 로그인 해보고, 회원가입 해보는 것으로 끝내는 것이 훨씬 낫다고 생각합니다.
이에, 이렇게 유틸 함수 파일을 분리하고, 이를 조합해서 테스트 코드를 작성하고자 합니다.
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { AxiosError } from 'axios'; import { isNewUserError, shouldUseRegisterFlow, getSavedNickname, saveNickname, clearSavedNickname, getErrorNavigationTarget, getSuccessNavigationTarget, getRedirectDelay, getLoginSuccessDelay, getSuccessMessage, getErrorMessage, getStatusFromError, } from './kakaoLoginLogic'; describe('카카오 로그인 비즈니스 로직', () => { // sessionStorage 초기화 beforeEach(() => { sessionStorage.clear(); }); afterEach(() => { sessionStorage.clear(); }); describe('통합 시나리오 테스트', () => { it('신규 사용자 플로우: 401 에러 → 캐릭터 생성 페이지 (2초)', () => { const error = { response: { status: 401 } } as AxiosError; expect(isNewUserError(error)).toBe(true); expect(getStatusFromError(error)).toBe('success'); expect(getErrorMessage(error)).toContain('새로운 계정'); expect(getErrorNavigationTarget(error)).toBe('/character-create'); expect(getRedirectDelay('/character-create')).toBe(2000); }); it('기존 사용자 플로우: 로그인 성공 → 홈 (3초)', () => { const isRegistration = false; expect(getSuccessMessage(isRegistration)).toContain('로그인이 완료'); expect(getLoginSuccessDelay(isRegistration)).toBe(3000); expect(getSuccessNavigationTarget()).toBe('/home'); }); it('회원가입 플로우: 닉네임 있음 → 회원가입 → 홈 (2초)', () => { saveNickname('테스트유저'); expect(shouldUseRegisterFlow()).toBe(true); expect(getSavedNickname()).toBe('테스트유저'); const isRegistration = true; expect(getSuccessMessage(isRegistration)).toContain('회원가입이 완료'); expect(getLoginSuccessDelay(isRegistration)).toBe(2000); expect(getSuccessNavigationTarget()).toBe('/home'); clearSavedNickname(); expect(getSavedNickname()).toBeNull(); }); it('서버 에러 플로우: 500 에러 → 로그인 페이지 (3초)', () => { const error = { response: { status: 500 } } as AxiosError; expect(isNewUserError(error)).toBe(false); expect(getStatusFromError(error)).toBe('error'); expect(getErrorMessage(error)).toContain('로그인에 실패'); expect(getErrorNavigationTarget(error)).toBe('/login'); expect(getRedirectDelay('/login')).toBe(3000); }); }); });
다음과 같이, 분기처리된 로그인 흐름에 대해서 테스트를 진행하고, 모두 통과할 수 있었습니다.
이 테스트롤 통해,
- 신규 사용자
401 에러 → 캐릭터 생성 페이지 → 2초 후 이동
- 기존 사용자
200 → 로그인 완료되었습니다 메세지 →3초→ 홈으로 이동
- 회원가입
닉네임 세션 저장 → 회원가입 완료 메세지 → 2초 → 세션스토리지 클리어 → 홈이동
과 같이 분기처리된 시나리오를 테스트 할 수 있습니다.
import { useState, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { getKakaoAuthorizationCode, getKakaoErrorMessage, getKakaoLoginStatus, useKakaoAuth, useKakaoRegister, } from '@/Apis/kakao'; import { useTokenCookies } from '@/utils/cookie'; import { getSavedNickname, clearSavedNickname, getErrorNavigationTarget, getLoginSuccessDelay, getSuccessMessage, getErrorMessage, getStatusFromError, getRedirectDelay, } from '@/utils/kakaoLoginLogic'; type CallbackStatus = 'loading' | 'success' | 'error'; interface UseKakaoCallbackReturn { status: CallbackStatus; message: string; isPending: boolean; } export const useKakaoCallback = (): UseKakaoCallbackReturn => { const [status, setStatus] = useState<CallbackStatus>('loading'); const [message, setMessage] = useState<string>(''); const { loginWithCode, isPending: isLoginPending } = useKakaoAuth(); const { mutateAsync: registerWithCode, isPending: isRegisterPending } = useKakaoRegister(); const { setAccessToken } = useTokenCookies(); const navigate = useNavigate(); const timeout = useRef<ReturnType<typeof setTimeout> | null>(null); const isProcessing = useRef(false); const processedCode = useRef<string | null>(null); /** * 에러 처리 및 리다이렉션 */ const handleError = (errorMessage: string, shouldLog = false, error?: unknown) => { if (shouldLog && error) { console.error('로그인 실패:', error); } setStatus('error'); setMessage(errorMessage); const delay = getRedirectDelay('/login'); timeout.current = setTimeout(() => { navigate('/login'); }, delay); }; /** * 회원가입 처리 */ const handleRegistration = async (code: string, nickname: string) => { const result = await registerWithCode({ code, nickname }); if (result.accessToken) { setAccessToken(result.accessToken, 7); clearSavedNickname(); const isRegistration = true; setStatus('success'); setMessage(getSuccessMessage(isRegistration)); const delay = getLoginSuccessDelay(isRegistration); timeout.current = setTimeout(() => { navigate('/home'); }, delay); } }; /** * 일반 로그인 처리 */ const handleLoginFlow = async (code: string) => { const result = await loginWithCode(code); const isRegistration = false; setStatus('success'); setMessage(getSuccessMessage(isRegistration)); if (result.accessToken) { setAccessToken(result.accessToken, 7); } const delay = getLoginSuccessDelay(isRegistration); timeout.current = setTimeout(() => { navigate('/home'); }, delay); }; /** * 로그인/회원가입 처리 로직 */ const handleLogin = async (authorizationCode: string) => { if (isProcessing.current) { return; } try { isProcessing.current = true; setStatus('loading'); setMessage('로그인 처리 중...'); const savedNickname = getSavedNickname(); // 순수 함수 사용 if (savedNickname) { await handleRegistration(authorizationCode, savedNickname); } else { await handleLoginFlow(authorizationCode); } isProcessing.current = false; processedCode.current = null; } catch (error) { isProcessing.current = false; processedCode.current = null; const statusType = getStatusFromError(error); const errorMessage = getErrorMessage(error); const navigationTarget = getErrorNavigationTarget(error); const delay = getRedirectDelay(navigationTarget); setStatus(statusType); setMessage(errorMessage); timeout.current = setTimeout(() => { navigate(navigationTarget); }, delay); } }; /** * 카카오 콜백 URL에서 인증 코드 추출 및 처리 */ useEffect(() => { const loginStatus = getKakaoLoginStatus(); if (loginStatus === 'success') { const authorizationCode = getKakaoAuthorizationCode(); if ( authorizationCode && !isProcessing.current && processedCode.current !== authorizationCode ) { processedCode.current = authorizationCode; window.history.replaceState({}, '', '/auth/kakao/callback'); handleLogin(authorizationCode); } } else if (loginStatus === 'error') { const errorMessage = getKakaoErrorMessage(); handleError(`로그인 실패: ${errorMessage || '알 수 없는 오류'}`); } else { setMessage('카카오 로그인을 처리하고 있습니다...'); } return () => { if (timeout.current) { clearTimeout(timeout.current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const isPending = isLoginPending || isRegisterPending; const currentStatus = isPending ? 'loading' : status; const currentMessage = isPending ? '서버와 통신 중...' : message; return { status: currentStatus, message: currentMessage, isPending, }; };
useKaKaoCallback도 다음과 같이 수정할 수 있습니다.
각 함수가 한가지 일만 하게 util 함수를 작성하고, 이를 조합만 하여 하드코딩된 요소들과 중복으로 사용되는 코드들을 최소화 하였습니다
또한 401이 newusererror라는 것을 판단할 수 있는 등 가독성이 좋아졌습니다.