From af3cc3de199fa7e01ad3eceb16db277a49b4b279 Mon Sep 17 00:00:00 2001 From: dasomel Date: Thu, 28 May 2026 08:58:56 +0900 Subject: [PATCH] =?UTF-8?q?test:=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9B=85=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calc, storage, passwordHash 유틸리티와 useDebouncedInput 훅에 대한 단위 테스트를 추가하여 핵심 비즈니스 로직의 동작을 검증한다. - calc.itemIdxByPage: 역순 인덱스 계산 6개 케이스 검증 - storage: localStorage/sessionStorage CRUD 동작 9개 케이스 검증 - passwordHash: SHA-256 기반 Base64 해싱 5개 케이스 검증 - useDebouncedInput: 디바운싱 지연·연속호출·상태업데이트 5개 케이스 검증 --- src/hooks/useDebounce.test.jsx | 83 ++++++++++++++++++++++++++++++++++ src/utils/calc.test.js | 34 ++++++++++++++ src/utils/passwordHash.test.js | 35 ++++++++++++++ src/utils/storage.test.js | 75 ++++++++++++++++++++++++++++++ 4 files changed, 227 insertions(+) create mode 100644 src/hooks/useDebounce.test.jsx create mode 100644 src/utils/calc.test.js create mode 100644 src/utils/passwordHash.test.js create mode 100644 src/utils/storage.test.js diff --git a/src/hooks/useDebounce.test.jsx b/src/hooks/useDebounce.test.jsx new file mode 100644 index 0000000..ba3c12c --- /dev/null +++ b/src/hooks/useDebounce.test.jsx @@ -0,0 +1,83 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useDebouncedInput } from '@/hooks/useDebounce'; + +describe('useDebouncedInput', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('지연 시간 전에는 setState가 호출되지 않는다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('name', '홍길동'); + }); + + expect(setState).not.toHaveBeenCalled(); + }); + + it('지연 시간 후에 setState가 호출된다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('name', '홍길동'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledTimes(1); + }); + + it('연속 호출 시 마지막 호출만 실행된다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('name', '첫번째'); + result.current('name', '두번째'); + result.current('name', '세번째'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledTimes(1); + }); + + it('setState에 이전 상태를 기반으로 업데이트 함수를 전달한다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('email', 'test@example.com'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledWith(expect.any(Function)); + + const updater = setState.mock.calls[0][0]; + const prevState = { name: '홍길동' }; + expect(updater(prevState)).toEqual({ name: '홍길동', email: 'test@example.com' }); + }); + + it('서로 다른 필드를 각각 디바운싱할 수 있다', () => { + const setState = vi.fn(); + const { result } = renderHook(() => useDebouncedInput(setState, 300)); + + act(() => { + result.current('title', '제목'); + vi.advanceTimersByTime(300); + }); + + act(() => { + result.current('content', '내용'); + vi.advanceTimersByTime(300); + }); + + expect(setState).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/utils/calc.test.js b/src/utils/calc.test.js new file mode 100644 index 0000000..a220e05 --- /dev/null +++ b/src/utils/calc.test.js @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { itemIdxByPage } from '@/utils/calc'; + +describe('itemIdxByPage', () => { + it('첫 페이지 첫 번째 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 1페이지, pageSize=5, index=0 → 10+1-(0*5+0+1) = 10 + expect(itemIdxByPage(10, 1, 5, 0)).toBe(10); + }); + + it('첫 페이지 마지막 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 1페이지, pageSize=5, index=4 → 10+1-(0*5+4+1) = 6 + expect(itemIdxByPage(10, 1, 5, 4)).toBe(6); + }); + + it('두 번째 페이지 첫 번째 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 2페이지, pageSize=5, index=0 → 10+1-(1*5+0+1) = 5 + expect(itemIdxByPage(10, 2, 5, 0)).toBe(5); + }); + + it('두 번째 페이지 마지막 항목의 역순 인덱스를 반환한다', () => { + // resultCnt=10, 2페이지, pageSize=5, index=4 → 10+1-(1*5+4+1) = 1 + expect(itemIdxByPage(10, 2, 5, 4)).toBe(1); + }); + + it('resultCnt가 0이면 음수 인덱스를 반환할 수 있다', () => { + // resultCnt=0, 1페이지, pageSize=5, index=0 → 0+1-(0+0+1) = 0 + expect(itemIdxByPage(0, 1, 5, 0)).toBe(0); + }); + + it('pageSize=10인 경우도 올바르게 동작한다', () => { + // resultCnt=25, 3페이지, pageSize=10, index=2 → 25+1-(2*10+2+1) = 3 + expect(itemIdxByPage(25, 3, 10, 2)).toBe(3); + }); +}); diff --git a/src/utils/passwordHash.test.js b/src/utils/passwordHash.test.js new file mode 100644 index 0000000..7dadd3d --- /dev/null +++ b/src/utils/passwordHash.test.js @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { hashPassword } from '@/utils/passwordHash'; + +describe('hashPassword', () => { + it('동일한 id와 password는 동일한 해시를 반환한다', async () => { + const hash1 = await hashPassword('user01', 'password123'); + const hash2 = await hashPassword('user01', 'password123'); + expect(hash1).toBe(hash2); + }); + + it('Base64 문자열을 반환한다', async () => { + const hash = await hashPassword('user01', 'password123'); + expect(typeof hash).toBe('string'); + // Base64 형식 검증 (알파벳, 숫자, +, /, = 만 허용) + expect(hash).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + it('password가 달라지면 다른 해시를 반환한다', async () => { + const hash1 = await hashPassword('user01', 'password123'); + const hash2 = await hashPassword('user01', 'differentPassword'); + expect(hash1).not.toBe(hash2); + }); + + it('id가 달라지면 다른 해시를 반환한다', async () => { + const hash1 = await hashPassword('user01', 'password123'); + const hash2 = await hashPassword('user02', 'password123'); + expect(hash1).not.toBe(hash2); + }); + + it('SHA-256 기반 Base64 결과는 44자이다', async () => { + const hash = await hashPassword('user01', 'password123'); + // SHA-256은 32바이트 → Base64 인코딩 시 44자 (패딩 포함) + expect(hash.length).toBe(44); + }); +}); diff --git a/src/utils/storage.test.js b/src/utils/storage.test.js new file mode 100644 index 0000000..f2281a0 --- /dev/null +++ b/src/utils/storage.test.js @@ -0,0 +1,75 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + getLocalItem, + setLocalItem, + removeLocalItem, + getSessionItem, + setSessionItem, + removeSessionItem, +} from '@/utils/storage'; + +function createStorageMock() { + let store = {}; + return { + getItem: (key) => store[key] ?? null, + setItem: (key, value) => { store[key] = String(value); }, + removeItem: (key) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +} + +describe('storage utils', () => { + beforeEach(() => { + vi.stubGlobal('localStorage', createStorageMock()); + vi.stubGlobal('sessionStorage', createStorageMock()); + }); + + describe('localStorage', () => { + it('값을 저장하고 가져온다', () => { + setLocalItem('user', { id: 1, name: '홍길동' }); + expect(getLocalItem('user')).toEqual({ id: 1, name: '홍길동' }); + }); + + it('존재하지 않는 키는 null을 반환한다', () => { + expect(getLocalItem('missing')).toBeNull(); + }); + + it('항목을 삭제하면 null을 반환한다', () => { + setLocalItem('token', 'abc'); + removeLocalItem('token'); + expect(getLocalItem('token')).toBeNull(); + }); + + it('문자열 값을 저장하고 가져온다', () => { + setLocalItem('lang', 'ko'); + expect(getLocalItem('lang')).toBe('ko'); + }); + + it('숫자 값을 저장하고 가져온다', () => { + setLocalItem('count', 42); + expect(getLocalItem('count')).toBe(42); + }); + + it('undefined를 저장하면 null로 직렬화된다', () => { + setLocalItem('empty', undefined); + expect(getLocalItem('empty')).toBeNull(); + }); + }); + + describe('sessionStorage', () => { + it('값을 저장하고 가져온다', () => { + setSessionItem('session', { token: 'xyz' }); + expect(getSessionItem('session')).toEqual({ token: 'xyz' }); + }); + + it('존재하지 않는 키는 null을 반환한다', () => { + expect(getSessionItem('missing')).toBeNull(); + }); + + it('항목을 삭제하면 null을 반환한다', () => { + setSessionItem('state', 'active'); + removeSessionItem('state'); + expect(getSessionItem('state')).toBeNull(); + }); + }); +});