20. 테스트코드 작성을 위해서는 책임분리가 더 필요하다.
현재는 아직까지 훅에 너무많은 문제점이 있습니다.
상태도 변경, 메세지도 변경, 네비게이트 시간도 하드코딩, 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라는 것을 판단할 수 있는 등 가독성이 좋아졌습니다.