diff --git a/.codex/skills/test-convention/SKILL.md b/.codex/skills/test-convention/SKILL.md
new file mode 100644
index 0000000..e9f4a5e
--- /dev/null
+++ b/.codex/skills/test-convention/SKILL.md
@@ -0,0 +1,80 @@
+---
+name: test-convention
+description: Write or review unit tests following the project test convention. Use when writing tests, reviewing test code, or when the user asks to add/fix/refactor tests. Trigger on "write test for", "test this function", "add unit tests", "테스트 작성", "테스트 추가", "테스트 리뷰". Do NOT use for integration tests, E2E tests, or component rendering tests.
+---
+
+# Unit Test Convention
+
+Use this skill for project unit tests only. Do not apply it to integration tests, E2E tests, or component rendering tests unless the user explicitly asks to adapt the convention.
+
+This project uses Vitest. Use `vi.` helpers such as `vi.useFakeTimers()`, `vi.setSystemTime()`, and `vi.fn()`. If a reference example uses `jest.`, translate it to the Vitest equivalent.
+
+## Workflow
+
+1. Identify the target source or test file from the user's request.
+2. Read the target source code and nearby existing tests before editing.
+3. Check whether the target is unit-testable:
+ - Time dependencies: `new Date()` or `Date.now()` inside the function body
+ - API or network dependencies: `fetch`, axios, or other HTTP calls
+ - Runtime dependencies: `window`, `document`, `localStorage`, or browser APIs
+ - Global mutable state: module-level mutable variables or singletons
+4. If hard dependencies are mixed with business logic, prefer extracting pure logic before testing. Do not add excessive mocks to force-test impure code.
+5. Read the relevant references below, then write or review tests according to them.
+6. For hooks or utilities that may run in both server and browser contexts, include SSR and DOM-context test coverage unless the target is explicitly environment-specific.
+7. Run the narrowest relevant test command through the repo package manager. If the command is unclear, inspect `package.json` and existing scripts first.
+8. Report what changed and which verification command ran.
+
+## References
+
+Reference docs live in `references/`. Load only the files needed for the current task.
+
+- `references/0-unit-test-standard.md`: Testable function design, dependency separation, pure logic extraction.
+- `references/1-unit-test-convention.md`: Test file naming, `describe`/`test` structure, Given-When-Then comments, matcher guidance.
+- `references/2-time-test.md`: Time-dependent logic, fake timers, fixed system time, timezone-sensitive tests.
+- `references/3-parameterized-test.md`: `test.each` patterns, table format, array format, boundary-focused cases.
+
+## Core Rules
+
+- Use `.test.ts` or `.test.tsx`; do not create `.spec` files.
+- Use `test()`, not `it()`.
+- Use `describe(functionName.name, ...)` when a function identifier is available.
+- Import `act`, `hooksCleanup`, `renderHook`, and `renderHookServer` from `react-use-hook-kit-testing` for hook tests.
+- Include `// given`, `// when`, and `// then` comments in each test block.
+- Name tests by concrete use case, not vague behavior.
+- Name the primary result variable `actual`.
+- Prefer boundary values over redundant middle cases.
+- For list-like inputs, cover 0, 1, and 2 items unless more cases are materially different.
+- Use `test.each` for repeated input-output mappings.
+- Use inline snapshots for complex object, array, JSON, or multi-line string assertions when that improves maintainability.
+- Assert core behavior only; avoid fragile assertions on incidental fields.
+
+## Time Tests
+
+- Prefer passing time as an explicit parameter when designing testable pure functions.
+- When the implementation intentionally reads current time, use `vi.useFakeTimers()` and `vi.setSystemTime()`.
+- Always restore real timers with `vi.useRealTimers()` in `afterEach`.
+- Use explicit timestamps and timezones for timezone-sensitive assertions.
+
+## SSR and DOM Tests
+
+- When a hook or utility depends on browser APIs, test both SSR-safe behavior and DOM behavior.
+- SSR tests should verify that importing or invoking the target without `window`/`document` does not throw and returns the expected server-side fallback.
+- DOM tests should verify the browser path with an appropriate DOM environment and realistic browser API setup.
+- Keep SSR and DOM expectations in separate tests so each runtime contract is clear.
+- If only one runtime applies, state that in the test or final report instead of adding artificial coverage.
+
+## Review Checklist
+
+After writing or reviewing tests, verify:
+
+- [ ] The target logic is appropriate for a unit test.
+- [ ] The test file extension is `.test.ts` or `.test.tsx`.
+- [ ] Tests use `test()`, not `it()`.
+- [ ] Hook tests import `act`, `hooksCleanup`, `renderHook`, and `renderHookServer` from `react-use-hook-kit-testing`.
+- [ ] Tests use Given-When-Then comments.
+- [ ] The main result variable is named `actual`.
+- [ ] Repetitive cases use `test.each`.
+- [ ] Time-dependent tests use Vitest fake timers and restore real timers.
+- [ ] Hooks or utilities with browser/runtime branching cover both SSR and DOM behavior.
+- [ ] Assertions focus on stable, meaningful behavior.
+- [ ] The relevant test command was run or the reason it could not run is documented.
diff --git a/.codex/skills/test-convention/agents/openai.yaml b/.codex/skills/test-convention/agents/openai.yaml
new file mode 100644
index 0000000..49dc892
--- /dev/null
+++ b/.codex/skills/test-convention/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: 'Test Convention'
+ short_description: 'Write unit tests using project conventions.'
+ default_prompt: 'Use $test-convention to add or review unit tests for this target.'
diff --git a/.codex/skills/test-convention/references/0-unit-test-standard.md b/.codex/skills/test-convention/references/0-unit-test-standard.md
new file mode 100644
index 0000000..76d4c90
--- /dev/null
+++ b/.codex/skills/test-convention/references/0-unit-test-standard.md
@@ -0,0 +1,1036 @@
+---
+title: "0. Unit Test Standard"
+description: "테스트 가능한 함수 설계와 시간 의존성 분리 등 안정적인 단위 테스트를 위한 핵심 원칙을 정리합니다."
+type: reference
+tags: [Testing, BestPractice]
+order: 0
+---
+
+# 0. Unit Test Standard
+
+## 1. 테스트 코드 작성 이전에 함수 자체를 testable하게 만들고 테스트 코드를 작성한다
+
+**원칙의 의도**: 테스트하기 어려운 함수는 좋은 테스트를 작성할 수 없다. 테스트 작성 전에 함수 자체를 testable하게 설계하여 멱등성을 보장하고 신뢰할 수 있는 테스트를 만든다.
+
+**해결하려는 문제**:
+- 실행 시점에 따라 테스트 결과가 달라지는 문제
+- 외부 의존성(API, 시간, 브라우저 API)으로 인한 테스트 불안정성
+- 사이드이펙트와 비즈니스 로직이 섞여 있어 핵심 로직을 테스트하기 어려운 문제
+- CI/CD 환경과 로컬 환경에서 테스트 결과가 다른 문제
+
+### 시간 의존성 분리
+
+#### ❌ Do Not
+```typescript
+// 시간 의존성이 있어 테스트하기 어려운 함수
+const formatOrderTime = (orderData: OrderData) => {
+ return {
+ ...orderData,
+ createdAt: new Date().toISOString(), // 실행 시점마다 다른 결과
+ displayTime: new Date().toLocaleString('ko-KR'), // 타임존에 따라 다른 결과
+ businessDay: isBusinessDay(new Date()) // 실행 날짜에 따라 다른 결과
+ };
+};
+
+// 테스트가 실행 환경과 시점에 따라 실패
+describe('formatOrderTime', () => {
+ it('주문 시간을 포맷팅한다', () => {
+ const order = { id: '1', amount: 1000 };
+ const result = formatOrderTime(order);
+
+ // 로컬(KST): 2024-01-01 09:00:00 통과
+ // CI(UTC): 2024-01-01 00:00:00 실패
+ expect(result.displayTime).toContain('2024-01-01 09:00:00');
+
+ // 평일 실행: true, 주말 실행: false
+ expect(result.businessDay).toBe(true);
+ });
+});
+```
+
+#### ✅ Do
+```typescript
+// 시간을 매개변수로 받아 testable하게 만든 함수
+const formatOrderTime = (orderData: OrderData, currentTime: Date) => {
+ return {
+ ...orderData,
+ createdAt: currentTime.toISOString(),
+ displayTime: currentTime.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }),
+ businessDay: isBusinessDay(currentTime)
+ };
+};
+
+// 테스트가 환경과 시점에 무관하게 안정적
+describe(formatOrderTime.name, () => {
+ it('주문 시간을 한국 시간으로 포맷팅한다', () => {
+ // given
+ const order = { id: '1', amount: 1000 };
+ const fixedTime = new Date('2024-01-01T00:00:00.000Z'); // 고정된 UTC 시간
+
+ // when
+ const result = formatOrderTime(order, fixedTime);
+
+ // then
+ expect(result.createdAt).toBe('2024-01-01T00:00:00.000Z');
+ expect(result.displayTime).toBe('2024. 1. 1. 오전 9:00:00'); // 항상 일정한 KST 결과
+ expect(result.businessDay).toBe(true); // 2024-01-01은 월요일(평일)
+ });
+});
+```
+
+### API 호출과 비즈니스 로직 분리
+
+#### ❌ Do Not
+```typescript
+// API 호출과 비즈니스 로직이 섞여있어 테스트하기 어려운 함수
+const processUserData = async (userId: string) => {
+ const userData = await fetchUser(userId); // API 호출 - 외부 의존성
+ const formattedName = `${userData.lastName}${userData.firstName}`; // 비즈니스 로직
+ const age = new Date().getFullYear() - new Date(userData.birthDate).getFullYear(); // 시간 의존성
+
+ return {
+ displayName: formattedName,
+ age: age,
+ createdAt: new Date().toISOString() // 런타임 의존성
+ };
+};
+```
+
+#### ✅ Do
+```typescript
+// 비즈니스 로직만 담은 순수 함수 (테스트 가능)
+const formatUserData = (userData: UserData, currentDate: string) => {
+ return {
+ displayName: `${userData.lastName}${userData.firstName}`,
+ age: new Date(currentDate).getFullYear() - new Date(userData.birthDate).getFullYear(),
+ isAdult: new Date(currentDate).getFullYear() - new Date(userData.birthDate).getFullYear() >= 20
+ };
+};
+
+// API 호출 로직은 별도 분리 (integration test에서 테스트)
+const processUserData = async (userId: string) => {
+ const userData = await fetchUser(userId); // API 호출
+ const currentDate = new Date().toISOString(); // 시간 의존성
+ return formatUserData(userData, currentDate); // 순수 함수 호출
+};
+```
+
+### 런타임 의존성과 비즈니스 로직 분리
+
+#### ❌ Do Not
+```typescript
+// 런타임(브라우저) 의존성이 있어 테스트하기 어려운 함수
+const saveToLocalStorage = (key: string, data: any) => {
+ const serialized = JSON.stringify(data); // 비즈니스 로직
+ localStorage.setItem(key, serialized); // 브라우저 API 의존성
+ return serialized;
+};
+```
+
+#### ✅ Do
+```typescript
+// 비즈니스 로직만 담은 순수 함수 (테스트 가능)
+const serializeData = (data: any) => {
+ return JSON.stringify(data);
+};
+
+// 런타임 의존성은 별도 분리
+const saveToLocalStorage = (key: string, data: any) => {
+ const serialized = serializeData(data); // 순수 함수 호출
+ localStorage.setItem(key, serialized); // 런타임 의존성
+ return serialized;
+};
+```
+
+### React Query select를 활용한 데이터 변환 로직 분리
+
+#### ❌ Do Not
+```typescript
+// React Query를 사용하지만 select 없이 컴포넌트에서 데이터 변환하는 경우
+const useUserProfile = (userId: string) => {
+ return useSuspenseQuery({
+ queryKey: ['user', userId],
+ queryFn: () => fetchUser(userId),
+ // select 옵션 없이 raw data 반환
+ });
+};
+
+const UserProfileComponent = ({ userId }: { userId: string }) => {
+ const { data: userData } = useUserProfile(userId);
+
+ // 비즈니스 로직이 컴포넌트에 섞여있어 테스트하기 어려움
+ const displayName = `${userData.lastName}${userData.firstName}`;
+ const age = new Date().getFullYear() - new Date(userData.birthDate).getFullYear();
+ const isAdult = age >= 20;
+
+ return (
+
+
{displayName}
+
나이: {age}
+
{isAdult ? '성인' : '미성년자'}
+
+ );
+};
+```
+
+#### ✅ Do
+```typescript
+// React Query select를 활용한 데이터 변환 로직 분리
+const selectUserDisplayData = (apiResponse: UserApiResponse) => {
+ return {
+ displayName: `${apiResponse.lastName}${apiResponse.firstName}`,
+ age: calculateAge(apiResponse.birthDate, '2024-01-01'), // 고정된 기준일 사용
+ isAdult: calculateAge(apiResponse.birthDate, '2024-01-01') >= 20
+ };
+};
+
+// React Query에서 사용
+const useUserProfile = (userId: string) => {
+ return useSuspenseQuery({
+ queryKey: ['user', userId],
+ queryFn: () => fetchUser(userId),
+ select: selectUserDisplayData // 비즈니스 로직만 분리하여 테스트 가능
+ });
+};
+```
+
+### 전역 상태 의존성 제거
+
+#### ❌ Do Not
+```typescript
+// 전역 상태에 의존하는 함수
+let globalCounter = 0;
+const incrementCounter = () => {
+ globalCounter++; // 전역 상태 변경 - 테스트 실행 순서에 의존
+ return globalCounter;
+};
+```
+
+#### ✅ Do
+```typescript
+// 상태를 매개변수로 받고 새로운 상태를 반환하는 순수 함수
+const incrementCounter = (currentValue: number) => {
+ return currentValue + 1;
+};
+```
+
+## 2. 테스트 코드를 통해 테스트 대상 코드에 대한 유즈케이스를 명확히 이해할 수 있다
+
+**원칙의 의도**: 테스트 코드가 함수의 사용 설명서 역할을 하도록 한다. 테스트만 읽어도 함수의 모든 사용 사례와 동작 방식을 이해할 수 있게 만든다.
+
+**해결하려는 문제**:
+- 함수의 사용법을 파악하기 위해 구현 코드를 일일이 읽어야 하는 문제
+- 함수의 예외 상황이나 경계 조건을 놓치기 쉬운 문제
+- 새로운 개발자가 기존 코드의 동작 방식을 이해하기 어려운 문제
+- 테스트명이 모호하여 실제 동작을 예측할 수 없는 문제
+
+### 모호한 테스트명 문제
+
+#### ❌ Do Not
+```typescript
+// 모호한 테스트명으로 Use Case를 파악할 수 없음
+describe('calculateLoanRate', () => {
+ it('금리를 계산한다', () => {
+ const result = calculateLoanRate(mockCustomer, 1000000);
+ expect(result).toBe(50000);
+ });
+});
+// 이 테스트만으로는 어떤 고객이 어떤 금리를 받는지 알 수 없음
+```
+
+#### ✅ Do
+```typescript
+// 구체적인 Use Case가 명확한 테스트명 사용
+describe(calculateLoanRate.name, () => {
+ it('일반 고객의 기본 금리 5%를 계산한다', () => {
+ // given
+ const customer = customerFactory.build({ grade: 'NORMAL', creditScore: 700 });
+
+ // when
+ const result = calculateLoanRate(customer, 1000000);
+
+ // then
+ expect(result).toBe(50000); // 5% 금리
+ });
+
+ it('VIP 고객에게 우대 금리 3%를 적용한다', () => {
+ // given
+ const vipCustomer = customerFactory.build({ grade: 'VIP', creditScore: 800 });
+
+ // when
+ const result = calculateLoanRate(vipCustomer, 1000000);
+
+ // then
+ expect(result).toBe(30000); // 3% 금리
+ });
+});
+```
+
+### 예외 상황 테스트 누락 문제
+
+#### ❌ Do Not
+```typescript
+// 예외 상황이나 경계 조건이 명시되지 않음
+describe('processPayment', () => {
+ it('결제를 처리한다', () => {
+ const result = processPayment(paymentData);
+ expect(result.success).toBe(true);
+ });
+});
+// 실패 케이스나 특별한 조건들이 테스트에서 드러나지 않음
+```
+
+#### ✅ Do
+```typescript
+// 예외 상황과 경계 조건을 명시적으로 테스트
+describe(processPayment.name, () => {
+ it('유효한 결제 정보로 결제를 성공한다', () => {
+ // given
+ const validPaymentData = paymentFactory.build({
+ amount: 10000,
+ cardNumber: '1234-5678-9012-3456'
+ });
+
+ // when
+ const result = processPayment(validPaymentData);
+
+ // then
+ expect(result.success).toBe(true);
+ expect(result.transactionId).toBeDefined();
+ });
+
+ it('잔액 부족 시 결제를 실패한다', () => {
+ // given
+ const insufficientPaymentData = paymentFactory.build({
+ amount: 1000000, // 잔액보다 큰 금액
+ cardNumber: '1234-5678-9012-3456'
+ });
+
+ // when
+ const result = processPayment(insufficientPaymentData);
+
+ // then
+ expect(result.success).toBe(false);
+ expect(result.errorCode).toBe('INSUFFICIENT_BALANCE');
+ });
+});
+```
+
+## 3. 효율적인 커버리지
+
+**원칙의 의도**: 테스트는 많다고 좋은 것이 아니다. 최소한의 테스트로 최대한의 신뢰성을 확보하여 유지보수 비용을 줄이고 개발 생산성을 높인다.
+
+**해결하려는 문제**:
+- 불필요한 중복 테스트로 인한 유지보수 부담 증가
+- 테스트 실행 시간이 길어져 개발 생산성 저하
+- 중요하지 않은 테스트가 많아 핵심 로직을 놓치기 쉬운 문제
+- 테스트 코드 자체가 버그의 원인이 되는 문제
+
+### 불필요한 중복 테스트 문제
+
+#### ❌ Do Not
+```typescript
+// 경계값이 아닌 중간값들만 반복 테스트하여 진짜 버그를 놓침
+describe('validateAge', () => {
+ it('25세는 성인이다', () => {
+ expect(validateAge(25)).toBe('adult');
+ });
+
+ it('30세는 성인이다', () => {
+ expect(validateAge(30)).toBe('adult');
+ });
+
+ it('35세는 성인이다', () => {
+ expect(validateAge(35)).toBe('adult');
+ });
+
+ it('40세는 성인이다', () => {
+ expect(validateAge(40)).toBe('adult');
+ });
+ // 모두 성인 범위 안의 중간값만 테스트, 경계값(20세, 19세)은 테스트 안함
+ // 실제 버그는 경계값에서 발생할 가능성이 높음
+});
+```
+
+#### ✅ Do
+```typescript
+// 경계값과 핵심 케이스에 집중한 효과적인 테스트
+describe(validateAge.name, () => {
+ it('정상 성인 나이를 검증한다', () => {
+ // given
+ const age = 25; // 일반적인 케이스
+
+ // when
+ const result = validateAge(age);
+
+ // then
+ expect(result).toBe('adult');
+ });
+
+ it('성인 경계값(20세)을 올바르게 처리한다', () => {
+ // given
+ const age = 20; // 경계값 - 성인 최소 나이
+
+ // when
+ const result = validateAge(age);
+
+ // then
+ expect(result).toBe('adult');
+ });
+
+ it('미성년자 경계값(19세)을 올바르게 처리한다', () => {
+ // given
+ const age = 19; // 경계값 - 미성년자 최대 나이
+
+ // when
+ const result = validateAge(age);
+
+ // then
+ expect(result).toBe('minor');
+ });
+
+ it('유효하지 않은 나이인 경우 에러를 발생시킨다', () => {
+ // given
+ const invalidAge = -1; // 경계값 - 최소값 미만
+
+ // when & then
+ expect(() => validateAge(invalidAge)).toThrow(AssertError);
+ });
+});
+```
+
+### 리스트 데이터 테스트에서 불필요한 케이스 반복 문제
+
+#### ❌ Do Not
+```typescript
+// 리스트 크기별로 의미 없는 테스트 반복
+describe('calculateTotalPrice', () => {
+ it('1개 상품의 총 금액을 계산한다', () => {
+ const items = [{ price: 1000 }];
+ expect(calculateTotalPrice(items)).toBe(1000);
+ });
+
+ it('2개 상품의 총 금액을 계산한다', () => {
+ const items = [{ price: 1000 }, { price: 2000 }];
+ expect(calculateTotalPrice(items)).toBe(3000);
+ });
+
+ it('3개 상품의 총 금액을 계산한다', () => {
+ const items = [{ price: 1000 }, { price: 2000 }, { price: 3000 }];
+ expect(calculateTotalPrice(items)).toBe(6000);
+ });
+
+ it('4개 상품의 총 금액을 계산한다', () => {
+ const items = [{ price: 1000 }, { price: 2000 }, { price: 3000 }, { price: 4000 }];
+ expect(calculateTotalPrice(items)).toBe(10000);
+ });
+ // 3개, 4개, 5개... 테스트는 2개 테스트와 동일한 로직만 반복
+});
+```
+
+#### ✅ Do
+```typescript
+// 리스트 처리의 핵심 경계값만 테스트: 0개, 1개, 2개
+describe(calculateTotalPrice.name, () => {
+ it('빈 배열(0개)인 경우 0을 반환한다', () => {
+ // given - 경계값: 빈 배열 처리 로직 검증
+ const items = [];
+
+ // when
+ const result = calculateTotalPrice(items);
+
+ // then
+ expect(result).toBe(0);
+ });
+
+ it('단일 상품(1개)의 총 금액을 계산한다', () => {
+ // given - 최소 정상 케이스: 기본 로직 작동 확인
+ const items = [{ price: 1000 }];
+
+ // when
+ const result = calculateTotalPrice(items);
+
+ // then
+ expect(result).toBe(1000);
+ });
+
+ it('여러 상품(2개)의 총 금액을 계산한다', () => {
+ // given - 반복 로직 검증: 루프가 올바르게 작동하는지 확인
+ const items = [{ price: 1000 }, { price: 2000 }];
+
+ // when
+ const result = calculateTotalPrice(items);
+
+ // then
+ expect(result).toBe(3000);
+ // 2개로 반복 로직을 검증했다면, 3개, 4개... N개도 동일하게 작동함
+ });
+
+ it('잘못된 price 값이 있는 경우 에러를 발생시킨다', () => {
+ // given - 예외 케이스
+ const items = [{ price: null }];
+
+ // when & then
+ expect(() => calculateTotalPrice(items)).toThrow(AssertError);
+ });
+});
+```
+
+### 핵심이 아닌 세부 케이스 테스트 문제
+
+#### ❌ Do Not
+```typescript
+// 핵심이 아닌 세부 케이스들로 시간 낭비
+describe('formatUserData', () => {
+ it('이름을 포맷팅한다', () => { /* 복잡한 설정 */ });
+ it('이름을 대문자로 포맷팅한다', () => { /* 복잡한 설정 */ });
+ it('이름을 소문자로 포맷팅한다', () => { /* 복잡한 설정 */ });
+ it('이름을 카멜케이스로 포맷팅한다', () => { /* 복잡한 설정 */ });
+ // 중요하지 않은 세부 케이스들만 테스트
+});
+```
+
+#### ✅ Do
+```typescript
+// 비즈니스 로직의 핵심 분기점에 집중
+describe(formatUserData.name, () => {
+ it('유효한 사용자 데이터를 포맷팅한다', () => {
+ // given
+ const userData = userFactory.build({
+ firstName: '길동',
+ lastName: '홍',
+ birthDate: '1990-01-01'
+ });
+
+ // when
+ const result = formatUserData(userData);
+
+ // then
+ expect(result.displayName).toBe('홍길동');
+ expect(result.isAdult).toBe(true);
+ });
+
+ it('필수 필드가 누락된 경우 에러를 발생시킨다', () => {
+ // given
+ const invalidUserData = userFactory.build({
+ firstName: undefined,
+ lastName: '홍'
+ });
+
+ // when & then
+ expect(() => formatUserData(invalidUserData)).toThrow(AssertError);
+ });
+});
+```
+
+## 4. 유지보수 편의성 및 가독성
+
+**원칙의 의도**: 테스트가 개발을 방해하는 요소가 되지 않도록 한다. 코드 변경 시 관련 없는 테스트가 깨지지 않게 하여 개발자가 테스트를 신뢰하고 유지할 수 있게 만든다.
+
+**해결하려는 문제**:
+- 코드 변경 시 관련 없는 테스트까지 깨져서 개발 속도가 저하되는 문제
+- 테스트가 자주 깨져 개발자들이 테스트를 무시하거나 삭제하게 되는 문제
+- 테스트 코드 수정이 어려워 기능 개발보다 테스트 수정에 더 많은 시간이 소요되는 문제
+- 복잡한 의존성으로 인해 테스트 자체가 불안정해지는 문제
+- 거대한 객체를 inline snapshot으로 처리하여 테스트 파일이 비대해지고 가독성이 떨어지는 문제
+- 테스트 데이터가 무엇을 표현하려는지 파악하기 어려운 문제
+
+### 다른 코드 변경으로 깨지기 쉬운 테스트 문제
+
+#### ❌ Do Not
+```typescript
+// 다른 코드 변경으로 깨지기 쉬운 테스트
+describe('calculateDiscount', () => {
+ it('할인을 계산한다', () => {
+ const result = calculateDiscount(mockCustomer, 100000);
+ expect(result).toEqual({
+ discountRate: 0.2,
+ finalAmount: 80000,
+ appliedAt: getCurrentTime(), // 시간 변경으로 깨짐
+ metadata: mockCustomer.fullProfile // 다른 필드 추가로 깨짐
+ });
+ });
+});
+```
+
+#### ✅ Do
+```typescript
+// 핵심 필드만 검증하여 변경에 강한 테스트
+describe(calculateDiscount.name, () => {
+ it('VIP 고객에게 20% 할인을 적용한다', () => {
+ // given
+ const customer = customerFactory.build({ grade: 'VIP' });
+ const amount = 100000;
+
+ // when
+ const result = calculateDiscount(customer, amount);
+
+ // then
+ expect(result.discountRate).toBe(0.2);
+ expect(result.finalAmount).toBe(80000);
+ // 시간이나 메타데이터 같은 부차적인 필드는 검증하지 않음
+ });
+});
+```
+
+### Mock 데이터 가독성 및 유지보수 문제
+
+#### ❌ Do Not
+```typescript
+// inline으로 큰 데이터를 작성하여 테스트 의도가 불분명하고 유지보수 어려움
+describe('calculateLoanEligibility', () => {
+ it('대출 자격을 검증한다', () => {
+ // given - 어떤 데이터가 중요한지 파악하기 어려움
+ const userData = {
+ id: '12345',
+ name: '홍길동',
+ birthDate: '1990-01-01',
+ address: {
+ city: '서울',
+ district: '강남구',
+ street: '테헤란로 123',
+ zipCode: '12345'
+ },
+ employment: {
+ company: '테스트회사',
+ position: '개발자',
+ salary: 5000000,
+ workPeriod: 36
+ },
+ creditHistory: {
+ score: 750,
+ records: [
+ { type: 'loan', amount: 1000000, status: 'completed' },
+ { type: 'card', amount: 500000, status: 'active' }
+ ]
+ },
+ assets: {
+ savings: 10000000,
+ realEstate: 50000000
+ }
+ };
+
+ // when
+ const result = calculateLoanEligibility(userData);
+
+ // then - 테스트 의도를 파악하기 어려움
+ expect(result.eligible).toBe(true);
+ });
+
+ it('저신용자는 대출 자격이 없다', () => {
+ // given - 새로운 케이스 추가 시 모든 필드를 다시 작성해야 함
+ const userData = {
+ id: '12346', // 일일이 변경
+ name: '김철수', // 일일이 변경
+ birthDate: '1985-05-15', // 일일이 변경
+ address: {
+ city: '부산', // 일일이 변경
+ district: '해운대구', // 일일이 변경
+ street: '해운대로 456', // 일일이 변경
+ zipCode: '48000' // 일일이 변경
+ },
+ employment: {
+ company: '다른회사', // 일일이 변경
+ position: '디자이너', // 일일이 변경
+ salary: 3000000, // 일일이 변경
+ workPeriod: 24 // 일일이 변경
+ },
+ creditHistory: {
+ score: 400, // 핵심 변경사항 - 하지만 다른 불필요한 변경들에 묻힘
+ records: [
+ { type: 'loan', amount: 500000, status: 'overdue' } // 일일이 변경
+ ]
+ },
+ assets: {
+ savings: 1000000, // 일일이 변경
+ realEstate: 0 // 일일이 변경
+ }
+ };
+
+ // when
+ const result = calculateLoanEligibility(userData);
+
+ // then
+ expect(result.eligible).toBe(false);
+ });
+});
+```
+
+#### ✅ Do
+```typescript
+// Factory를 사용하여 테스트 의도를 명확히 표현
+describe(calculateLoanEligibility.name, () => {
+ it('고소득 직장인은 대출 자격이 있다', () => {
+ // given - 테스트에 중요한 부분만 명시적으로 표현
+ const highIncomeUser = userDataFactory.build({
+ employment: employmentFactory.build({
+ salary: 8000000, // 고소득 - 테스트 의도가 명확
+ workPeriod: 48 // 장기 근무 - 테스트 의도가 명확
+ }),
+ creditHistory: creditHistoryFactory.build({
+ score: 850 // 높은 신용점수 - 테스트 의도가 명확
+ })
+ // 나머지 필드들은 기본값으로 자동 생성
+ });
+
+ // when
+ const result = calculateLoanEligibility(highIncomeUser);
+
+ // then
+ expect(result.eligible).toBe(true);
+ expect(result.maxAmount).toBe(80000000); // 연봉의 10배
+ });
+
+ it('저신용자는 대출 자격이 없다', () => {
+ // given - 실패 케이스의 핵심 조건만 명시
+ const lowCreditUser = userDataFactory.build({
+ creditHistory: creditHistoryFactory.build({
+ score: 400 // 낮은 신용점수 - 실패 조건이 명확
+ })
+ // 다른 조건들은 기본값(정상값)으로 설정
+ });
+
+ // when
+ const result = calculateLoanEligibility(lowCreditUser);
+
+ // then
+ expect(result.eligible).toBe(false);
+ expect(result.reason).toBe('LOW_CREDIT_SCORE');
+ });
+});
+```
+
+### Jest Mock과 런타임 의존성으로 자주 깨지는 테스트 문제
+
+#### ❌ Do Not
+```typescript
+// Jest mock과 toBeCalled 검증으로 자주 깨지는 테스트
+describe('trackUserAction', () => {
+ beforeEach(() => {
+ // window 객체 mock 설정
+ global.window = Object.create(window);
+ global.window.gtag = jest.fn();
+ global.window.localStorage = {
+ setItem: jest.fn(),
+ getItem: jest.fn()
+ };
+ });
+
+ it('사용자 액션을 추적한다', () => {
+ // given
+ const action = 'click_button';
+ const userId = 'user123';
+
+ // when
+ trackUserAction(action, userId);
+
+ // then - Jest 실행 순서나 다른 테스트의 영향으로 자주 실패
+ expect(window.gtag).toHaveBeenCalledTimes(1);
+ expect(window.gtag).toHaveBeenCalledWith('event', action, {
+ user_id: userId
+ });
+ expect(window.localStorage.setItem).toHaveBeenCalledTimes(1);
+ expect(window.localStorage.setItem).toHaveBeenCalledWith(
+ 'last_action',
+ JSON.stringify({ action, userId, timestamp: expect.any(Number) })
+ );
+ });
+
+ it('중복 액션은 필터링한다', () => {
+ // given
+ const action = 'click_button';
+ window.localStorage.getItem.mockReturnValue(
+ JSON.stringify({ action, timestamp: Date.now() - 1000 })
+ );
+
+ // when
+ trackUserAction(action, 'user123');
+
+ // then - 이전 테스트의 mock 호출 횟수와 섞여서 실패
+ expect(window.gtag).toHaveBeenCalledTimes(0); // 실제로는 이전 테스트 + 현재 = 1회
+ expect(window.localStorage.setItem).not.toHaveBeenCalled(); // mock 상태 초기화 누락으로 실패
+ });
+});
+```
+
+#### ✅ Do
+```typescript
+// 런타임 의존성을 분리하여 안정적인 테스트
+describe(formatTrackingData.name, () => {
+ it('추적 데이터를 올바른 형식으로 변환한다', () => {
+ // given
+ const action = 'click_button';
+ const userId = 'user123';
+ const timestamp = 1640995200000; // 고정된 시간
+
+ // when
+ const result = formatTrackingData(action, userId, timestamp);
+
+ // then - 순수 함수이므로 안정적
+ expect(result).toEqual({
+ event: action,
+ user_id: userId,
+ timestamp: timestamp
+ });
+ });
+
+ it('중복 액션을 필터링해야 하는지 판단한다', () => {
+ // given
+ const currentAction = 'click_button';
+ const currentTime = 1640995200000;
+ const lastAction = { action: 'click_button', timestamp: 1640995199000 }; // 1초 전
+
+ // when
+ const result = shouldFilterDuplicateAction(currentAction, lastAction, currentTime);
+
+ // then - 비즈니스 로직만 테스트하므로 안정적
+ expect(result).toBe(true); // 1초 이내 중복 액션
+ });
+
+ it('시간 차이가 충분한 경우 중복 필터링하지 않는다', () => {
+ // given
+ const currentAction = 'click_button';
+ const currentTime = 1640995200000;
+ const lastAction = { action: 'click_button', timestamp: 1640995195000 }; // 5초 전
+
+ // when
+ const result = shouldFilterDuplicateAction(currentAction, lastAction, currentTime);
+
+ // then
+ expect(result).toBe(false); // 5초 차이로 필터링하지 않음
+ });
+});
+```
+
+### 거대한 객체로 인한 테스트 가독성 문제
+
+#### ❌ Do Not
+```typescript
+// 거대한 객체를 inline snapshot으로 처리하여 가독성이 떨어지는 테스트
+describe('generateUserReport', () => {
+ it('사용자 리포트를 생성한다', () => {
+ // given
+ const userData = userDataFactory.build();
+
+ // when
+ const result = generateUserReport(userData);
+
+ // then - 테스트 파일이 비대해지고 무엇을 검증하려는지 불분명
+ expect(result).toMatchInlineSnapshot(`
+ {
+ "user": {
+ "id": "12345",
+ "name": "홍길동",
+ "email": "hong@example.com",
+ "birthDate": "1990-01-01",
+ "address": {
+ "city": "서울",
+ "district": "강남구",
+ "street": "테헤란로 123",
+ "zipCode": "12345",
+ "coordinates": {
+ "latitude": 37.123456,
+ "longitude": 127.123456
+ }
+ },
+ "preferences": {
+ "language": "ko",
+ "timezone": "Asia/Seoul",
+ "notifications": {
+ "email": true,
+ "sms": false,
+ "push": true
+ }
+ }
+ },
+ "statistics": {
+ "totalOrders": 42,
+ "totalAmount": 1250000,
+ "averageOrderValue": 29761.90,
+ "lastOrderDate": "2024-01-15",
+ "favoriteCategories": ["electronics", "books", "clothing"],
+ "monthlySpending": {
+ "2024-01": 125000,
+ "2024-02": 89000,
+ "2024-03": 156000
+ }
+ },
+ "metadata": {
+ "generatedAt": "2024-03-15T10:30:00.000Z",
+ "version": "1.2.3",
+ "processingTime": 245
+ }
+ }
+ `);
+ // 200줄이 넘는 snapshot으로 테스트 파일이 비대해짐
+ // 실제로 검증하려는 핵심 로직이 무엇인지 파악하기 어려움
+ });
+});
+```
+
+#### ✅ Do
+```typescript
+// Helper 함수로 필요한 데이터만 추출하거나 개별 expect로 검증
+describe(generateUserReport.name, () => {
+ // 방법 1: Helper 함수로 핵심 데이터만 추출
+ it('사용자 리포트의 핵심 정보를 올바르게 생성한다', () => {
+ // given
+ const userData = userDataFactory.build({
+ id: '12345',
+ name: '홍길동'
+ });
+
+ // when
+ const result = generateUserReport(userData);
+
+ // then - Helper 함수로 핵심 정보만 추출하여 검증
+ const essentialData = extractReportEssentials(result);
+ expect(essentialData).toMatchInlineSnapshot(`
+ {
+ "userName": "홍길동",
+ "userId": "12345",
+ "totalOrders": 42,
+ "totalAmount": 1250000,
+ "reportGenerated": true
+ }
+ `);
+ });
+
+ // 방법 2: 개별 expect로 핵심 필드들을 명시적으로 검증
+ it('사용자 통계를 정확히 계산한다', () => {
+ // given
+ const userData = userDataFactory.build();
+
+ // when
+ const result = generateUserReport(userData);
+
+ // then - 각 핵심 로직을 개별적으로 명확히 검증
+ expect(result.user.name).toBe('홍길동');
+ expect(result.statistics.totalOrders).toBe(42);
+ expect(result.statistics.averageOrderValue).toBe(29761.90);
+ expect(result.statistics.favoriteCategories).toEqual(['electronics', 'books', 'clothing']);
+ expect(result.metadata.generatedAt).toBeDefined();
+ });
+});
+
+// Helper 함수 정의
+const extractReportEssentials = (report: UserReport) => ({
+ userName: report.user.name,
+ userId: report.user.id,
+ totalOrders: report.statistics.totalOrders,
+ totalAmount: report.statistics.totalAmount,
+ reportGenerated: !!report.metadata.generatedAt
+});
+```
+
+### DOM 의존성 테스트를 Unit Test에서 다루는 문제
+
+#### ❌ Do Not
+```typescript
+// React Testing Library로 DOM 상호작용을 Unit Test에서 처리
+describe('LoginForm', () => {
+ it('유효하지 않은 이메일 입력 시 에러 메시지를 표시한다', () => {
+ // given
+ render();
+
+ // when
+ const emailInput = screen.getByLabelText('이메일'); // 접근성 속성 변경 시 깨짐
+ const submitButton = screen.getByRole('button', { name: '로그인' }); // 버튼 텍스트 변경 시 깨짐
+
+ fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
+ fireEvent.click(submitButton);
+
+ // then - DOM 구조나 텍스트 변경에 매우 취약
+ expect(screen.getByText('올바른 이메일 형식을 입력해주세요')).toBeInTheDocument();
+ // 실행 시간이 오래 걸림 (DOM 렌더링 + 이벤트 처리)
+ // 접근성 속성 누락 시 테스트 실패
+ });
+
+ it('로그인 버튼 클릭 시 로딩 상태를 표시한다', () => {
+ // given
+ const mockSubmit = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)));
+ render();
+
+ // when
+ fireEvent.click(screen.getByRole('button', { name: '로그인' }));
+
+ // then - DOM 의존성으로 인해 불안정하고 느림
+ expect(screen.getByText('로그인 중...')).toBeInTheDocument();
+ expect(screen.getByRole('button')).toBeDisabled();
+ });
+});
+```
+
+#### ✅ Do
+```typescript
+// Unit Test: 순수 로직만 검증
+describe(validateEmail.name, () => {
+ it('유효한 이메일 형식을 올바르게 검증한다', () => {
+ // given
+ const validEmail = 'user@example.com';
+
+ // when
+ const result = validateEmail(validEmail);
+
+ // then - 빠르고 안정적인 순수 함수 테스트
+ expect(result.isValid).toBe(true);
+ expect(result.errorMessage).toBeNull();
+ });
+
+ it('유효하지 않은 이메일에 대해 적절한 에러 메시지를 반환한다', () => {
+ // given
+ const invalidEmail = 'invalid-email';
+
+ // when
+ const result = validateEmail(invalidEmail);
+
+ // then
+ expect(result.isValid).toBe(false);
+ expect(result.errorMessage).toBe('올바른 이메일 형식을 입력해주세요');
+ });
+ });
+
+describe(getLoginButtonState.name, () => {
+ it('로딩 중일 때 버튼 상태를 올바르게 반환한다', () => {
+ // given
+ const isLoading = true;
+ const isValid = true;
+
+ // when
+ const result = getLoginButtonState(isLoading, isValid);
+
+ // then - DOM 없이 순수 로직만 테스트
+ expect(result.disabled).toBe(true);
+ expect(result.text).toBe('로그인 중...');
+ });
+
+ it('유효하지 않은 입력일 때 버튼을 비활성화한다', () => {
+ // given
+ const isLoading = false;
+ const isValid = false;
+
+ // when
+ const result = getLoginButtonState(isLoading, isValid);
+
+ // then
+ expect(result.disabled).toBe(true);
+ expect(result.text).toBe('로그인');
+ });
+});
+
+// 통합 테스트에서 다뤄야 할 내용:
+// - DOM 렌더링 결과
+// - 사용자 상호작용 (클릭, 입력 등)
+// - 접근성 속성 검증
+// - 컴포넌트 간 상호작용
+// - 실제 브라우저 환경에서의 동작
+```
+
+
+
+## 세부 가이드라인
+
+세부 사항들은 별도 파일에서 다룹니다:
+- 테스트 목 데이터: `unit-test-mock-data.md`
+- 테스트 컨벤션: `unit-test-convention.md`
+- 시간 의존성 테스트: `unit-test-time.md`
diff --git a/.codex/skills/test-convention/references/1-unit-test-convention.md b/.codex/skills/test-convention/references/1-unit-test-convention.md
new file mode 100644
index 0000000..e83fa1b
--- /dev/null
+++ b/.codex/skills/test-convention/references/1-unit-test-convention.md
@@ -0,0 +1,421 @@
+---
+title: "1. Unit Test Convention"
+description: "테스트 파일 구조와 네이밍, Given-When-Then 패턴, matcher 사용 규칙을 정리합니다."
+type: reference
+tags: [Testing, BestPractice]
+order: 1
+---
+
+# 1. Unit Test Convention
+
+## 테스트 파일 구조
+
+### 파일 명명 규칙
+
+테스트 파일은 `.spec`이 아닌 `.test` 확장자로 통일합니다.
+
+```
+src/utils/format-date.ts → src/utils/format-date.test.ts
+src/components/button.tsx → src/components/button.test.tsx
+```
+
+#### `.test` vs `.spec` 사용 이유
+
+`.spec` 대신 `.test`를 사용하는 이유는 `test` vs `it` 사용 이유와 동일합니다:
+
+1. **보편성**: `spec`은 특정 맥락(specification)을 가진 용어이지만, `test`는 언어와 도메인에 관계없이 보편적
+2. **명확성**: `test`는 직관적이고 누구나 이해할 수 있는 명확한 의미
+3. **일관성**: 함수명도 `test()`, 파일명도 `.test`로 통일하여 일관된 네이밍 컨벤션 유지
+
+## Test Structure
+
+### Describe와 Test 구조
+
+```typescript
+describe(함수명.name, () => {
+ test('구체적인 상황에서 예상되는 결과를 설명한다', () => {
+ // given - 테스트 준비
+ const input = 'test input';
+
+ // when - 실행
+ const result = 함수명(input);
+
+ // then - 검증
+ expect(result).toBe(expectedValue);
+ });
+});
+```
+
+#### `test` vs `it` 사용 이유
+
+`it` 대신 `test`를 사용하는 이유:
+
+1. **표준 레퍼런스**: [Jest 공식 문서](https://jestjs.io/docs/getting-started), [Node.js Test Runner](https://nodejs.org/api/test.html#describe-and-it-aliases), [Vitest](https://vitest.dev/guide/) 모두 `test`를 기본으로 사용
+2. **LLM 학습 데이터**: 더 많은 레퍼런스와 예제에서 `test`가 사용되어 AI 도구들이 더 정확한 코드를 생성
+3. **가독성**: `it`은 맥락 의존적이고 영어권이 아닌 개발자나 타 직군에게 이해하기 어려움
+4. **명확성**: `test`는 직관적이고 언어에 관계없이 테스트임을 명확히 표현
+
+### Given-When-Then 패턴
+
+- **Given**: 테스트에 필요한 데이터와 상태를 준비
+- **When**: 테스트할 함수나 메서드를 실행
+- **Then**: 결과를 검증
+
+```typescript
+describe('calculateTax', () => {
+ test('일반 소득에 대해 세율 10%를 적용한다', () => {
+ // given - 테스트 데이터 준비
+ const income = 50000;
+ const taxRate = 0.1;
+
+ // when - 함수 실행
+ const result = calculateTax(income, taxRate);
+
+ // then - 결과 검증
+ expect(result.taxAmount).toBe(5000);
+ expect(result.netIncome).toBe(45000);
+ });
+
+ test('소득이 0일 때 세금을 0으로 계산한다', () => {
+ // given
+ const income = 0;
+ const taxRate = 0.1;
+
+ // when
+ const result = calculateTax(income, taxRate);
+
+ // then
+ expect(result.taxAmount).toBe(0);
+ expect(result.netIncome).toBe(0);
+ });
+});
+```
+
+#### 중첩 구조나 별도 라이브러리 대신 주석을 사용하는 이유
+
+1. **가독성**: 실제로 보는 것은 터미널 출력이 아닌 테스트 코드 자체이므로, 중첩 구조는 오히려 가독성을 떨어뜨림
+2. **작성 편의성**: nested 문법의 열고 닫는 괄호 실수를 방지하고 코드 작성이 더 간단함
+3. **의존성 최소화**: 별도 유틸 라이브러리는 불필요한 오버헤드이며, 테스트에서 의존성 문제로 예기치 않게 깨질 위험이 있음
+4. **충분한 표현력**: Given-When-Then 주석만으로도 테스트 구조를 명확히 표현 가능
+5. **LLM 친화적**: 최근 LLM이 테스트 코드를 작성하는 경우가 많아졌는데, 주석 기반 구조가 더 일관되고 이해하기 쉬움
+
+## 테스트 코드에 사용되는 변수명
+
+### 기본 변수명 규칙
+
+테스트 코드에서는 일관된 변수명을 사용하여 가독성을 높입니다.
+
+```typescript
+describe('calculateDiscount', () => {
+ test('VIP 고객에게 20% 할인을 적용한다', () => {
+ // given
+ const input = {
+ customerId: 'vip123',
+ orderAmount: 10000,
+ customerType: 'VIP'
+ };
+
+ // when
+ const actual = calculateDiscount(input);
+
+ // then
+ expect(actual).toEqual({
+ discountRate: 0.2,
+ finalAmount: 8000
+ });
+ });
+});
+```
+
+
+## Jest Matcher 사용 가이드
+
+### 테스트 유지보수 편의성을 위한 Inline Snapshot 활용
+
+String, Object, Array, JSON 구조 등의 데이터는 `toMatchInlineSnapshot()`으로 처리합니다.
+
+```typescript
+describe('generateEmailTemplate', () => {
+ test('환영 이메일 템플릿을 생성한다', () => {
+ // given
+ const user = { name: '홍길동', email: 'hong@example.com' };
+
+ // when
+ const result = generateEmailTemplate('welcome', user);
+
+ // then - 문자열도 inline snapshot으로 처리
+ expect(result).toMatchInlineSnapshot(`
+ "안녕하세요, 홍길동님!
+
+ 회원가입을 환영합니다.
+ 귀하의 이메일 주소: hong@example.com
+
+ 서비스 이용에 궁금한 점이 있으시면 언제든 문의해 주세요.
+
+ 감사합니다."
+ `);
+ });
+});
+
+describe('formatUserProfile', () => {
+ test('사용자 프로필을 올바른 형식으로 변환한다', () => {
+ // given
+ const rawUser = {
+ id: 123,
+ name: '홍길동',
+ email: 'hong@example.com',
+ preferences: { theme: 'dark', language: 'ko' }
+ };
+
+ // when
+ const result = formatUserProfile(rawUser);
+
+ // then - 복잡한 객체도 inline snapshot으로 처리
+ expect(result).toMatchInlineSnapshot(`
+ {
+ "displayName": "홍길동",
+ "email": "hong@example.com",
+ "id": 123,
+ "settings": {
+ "language": "ko",
+ "theme": "dark",
+ },
+ }
+ `);
+ });
+
+ test('사용자 활동 목록을 생성한다', () => {
+ // given
+ const activities = generateUserActivities('user123');
+
+ // when & then - 배열도 inline snapshot으로 처리
+ expect(activities).toMatchInlineSnapshot(`
+ [
+ {
+ "action": "login",
+ "timestamp": "2024-01-01T09:00:00Z",
+ "userId": "user123",
+ },
+ {
+ "action": "profile_update",
+ "timestamp": "2024-01-01T10:30:00Z",
+ "userId": "user123",
+ },
+ ]
+ `);
+ });
+});
+```
+
+#### Inline Snapshot을 사용하는 이유
+
+1. **리팩토링 편의성**: 코드 변경 시 테스트 러너의 `u`(update) 기능으로 테스트를 쉽게 업데이트 가능
+2. **자동화된 업데이트**: 함수 리턴값이 변경되었을 때 수동으로 기댓값을 수정할 필요 없음
+3. **정확성**: 복잡한 객체 구조를 수동으로 작성할 때 발생할 수 있는 실수 방지
+
+#### 아무 생각 없이 update를 눌러서 잘못된 테스트가 통과되지 않을까?
+
+1. **코드 리뷰 과정**: 모든 테스트 변경사항은 코드 리뷰를 거치며, 리뷰어가 snapshot 변경사항을 검토
+2. **명시적인 Diff**: Git diff에서 snapshot 변경사항이 명확히 드러나므로 의도하지 않은 업데이트를 쉽게 발견
+3. **실제 경험**: 지금까지 이런 방식으로 인한 큰 문제가 발생하지 않았음
+4. **트레이드오프 선택**: 실수 가능성 vs 편의성의 트레이드오프에서, 경험상 편의성을 선택하는 것이 더 효율적
+
+### 기본 Matcher (원시 타입용)
+
+```typescript
+// 원시 타입 비교 (===)
+expect(result).toBe(expectedValue);
+
+// truthy/falsy 검증
+expect(result).toBeTruthy();
+expect(result).toBeFalsy();
+
+// 정의 여부 검증
+expect(result).toBeDefined();
+expect(result).toBeUndefined();
+expect(result).toBeNull();
+
+// 에러 발생 검증
+expect(() => functionCall()).toThrow();
+expect(() => functionCall()).toThrow('특정 에러 메시지');
+```
+
+### 함수 Mock Matcher
+
+```typescript
+// 호출 여부 검증
+expect(mockFunction).toHaveBeenCalled();
+expect(mockFunction).not.toHaveBeenCalled();
+
+// 호출 횟수 검증
+expect(mockFunction).toHaveBeenCalledTimes(2);
+
+// 호출 인자 검증
+expect(mockFunction).toHaveBeenCalledWith(expectedArg1, expectedArg2);
+expect(mockFunction).toHaveBeenLastCalledWith(expectedArg);
+```
+
+## 테스트 데이터 관리
+
+### Factory 패턴 사용
+
+테스트에서 사용하는 모든 Mock Data는 `factory.ts` 파일을 통해 생성해야 합니다.
+
+```typescript
+// src/utils/user.factory.ts
+import { Sync } from 'factory.ts';
+
+export const userFactory = Sync.makeFactory(() => ({
+ id: 'user123',
+ name: '홍길동',
+ email: 'hong@example.com',
+ status: 'active',
+ createdAt: '1704067200000', // 2024-01-01
+}));
+
+// 특정 상태별 Mock 데이터 배열
+export const mockUserData: User[] = [
+ userFactory.build(), // 기본 사용자
+ userFactory.build({
+ status: 'inactive' // 비활성 사용자
+ }),
+ userFactory.build({
+ membershipLevel: 'VIP',
+ discountRate: 0.2 // VIP 사용자
+ }),
+];
+```
+
+### 변경 의도 명시적 표현
+
+데이터에 따라 결과가 달라지는 테스트의 경우, factory 함수 사용 시 변경되는 값을 명시적으로 표기합니다.
+
+```typescript
+describe('calculateMembershipBenefit', () => {
+ test('VIP 회원은 20% 할인을 받는다', () => {
+ // given - VIP 등급과 할인율을 명시적으로 설정
+ const vipUser = userFactory.build({
+ membershipLevel: 'VIP', // 테스트 포인트: VIP 등급
+ discountRate: 0.2 // 테스트 포인트: 20% 할인율
+ });
+ const orderAmount = 10000;
+
+ // when
+ const result = calculateMembershipBenefit(vipUser, orderAmount);
+
+ // then
+ expect(result.discount).toBe(2000); // 20% 할인 적용
+ expect(result.finalAmount).toBe(8000);
+ });
+
+ test('일반 회원은 할인을 받지 않는다', () => {
+ // given - 일반 회원 등급을 명시적으로 설정
+ const normalUser = userFactory.build({
+ membershipLevel: 'NORMAL', // 테스트 포인트: 일반 등급
+ discountRate: 0 // 테스트 포인트: 할인 없음
+ });
+ const orderAmount = 10000;
+
+ // when
+ const result = calculateMembershipBenefit(normalUser, orderAmount);
+
+ // then
+ expect(result.discount).toBe(0);
+ expect(result.finalAmount).toBe(10000);
+ });
+
+ test('비활성 사용자는 혜택을 받을 수 없다', () => {
+ // given - 비활성 상태를 명시적으로 설정
+ const inactiveUser = userFactory.build({
+ status: 'inactive', // 테스트 포인트: 비활성 상태
+ membershipLevel: 'VIP' // VIP지만 비활성 상태
+ });
+ const orderAmount = 10000;
+
+ // when & then
+ expect(() => calculateMembershipBenefit(inactiveUser, orderAmount))
+ .toThrow('비활성 사용자는 혜택을 받을 수 없습니다');
+ });
+});
+```
+
+### API Mock 데이터 관리
+
+API 관련 mock 데이터는 `data-access` 레이어에서 선언하고 import하여 사용합니다.
+
+```typescript
+// shared/api/products/src/apis/get-product-price-estimate/get-product-price-estimate.mock.ts
+import { Sync } from 'factory.ts';
+import { GetProductPriceEstimateResponse } from '@app/api/products';
+
+export const getAlimiPredictedCreditInterestRateDecreaseResponseFactory =
+ Sync.makeFactory(() => ({
+ predictedInterestRate2f: '3.2',
+ predictedDateMs: '1704067200000', // 2024-01-01
+ }));
+
+export const mockGetAlimiPredictedCreditInterestRateDecreaseData: GetAlimiPredictedCreditInterestRateDecreaseResponse[] =
+ [
+ getAlimiPredictedCreditInterestRateDecreaseResponseFactory.build(),
+ getAlimiPredictedCreditInterestRateDecreaseResponseFactory.build({
+ predictedInterestRate2f: undefined, // 금리 하락이 예상되지 않는 경우
+ predictedDateMs: undefined,
+ }),
+ ];
+
+```
+
+### 테스트 코드에서 Factory 사용 예시
+
+데이터에 따라 결과가 달라지는 테스트에서 Factory를 사용할 때, 변경되는 값을 명시적으로 표기합니다.
+
+```typescript
+describe(determineLoanAlimiRateResultType.name, () => {
+ test('최근 결과가 없는 경우 empty를 반환한다', () => {
+ // given
+ const predictedResponse = getAlimiPredictedCreditInterestRateDecreaseResponseFactory.build({
+ predictedInterestRate2f: undefined, // 테스트 포인트: 예측 금리 없음
+ predictedDateMs: undefined, // 테스트 포인트: 예측 날짜 없음
+ });
+ const recentResponse = listAlimiRecentPrequalificationApplicationResultsResponseFactory.build({
+ alimiPrequalificationApplicationResults: [], // 테스트 포인트: 빈 결과 배열
+ });
+
+ // when
+ const result = determineLoanAlimiRateResultType(predictedResponse, recentResponse);
+
+ // then
+ expect(result).toBe('empty');
+ });
+
+ test('최근 결과가 있는 경우 recent를 반환한다', () => {
+ // given
+ const predictedResponse = getAlimiPredictedCreditInterestRateDecreaseResponseFactory.build({
+ predictedInterestRate2f: '3.5',
+ predictedDateMs: '1704067200000',
+ });
+ const recentResponse = listAlimiRecentPrequalificationApplicationResultsResponseFactory.build({
+ alimiPrequalificationApplicationResults: [
+ {
+ prequalificationApplicationId: '123', // 테스트 포인트: 최근 결과 존재
+ lowestInterestRate2f: '3.2',
+ resultDateMs: '1704067200000',
+ },
+ ],
+ });
+
+ // when
+ const result = determineLoanAlimiRateResultType(predictedResponse, recentResponse);
+
+ // then
+ expect(result).toBe('recent');
+ });
+});
+```
+
+### Factory 사용 시 주의사항
+
+1. **기본값 정의**: Factory는 항상 유효한 기본값을 제공해야 함
+2. **Override 활용**: 테스트별로 필요한 값만 override하여 의도를 명확히 표현
+3. **재사용성**: 여러 테스트에서 공통으로 사용할 수 있도록 설계
+4. **타입 안전성**: TypeScript 타입을 활용하여 컴파일 타임에 오류 방지
diff --git a/.codex/skills/test-convention/references/2-time-test.md b/.codex/skills/test-convention/references/2-time-test.md
new file mode 100644
index 0000000..4f8c2d9
--- /dev/null
+++ b/.codex/skills/test-convention/references/2-time-test.md
@@ -0,0 +1,635 @@
+---
+title: "2. Time Test"
+description: "시간 의존 로직을 Jest time mock과 date-fns로 안정적으로 테스트하는 패턴을 설명합니다."
+type: pattern
+tags: [Testing]
+order: 2
+---
+
+# 2. Time Test
+
+시간에 의존하는 코드를 테스트할 때의 가이드라인과 패턴을 다룹니다. 이 프로젝트는 `date-fns` 라이브러리를 사용합니다.
+
+## 시간 의존성 문제
+
+### 문제점
+
+시간에 의존하는 코드는 다음과 같은 문제를 야기합니다:
+
+- **실행 시점에 따른 결과 차이**: 테스트 실행 시간에 따라 결과가 달라짐
+- **타임존 의존성**: 로컬(KST)과 CI(UTC) 환경에서 다른 결과
+- **날짜/시간 계산 오류**: 윤년, 월말, 시간대 변경 등의 예외 상황
+- **테스트 불안정성**: 특정 시간대나 날짜에만 실패하는 테스트
+
+## 시간 의존성 해결 패턴
+
+### 1. Time Mock을 사용한 현재 시간 고정
+
+#### ❌ Do Not
+```typescript
+import { differenceInYears } from 'date-fns';
+
+// 현재 시간에 의존하는 함수 - 매개변수로 시간을 받지 않음
+const calculateAge = (birthDate: string) => {
+ const birth = new Date(birthDate);
+ const now = new Date(); // 실행 시점에 의존
+ return differenceInYears(now, birth);
+};
+
+// 테스트가 실행 시점에 따라 실패할 수 있음
+test('나이를 계산한다', () => {
+ const result = calculateAge('1990-01-01');
+ expect(result).toBe(34); // 2024년에만 통과, 2025년에는 실패
+});
+```
+
+#### ✅ Do
+```typescript
+import { differenceInYears } from 'date-fns';
+
+// 현재 시간에 의존하는 함수 - 그대로 유지
+const calculateAge = (birthDate: string) => {
+ const birth = new Date(birthDate);
+ const now = new Date(); // 실행 시점에 의존
+ return differenceInYears(now, birth);
+};
+
+// Time Mock을 사용하여 현재 시간을 고정
+describe(calculateAge.name, () => {
+ beforeEach(() => {
+ // 현재 시간을 2024-01-01로 고정
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('나이를 계산한다', () => {
+ // given
+ const birthDate = '1990-01-01';
+
+ // when
+ const result = calculateAge(birthDate);
+
+ // then - 고정된 시간으로 항상 동일한 결과
+ expect(result).toBe(34);
+ });
+
+ test('윤년 출생자의 나이를 계산한다', () => {
+ // given
+ const birthDate = '1992-02-29'; // 윤년 출생
+
+ // when
+ const result = calculateAge(birthDate);
+
+ // then
+ expect(result).toBe(32);
+ });
+
+ test('생일이 지나지 않은 경우 나이를 계산한다', () => {
+ // given - 생일 전 날짜로 시간 고정
+ jest.setSystemTime(new Date('2024-06-15T00:00:00.000Z'));
+ const birthDate = '1990-12-25'; // 생일이 아직 안 지남
+
+ // when
+ const result = calculateAge(birthDate);
+
+ // then
+ expect(result).toBe(33); // 생일 전이므로 한 살 적음
+ });
+});
+```
+
+### 2. 복합적인 시간 로직의 Time Mock 처리
+
+#### ❌ Do Not
+```typescript
+import { format, isWeekend } from 'date-fns';
+
+// 시간 로직이 비즈니스 로직과 섞여있어 테스트하기 어려움
+const generateReport = (data: ReportData) => {
+ const now = new Date();
+ const reportDate = format(now, 'yyyy-MM-dd HH:mm:ss');
+ const weekendCheck = isWeekend(now);
+
+ return {
+ ...data,
+ generatedAt: reportDate,
+ priority: weekendCheck ? 'low' : 'high'
+ };
+};
+
+// 실행 시점에 따라 결과가 달라지는 테스트
+test('리포트를 생성한다', () => {
+ const data = { title: 'Test Report' };
+ const result = generateReport(data);
+
+ // 주말/평일에 따라 priority가 달라져서 불안정
+ expect(result.priority).toBe('high'); // 주말에 실행하면 실패
+});
+```
+
+#### ✅ Do
+```typescript
+import { format, isWeekend } from 'date-fns';
+
+// 현재 시간에 의존하는 함수 - 그대로 유지
+const generateReport = (data: ReportData) => {
+ const now = new Date();
+ const reportDate = format(now, 'yyyy-MM-dd HH:mm:ss');
+ const weekendCheck = isWeekend(now);
+
+ return {
+ ...data,
+ generatedAt: reportDate,
+ priority: weekendCheck ? 'low' : 'high'
+ };
+};
+
+// Time Mock을 사용하여 다양한 시간 상황 테스트
+describe(generateReport.name, () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('평일에 생성된 리포트는 높은 우선순위를 갖는다', () => {
+ // given - 월요일로 시간 고정
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2024-01-01T09:00:00.000Z')); // 월요일
+ const data = { title: 'Test Report' };
+
+ // when
+ const result = generateReport(data);
+
+ // then
+ expect(result.priority).toBe('high');
+ expect(result.generatedAt).toBe('2024-01-01 09:00:00');
+ });
+
+ test('주말에 생성된 리포트는 낮은 우선순위를 갖는다', () => {
+ // given - 토요일로 시간 고정
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2024-01-06T09:00:00.000Z')); // 토요일
+ const data = { title: 'Weekend Report' };
+
+ // when
+ const result = generateReport(data);
+
+ // then
+ expect(result.priority).toBe('low');
+ expect(result.generatedAt).toBe('2024-01-06 09:00:00');
+ });
+});
+```
+
+### 3. date-fns를 사용한 시간대 고정 처리
+
+#### ❌ Do Not
+```typescript
+import { format } from 'date-fns';
+
+// 시스템 시간대에 의존하는 함수
+const formatBusinessTime = (timestamp: number) => {
+ const date = new Date(timestamp);
+ return format(date, 'yyyy-MM-dd HH:mm:ss'); // 시스템 시간대에 의존
+};
+
+test('업무 시간을 포맷팅한다', () => {
+ const result = formatBusinessTime(1704067200000); // 2024-01-01 00:00:00 UTC
+ // 로컬(KST): "2024-01-01 09:00:00"
+ // CI(UTC): "2024-01-01 00:00:00"
+ expect(result).toBe('2024-01-01 09:00:00'); // CI에서 실패
+});
+```
+
+#### ✅ Do
+```typescript
+import { format } from 'date-fns';
+import { zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
+
+// 시간대를 명시적으로 지정하는 함수
+const formatBusinessTime = (timestamp: number, timeZone: string = 'Asia/Seoul') => {
+ const utcDate = new Date(timestamp);
+ const zonedDate = utcToZonedTime(utcDate, timeZone);
+ return format(zonedDate, 'yyyy-MM-dd HH:mm:ss');
+};
+
+test('업무 시간을 한국 시간대로 포맷팅한다', () => {
+ // given
+ const timestamp = 1704067200000; // 2024-01-01 00:00:00 UTC
+
+ // when
+ const result = formatBusinessTime(timestamp, 'Asia/Seoul');
+
+ // then
+ expect(result).toBe('2024-01-01 09:00:00'); // 어디서든 동일한 결과
+});
+
+test('다른 시간대로도 포맷팅할 수 있다', () => {
+ // given
+ const timestamp = 1704067200000; // 2024-01-01 00:00:00 UTC
+
+ // when
+ const result = formatBusinessTime(timestamp, 'America/New_York');
+
+ // then
+ expect(result).toBe('2023-12-31 19:00:00'); // EST 기준
+});
+```
+
+## 날짜 계산 테스트 패턴
+
+### 경계값 테스트
+
+```typescript
+import { addBusinessDays, format } from 'date-fns';
+
+// date-fns를 사용한 영업일 계산 함수
+const addWorkingDays = (dateString: string, days: number) => {
+ const startDate = new Date(dateString);
+ const result = addBusinessDays(startDate, days);
+ return format(result, 'yyyy-MM-dd');
+};
+
+describe(addWorkingDays.name, () => {
+ test('평일에 영업일을 더한다', () => {
+ // given
+ const startDate = '2024-01-02'; // 화요일
+ const businessDays = 3;
+
+ // when
+ const result = addWorkingDays(startDate, businessDays);
+
+ // then
+ expect(result).toBe('2024-01-05'); // 금요일
+ });
+
+ test('금요일에 영업일을 더하면 주말을 건너뛴다', () => {
+ // given
+ const startDate = '2024-01-05'; // 금요일
+ const businessDays = 1;
+
+ // when
+ const result = addWorkingDays(startDate, businessDays);
+
+ // then
+ expect(result).toBe('2024-01-08'); // 다음 주 월요일
+ });
+
+ test('월말에서 영업일을 더한다', () => {
+ // given
+ const startDate = '2024-01-31'; // 1월 마지막 날 (수요일)
+ const businessDays = 2;
+
+ // when
+ const result = addWorkingDays(startDate, businessDays);
+
+ // then
+ expect(result).toBe('2024-02-02'); // 2월 2일 (금요일)
+ });
+
+ test('연휴가 포함된 기간의 영업일을 계산한다', () => {
+ // given
+ const startDate = '2024-01-04'; // 목요일
+ const businessDays = 5; // 5 영업일
+
+ // when
+ const result = addWorkingDays(startDate, businessDays);
+
+ // then
+ expect(result).toBe('2024-01-11'); // 다음 주 목요일 (주말 2일 건너뜀)
+ });
+});
+```
+
+### 윤년 처리 테스트
+
+```typescript
+import { isLeapYear, getDaysInMonth } from 'date-fns';
+
+describe('윤년 검사', () => {
+ test('4의 배수인 해는 윤년이다', () => {
+ expect(isLeapYear(new Date(2024, 0, 1))).toBe(true);
+ });
+
+ test('100의 배수이지만 400의 배수가 아닌 해는 평년이다', () => {
+ expect(isLeapYear(new Date(1900, 0, 1))).toBe(false);
+ });
+
+ test('400의 배수인 해는 윤년이다', () => {
+ expect(isLeapYear(new Date(2000, 0, 1))).toBe(true);
+ });
+});
+
+describe('월별 일수 계산', () => {
+ test('윤년 2월은 29일이다', () => {
+ const februaryInLeapYear = new Date(2024, 1, 1); // 2024년 2월
+ expect(getDaysInMonth(februaryInLeapYear)).toBe(29);
+ });
+
+ test('평년 2월은 28일이다', () => {
+ const februaryInNormalYear = new Date(2023, 1, 1); // 2023년 2월
+ expect(getDaysInMonth(februaryInNormalYear)).toBe(28);
+ });
+
+ test('31일까지 있는 달을 확인한다', () => {
+ const january = new Date(2024, 0, 1); // 1월
+ expect(getDaysInMonth(january)).toBe(31);
+ });
+
+ test('30일까지 있는 달을 확인한다', () => {
+ const april = new Date(2024, 3, 1); // 4월
+ expect(getDaysInMonth(april)).toBe(30);
+ });
+});
+```
+
+## 시간 범위 테스트
+
+### 기간 계산 테스트
+
+```typescript
+import { differenceInDays, differenceInYears, differenceInMonths } from 'date-fns';
+
+// date-fns를 사용한 기간 계산 함수
+const calculatePeriod = (startDateString: string, endDateString: string) => {
+ const startDate = new Date(startDateString);
+ const endDate = new Date(endDateString);
+
+ return {
+ days: differenceInDays(endDate, startDate),
+ months: differenceInMonths(endDate, startDate),
+ years: differenceInYears(endDate, startDate)
+ };
+};
+
+describe(calculatePeriod.name, () => {
+ test('같은 날짜의 기간은 0일이다', () => {
+ // given
+ const startDate = '2024-01-01';
+ const endDate = '2024-01-01';
+
+ // when
+ const result = calculatePeriod(startDate, endDate);
+
+ // then
+ expect(result.days).toBe(0);
+ expect(result.months).toBe(0);
+ expect(result.years).toBe(0);
+ });
+
+ test('연도를 넘나드는 기간을 계산한다', () => {
+ // given
+ const startDate = '2023-12-31';
+ const endDate = '2024-01-02';
+
+ // when
+ const result = calculatePeriod(startDate, endDate);
+
+ // then
+ expect(result.days).toBe(2);
+ expect(result.months).toBe(0);
+ expect(result.years).toBe(0); // 1년 미만
+ });
+
+ test('여러 해에 걸친 기간을 계산한다', () => {
+ // given
+ const startDate = '2022-01-01';
+ const endDate = '2024-01-01';
+
+ // when
+ const result = calculatePeriod(startDate, endDate);
+
+ // then
+ expect(result.years).toBe(2);
+ expect(result.months).toBe(24);
+ expect(result.days).toBe(731); // 2년 + 윤년 1일
+ });
+
+ test('월 단위 기간을 정확히 계산한다', () => {
+ // given
+ const startDate = '2024-01-15';
+ const endDate = '2024-06-15';
+
+ // when
+ const result = calculatePeriod(startDate, endDate);
+
+ // then
+ expect(result.months).toBe(5);
+ expect(result.days).toBe(152); // 실제 일수
+ });
+});
+```
+
+## 시간 Mock 패턴
+
+### Jest Timer Mock 사용
+
+```typescript
+describe('delayedFunction', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('3초 후에 콜백을 실행한다', () => {
+ // given
+ const mockCallback = jest.fn();
+
+ // when
+ delayedFunction(mockCallback, 3000);
+
+ // then - 아직 실행되지 않음
+ expect(mockCallback).not.toHaveBeenCalled();
+
+ // 3초 경과
+ jest.advanceTimersByTime(3000);
+
+ // then - 콜백이 실행됨
+ expect(mockCallback).toHaveBeenCalledTimes(1);
+ });
+});
+```
+
+### 시간 기반 함수의 Time Mock 테스트
+
+```typescript
+// 현재 시간의 타임스탬프를 반환하는 함수
+const getCurrentTimestamp = () => {
+ return new Date().getTime();
+};
+
+describe(getCurrentTimestamp.name, () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('현재 시간의 타임스탬프를 반환한다', () => {
+ // given - 시간을 고정
+ jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
+
+ // when
+ const result = getCurrentTimestamp();
+
+ // then
+ expect(result).toBe(1704067200000);
+ });
+
+ test('다른 시간으로 설정하여 테스트한다', () => {
+ // given - 다른 시간으로 고정
+ jest.setSystemTime(new Date('2024-06-15T12:30:00.000Z'));
+
+ // when
+ const result = getCurrentTimestamp();
+
+ // then
+ expect(result).toBe(1718453400000);
+ });
+});
+```
+
+## 시간 관련 유틸리티 함수 테스트
+
+### 날짜 유효성 검사
+
+```typescript
+import { isValid, parseISO } from 'date-fns';
+
+// date-fns를 사용한 날짜 유효성 검사 함수
+const isValidDateString = (dateString: string) => {
+ const parsedDate = parseISO(dateString);
+ return isValid(parsedDate);
+};
+
+describe(isValidDateString.name, () => {
+ test('유효한 날짜 문자열을 검증한다', () => {
+ expect(isValidDateString('2024-01-01')).toBe(true);
+ expect(isValidDateString('2024-12-31')).toBe(true);
+ expect(isValidDateString('2024-01-01T00:00:00.000Z')).toBe(true); // ISO 형식
+ });
+
+ test('유효하지 않은 날짜 문자열을 검증한다', () => {
+ expect(isValidDateString('2024-02-30')).toBe(false); // 2월 30일은 존재하지 않음
+ expect(isValidDateString('2024-13-01')).toBe(false); // 13월은 존재하지 않음
+ expect(isValidDateString('invalid-date')).toBe(false);
+ expect(isValidDateString('')).toBe(false); // 빈 문자열
+ });
+
+ test('윤년 날짜를 올바르게 검증한다', () => {
+ expect(isValidDateString('2024-02-29')).toBe(true); // 윤년
+ expect(isValidDateString('2023-02-29')).toBe(false); // 평년
+ });
+
+ test('다양한 날짜 형식을 검증한다', () => {
+ expect(isValidDateString('2024-01-01T15:30:00')).toBe(true);
+ expect(isValidDateString('2024-01-01T15:30:00+09:00')).toBe(true);
+ expect(isValidDateString('01/01/2024')).toBe(false); // MM/DD/YYYY 형식은 parseISO로 파싱 불가
+ });
+});
+```
+
+### 시간 포맷팅
+
+```typescript
+import { format, intervalToDuration, addSeconds } from 'date-fns';
+import { ko } from 'date-fns/locale';
+
+// date-fns를 사용한 시간 포맷팅 함수
+const formatDuration = (seconds: number): string => {
+ const start = new Date(0);
+ const end = addSeconds(start, seconds);
+ const duration = intervalToDuration({ start, end });
+
+ const parts: string[] = [];
+
+ if (duration.days && duration.days > 0) parts.push(`${duration.days}일`);
+ if (duration.hours && duration.hours > 0) parts.push(`${duration.hours}시간`);
+ if (duration.minutes && duration.minutes > 0) parts.push(`${duration.minutes}분`);
+ if (duration.seconds && duration.seconds > 0) parts.push(`${duration.seconds}초`);
+
+ return parts.length > 0 ? parts.join(' ') : '0초';
+};
+
+// date-fns를 사용한 날짜 포맷팅 함수
+const formatKoreanDate = (date: Date): string => {
+ return format(date, 'yyyy년 MM월 dd일', { locale: ko });
+};
+
+describe(formatDuration.name, () => {
+ test('초 단위 시간을 포맷팅한다', () => {
+ expect(formatDuration(30)).toBe('30초');
+ expect(formatDuration(90)).toBe('1분 30초');
+ expect(formatDuration(3661)).toBe('1시간 1분 1초');
+ });
+
+ test('0초는 "0초"로 표시한다', () => {
+ expect(formatDuration(0)).toBe('0초');
+ });
+
+ test('하루 이상의 시간을 포맷팅한다', () => {
+ const oneDayInSeconds = 24 * 60 * 60;
+ expect(formatDuration(oneDayInSeconds)).toBe('1일');
+ expect(formatDuration(oneDayInSeconds + 3661)).toBe('1일 1시간 1분 1초');
+ });
+
+ test('분 단위만 있는 경우를 포맷팅한다', () => {
+ expect(formatDuration(120)).toBe('2분');
+ expect(formatDuration(3600)).toBe('1시간');
+ });
+});
+
+describe(formatKoreanDate.name, () => {
+ test('날짜를 한국어 형식으로 포맷팅한다', () => {
+ // given
+ const date = new Date('2024-01-01T00:00:00.000Z');
+
+ // when
+ const result = formatKoreanDate(date);
+
+ // then
+ expect(result).toBe('2024년 01월 01일');
+ });
+
+ test('윤년 날짜를 포맷팅한다', () => {
+ // given
+ const date = new Date('2024-02-29T00:00:00.000Z');
+
+ // when
+ const result = formatKoreanDate(date);
+
+ // then
+ expect(result).toBe('2024년 02월 29일');
+ });
+});
+```
+
+## 주의사항
+
+### 시간대 처리 시 고려사항
+
+1. **일관된 시간대 사용**: 테스트에서는 항상 명시적인 시간대 지정
+2. **UTC 기준 계산**: 내부 계산은 UTC로, 표시는 로컬 시간대로
+3. **서머타임 고려**: 서머타임이 적용되는 지역의 시간 계산 주의
+
+### 테스트 데이터 선택
+
+1. **의미있는 날짜 사용**: 실제 비즈니스 로직과 관련된 날짜 선택
+2. **경계값 테스트**: 월말, 연말, 윤년 등의 경계 상황 포함
+3. **고정된 시간 사용**: 테스트에서는 항상 고정된 시간값 사용
+4. **date-fns 함수 활용**: 날짜 계산은 date-fns 함수를 사용하여 정확성 보장
+
+### Time Mock 사용 시 주의점
+
+1. **Timer 정리**: 테스트 후 반드시 `jest.useRealTimers()` 호출
+2. **일관된 패턴**: `beforeEach`에서 `useFakeTimers()`, `afterEach`에서 `useRealTimers()` 사용
+3. **시간 고정**: `jest.setSystemTime()`을 사용하여 명시적으로 시간 고정
+4. **테스트 격리**: 각 테스트마다 독립적인 시간 설정으로 격리 보장
diff --git a/.codex/skills/test-convention/references/3-parameterized-test.md b/.codex/skills/test-convention/references/3-parameterized-test.md
new file mode 100644
index 0000000..7138c5e
--- /dev/null
+++ b/.codex/skills/test-convention/references/3-parameterized-test.md
@@ -0,0 +1,475 @@
+---
+title: "3. Parameterized Test"
+description: "중복 테스트를 줄이고 가독성을 높이는 parameterized 테스트 테이블 패턴을 예제로 안내합니다."
+type: pattern
+tags: [Testing]
+order: 3
+---
+
+# 3. Parameterized Test
+
+여러 입력값에 대해 동일한 로직을 테스트할 때 사용하는 parameterized 테스트 패턴을 다룹니다.
+
+## 문제점과 해결책
+
+### 1. 중복 코드 문제
+
+**문제점**: 비슷한 테스트 코드가 반복되어 코드량이 증가하고 일관성 유지가 어려움
+
+```typescript
+// ❌ 중복된 테스트 코드
+describe('validateEmail', () => {
+ it('유효한 이메일을 검증한다', () => {
+ const result = validateEmail('user@example.com');
+ expect(result.isValid).toBe(true);
+ });
+
+ it('유효하지 않은 이메일을 검증한다', () => {
+ const result = validateEmail('invalid-email');
+ expect(result.isValid).toBe(false);
+ });
+
+ it('빈 문자열을 검증한다', () => {
+ const result = validateEmail('');
+ expect(result.isValid).toBe(false);
+ });
+
+ it('도메인이 없는 이메일을 검증한다', () => {
+ const result = validateEmail('user@');
+ expect(result.isValid).toBe(false);
+ });
+});
+```
+
+**해결책**: Parameterized 테스트로 중복 코드 제거
+
+```typescript
+// ✅ Parameterized 테스트로 중복 제거
+describe('validateEmail', () => {
+ it.each`
+ email | expected
+ ${'user@example.com'} | ${true}
+ ${'invalid-email'} | ${false}
+ ${''} | ${false}
+ ${'user@'} | ${false}
+ `('$email 이메일이 $expected 결과를 반환한다', ({ email, expected }) => {
+ const result = validateEmail(email);
+ expect(result.isValid).toBe(expected);
+ });
+});
+```
+
+### 2. 유지보수 어려움 문제
+
+**문제점**: 새로운 케이스 추가 시 모든 테스트를 수정해야 하고, 로직 변경 시 모든 테스트를 개별적으로 업데이트해야 함
+
+```typescript
+// ❌ 새로운 케이스 추가 시 또 다른 it 블록 생성 필요
+describe('formatDateAtMs', () => {
+ it('5월 20일을 올바르게 포맷팅한다', () => {
+ const result = formatDateAtMs('1621500612000', 'M월 d일');
+ expect(result).toEqual('5월 20일');
+ });
+
+ it('금요일을 올바르게 포맷팅한다', () => {
+ const result = formatDateAtMs('1640952000000', 'eeee');
+ expect(result).toEqual('금요일');
+ });
+
+ // 새로운 케이스 추가 시 또 다른 it 블록 생성 필요
+ it('2024년 1월 1일을 올바르게 포맷팅한다', () => {
+ const result = formatDateAtMs('1704067200000', 'yyyy년 M월 d일');
+ expect(result).toEqual('2024년 1월 1일');
+ });
+});
+```
+
+**해결책**: 테이블에만 새로운 케이스 추가하여 유지보수성 향상
+
+```typescript
+// ✅ 새로운 케이스는 테이블에만 추가
+describe('formatDateAtMs', () => {
+ it.each`
+ date | ms | format | expected
+ ${'Thursday, May 20, 2021 8:50:12 AM'} | ${'1621500612000'} | ${'M월 d일'} | ${'5월 20일'}
+ ${'Friday, December 31, 2021 12:00:00 PM'} | ${'1640952000000'} | ${'eeee'} | ${'금요일'}
+ ${'Monday, January 1, 2024 9:00:00 AM'} | ${'1704067200000'} | ${'yyyy년 M월 d일'} | ${'2024년 1월 1일'}
+ `('when $ms ($date), $format given, returns $expected', ({ ms, format, expected }) => {
+ const result = formatDateAtMs(ms, format);
+ expect(result).toEqual(expected);
+ });
+});
+```
+
+### 3. 가독성 저하 문제
+
+**문제점**: 테스트 의도가 반복 코드에 묻혀서 무엇을 검증하려는지 파악하기 어려움
+
+```typescript
+// ❌ 테스트 의도가 불분명한 반복 코드
+describe('calculateTax', () => {
+ it('소득 100만원에 세율 10%를 적용한다', () => {
+ const result = calculateTax(1000000, 0.1);
+ expect(result.taxAmount).toBe(100000);
+ expect(result.netIncome).toBe(900000);
+ });
+
+ it('소득 500만원에 세율 20%를 적용한다', () => {
+ const result = calculateTax(5000000, 0.2);
+ expect(result.taxAmount).toBe(1000000);
+ expect(result.netIncome).toBe(4000000);
+ });
+
+ it('소득 1000만원에 세율 30%를 적용한다', () => {
+ const result = calculateTax(10000000, 0.3);
+ expect(result.taxAmount).toBe(3000000);
+ expect(result.netIncome).toBe(7000000);
+ });
+});
+```
+
+**해결책**: 구조화된 테이블로 테스트 의도가 명확히 드러남
+
+```typescript
+// ✅ 테이블 구조로 테스트 의도가 명확
+describe('calculateTax', () => {
+ it.each`
+ income | taxRate | expectedTax | expectedNetIncome
+ ${1000000} | ${0.1} | ${100000} | ${900000}
+ ${5000000} | ${0.2} | ${1000000} | ${4000000}
+ ${10000000} | ${0.3} | ${3000000} | ${7000000}
+ `('소득 $income에 세율 $taxRate를 적용하면 세금 $expectedTax, 순소득 $expectedNetIncome이다',
+ ({ income, taxRate, expectedTax, expectedNetIncome }) => {
+ const result = calculateTax(income, taxRate);
+ expect(result.taxAmount).toBe(expectedTax);
+ expect(result.netIncome).toBe(expectedNetIncome);
+ });
+});
+```
+
+### 4. 실수 가능성 문제
+
+**문제점**: 복사-붙여넣기로 인한 실수와 일관성 없는 테스트 코드
+
+```typescript
+// ❌ 복사-붙여넣기로 인한 실수 가능성
+describe('parseAmount', () => {
+ it('유효한 숫자를 파싱한다', () => {
+ expect(() => parseAmount('abc')).toThrow('Invalid number format');
+ });
+
+ it('음수를 파싱한다', () => {
+ expect(() => parseAmount('-1000')).toThrow('Amount cannot be negative');
+ });
+
+ it('0을 파싱한다', () => {
+ expect(() => parseAmount('0')).toThrow('Amount must be greater than 0');
+ });
+
+ // 복사-붙여넣기로 인한 실수: expect 함수명이 다름
+ it('빈 문자열을 파싱한다', () => {
+ expect(() => parseAmount('')).toThrow('Amount is required');
+ });
+});
+```
+
+**해결책**: 구조화된 테스트 데이터로 실수 최소화
+
+```typescript
+// ✅ 구조화된 데이터로 실수 방지
+describe('parseAmount', () => {
+ it.each`
+ input | expectedError
+ ${'abc'} | ${'Invalid number format'}
+ ${'-1000'} | ${'Amount cannot be negative'}
+ ${'0'} | ${'Amount must be greater than 0'}
+ ${''} | ${'Amount is required'}
+ `('$input 입력 시 "$expectedError" 에러가 발생한다', ({ input, expectedError }) => {
+ expect(() => parseAmount(input)).toThrow(expectedError);
+ });
+});
+```
+
+### 5. 비개발자와의 소통 어려움 문제
+
+**문제점**: 기획자나 타 직군이 테스트 코드를 이해하기 어려워 비즈니스 로직 검증이 어려움
+
+```typescript
+// ❌ 비개발자가 이해하기 어려운 테스트 코드
+describe('calculateDiscount', () => {
+ it('VIP 고객에게 할인을 적용한다', () => {
+ const customer = customerFactory.build({ type: 'VIP', totalSpent: 1000000 });
+ const result = calculateDiscount(customer);
+ expect(result.discountRate).toBe(0.2);
+ expect(result.finalAmount).toBe(800000);
+ });
+
+ it('일반 고객에게 할인을 적용한다', () => {
+ const customer = customerFactory.build({ type: 'NORMAL', totalSpent: 500000 });
+ const result = calculateDiscount(customer);
+ expect(result.discountRate).toBe(0.1);
+ expect(result.finalAmount).toBe(450000);
+ });
+});
+// 기획자: "어떤 조건에서 어떤 할인이 적용되는지 한눈에 파악하기 어려움"
+```
+
+**해결책**: Parameterized 테스트로 비즈니스 로직을 명확히 표현
+
+```typescript
+// ✅ 비개발자도 쉽게 이해할 수 있는 테스트 코드
+describe('calculateDiscount', () => {
+ it.each`
+ customerType | totalSpent | expectedDiscount | expectedFinalAmount | description
+ ${'VIP'} | ${1000000} | ${0.2} | ${800000} | ${'VIP 고객 100만원 구매 시 20% 할인'}
+ ${'NORMAL'} | ${500000} | ${0.1} | ${450000} | ${'일반 고객 50만원 구매 시 10% 할인'}
+ ${'NEW'} | ${200000} | ${0} | ${200000} | ${'신규 고객 20만원 구매 시 할인 없음'}
+ `('$description', ({ customerType, totalSpent, expectedDiscount, expectedFinalAmount }) => {
+ const customer = customerFactory.build({ type: customerType, totalSpent });
+ const result = calculateDiscount(customer);
+
+ expect(result.discountRate).toBe(expectedDiscount);
+ expect(result.finalAmount).toBe(expectedFinalAmount);
+ });
+});
+// 기획자: "테이블만 봐도 어떤 고객이 어떤 조건에서 어떤 할인을 받는지 명확히 파악 가능"
+```
+
+## 테이블 vs 배열 선택 가이드
+
+### 테이블 템플릿 문자열 사용 케이스
+
+#### 1. 단순한 입력-출력 매핑
+
+**적합한 경우**: 입력값과 기댓값이 단순하고 명확한 1:1 매핑
+
+```typescript
+// ✅ 테이블이 적합한 경우: 단순한 유효성 검사
+describe('validateEmail', () => {
+ it.each`
+ email | expected
+ ${'user@example.com'} | ${true}
+ ${'invalid-email'} | ${false}
+ ${''} | ${false}
+ ${'user@'} | ${false}
+ `('$email 이메일이 $expected 결과를 반환한다', ({ email, expected }) => {
+ const result = validateEmail(email);
+ expect(result.isValid).toBe(expected);
+ });
+});
+```
+
+**장점**:
+- 가독성이 좋음 (테이블 형태로 한눈에 파악 가능)
+- 새로운 케이스 추가가 쉬움
+- 테스트 의도가 명확함
+
+#### 2. 날짜/시간 포맷팅 테스트
+
+```typescript
+// ✅ 테이블이 적합한 경우: 포맷팅 패턴 테스트
+describe('formatDateAtMs', () => {
+ it.each`
+ date | ms | format | expected
+ ${'Thursday, May 20, 2021 8:50:12 AM'} | ${'1621500612000'} | ${'M월 d일'} | ${'5월 20일'}
+ ${'Friday, December 31, 2021 12:00:00 PM'} | ${'1640952000000'} | ${'eeee'} | ${'금요일'}
+ ${'Monday, January 1, 2024 9:00:00 AM'} | ${'1704067200000'} | ${'yyyy년 M월 d일'} | ${'2024년 1월 1일'}
+ `('when $ms ($date), $format given, returns $expected', ({ ms, format, expected }) => {
+ const result = formatDateAtMs(ms, format);
+ expect(result).toEqual(expected);
+ });
+});
+```
+
+### 배열 기반 사용 케이스
+
+#### 1. 복잡한 객체 구조
+
+**적합한 경우**: 복잡한 객체나 중첩된 데이터 구조
+
+```typescript
+// ✅ 배열이 적합한 경우: 복잡한 객체 구조
+describe('calculateLoanEligibility', () => {
+ it.each([
+ {
+ customer: {
+ creditScore: 800,
+ income: 50000000,
+ employmentYears: 5,
+ hasDefaultHistory: false
+ },
+ expected: {
+ eligible: true,
+ maxAmount: 100000000,
+ reason: null,
+ interestRate: 3.5
+ }
+ },
+ {
+ customer: {
+ creditScore: 400,
+ income: 20000000,
+ employmentYears: 1,
+ hasDefaultHistory: true
+ },
+ expected: {
+ eligible: false,
+ maxAmount: 0,
+ reason: 'LOW_CREDIT_SCORE',
+ interestRate: null
+ }
+ }
+ ])('고객 정보로 대출 자격을 판단한다', ({ customer, expected }) => {
+ const customerData = customerFactory.build(customer);
+ const result = calculateLoanEligibility(customerData);
+
+ expect(result.eligible).toBe(expected.eligible);
+ expect(result.maxAmount).toBe(expected.maxAmount);
+ expect(result.reason).toBe(expected.reason);
+ expect(result.interestRate).toBe(expected.interestRate);
+ });
+});
+```
+
+**장점**:
+- 복잡한 객체 구조 표현 가능
+- TypeScript 타입 추론 지원
+- 객체 구조 변경 시 컴파일 타임 에러 감지
+
+#### 2. 조건부 로직이 필요한 경우
+
+```typescript
+// ✅ 배열이 적합한 경우: 조건부 검증 로직
+describe('processUserOrder', () => {
+ it.each([
+ {
+ userType: 'VIP',
+ orderAmount: 100000,
+ expectedDiscount: 0.2,
+ expectedStatus: 'APPROVED',
+ shouldValidateCredit: false // VIP는 신용검증 불필요
+ },
+ {
+ userType: 'NEW',
+ orderAmount: 100000,
+ expectedDiscount: 0,
+ expectedStatus: 'PENDING',
+ shouldValidateCredit: true // 신규 사용자는 신용검증 필요
+ }
+ ])('$userType 고객의 주문을 처리한다', ({ userType, orderAmount, expectedDiscount, expectedStatus, shouldValidateCredit }) => {
+ const user = userFactory.build({ type: userType });
+ const order = orderFactory.build({ amount: orderAmount });
+
+ const result = processUserOrder(user, order);
+
+ expect(result.discountRate).toBe(expectedDiscount);
+ expect(result.status).toBe(expectedStatus);
+
+ // 조건부 검증
+ if (shouldValidateCredit) {
+ expect(result.creditCheckRequired).toBe(true);
+ } else {
+ expect(result.creditCheckRequired).toBe(false);
+ }
+ });
+});
+```
+
+### 선택 기준
+
+#### 테이블 템플릿 문자열 선택 시기
+- **단순한 데이터**: 입력값과 기댓값이 단순한 타입
+- **명확한 매핑**: 1:1 관계가 명확한 경우
+- **가독성 우선**: 테스트 데이터를 한눈에 파악하고 싶은 경우
+- **빠른 추가**: 새로운 케이스를 빠르게 추가하고 싶은 경우
+- **영문 데이터**: 한글이 포함되지 않은 데이터 (한글은 정렬 문제로 가독성 저하)
+
+#### 배열 기반 선택 시기
+- **복잡한 객체**: 중첩된 객체나 복잡한 데이터 구조
+- **타입 안전성**: TypeScript 타입 체킹이 중요한 경우
+- **조건부 로직**: 테스트 케이스별로 다른 검증 로직이 필요한 경우
+- **재사용성**: 테스트 데이터를 다른 곳에서도 재사용하고 싶은 경우
+- **한글 데이터**: 한글이 포함된 데이터 (테이블 정렬 문제로 가독성 저하)
+
+### 한글 데이터 처리 주의사항
+
+#### 테이블에서 한글 정렬 문제
+
+```typescript
+// ❌ 한글이 포함된 테이블 - 정렬이 깨져서 가독성 저하
+describe('validateKoreanName', () => {
+ it.each`
+ name | expected
+ ${'홍길동'} | ${true}
+ ${'김철수'} | ${true}
+ ${'123'} | ${false}
+ ${'abc'} | ${false}
+ ${'이영희'} | ${true}
+ `('$name 이름이 $expected 결과를 반환한다', ({ name, expected }) => {
+ const result = validateKoreanName(name);
+ expect(result.isValid).toBe(expected);
+ });
+});
+```
+
+#### 배열로 해결
+
+```typescript
+// ✅ 한글 데이터는 배열로 처리하여 가독성 확보
+describe('validateKoreanName', () => {
+ it.each([
+ { name: '홍길동', expected: true, description: '유효한 한글 이름' },
+ { name: '김철수', expected: true, description: '유효한 한글 이름' },
+ { name: '이영희', expected: true, description: '유효한 한글 이름' },
+ { name: '123', expected: false, description: '숫자만 포함' },
+ { name: 'abc', expected: false, description: '영문만 포함' }
+ ])('$description: $name', ({ name, expected }) => {
+ const result = validateKoreanName(name);
+ expect(result.isValid).toBe(expected);
+ });
+});
+```
+
+### 하이브리드 접근법
+
+#### 복잡한 케이스에서 테이블 활용
+
+```typescript
+// ✅ 하이브리드: 기본 데이터는 테이블, 복잡한 로직은 배열
+describe('calculateTax', () => {
+ // 기본 세율 계산은 테이블로
+ it.each`
+ income | taxRate | expectedTax
+ ${1000000} | ${0.1} | ${100000}
+ ${5000000} | ${0.2} | ${1000000}
+ ${10000000} | ${0.3} | ${3000000}
+ `('소득 $income에 세율 $taxRate를 적용하면 세금 $expectedTax이다',
+ ({ income, taxRate, expectedTax }) => {
+ const result = calculateTax(income, taxRate);
+ expect(result.taxAmount).toBe(expectedTax);
+ });
+
+ // 복잡한 공제 계산은 배열로
+ it.each([
+ {
+ income: 5000000,
+ deductions: [
+ { type: 'INSURANCE', amount: 500000 },
+ { type: 'RETIREMENT', amount: 300000 }
+ ],
+ expectedNetTax: 840000 // (5000000 - 800000) * 0.2
+ },
+ {
+ income: 10000000,
+ deductions: [
+ { type: 'CHARITY', amount: 1000000 },
+ { type: 'EDUCATION', amount: 500000 }
+ ],
+ expectedNetTax: 2550000 // (10000000 - 1500000) * 0.3
+ }
+ ])('공제를 적용한 세금을 계산한다', ({ income, deductions, expectedNetTax }) => {
+ const result = calculateTaxWithDeductions(income, deductions);
+ expect(result.netTaxAmount).toBe(expectedNetTax);
+ });
+});
+```
diff --git a/knip.json b/knip.json
index 1ce41e3..06c7cbf 100644
--- a/knip.json
+++ b/knip.json
@@ -2,7 +2,9 @@
"$schema": "https://unpkg.com/knip@6/schema.json",
"ignoreFiles": ["sheriff.config.ts"],
"ignoreIssues": {
- "packages/react-hook-kit/src/index.ts": ["types"]
+ "packages/react-hook-kit/src/index.ts": ["types"],
+ "packages/react-hooks-testing/src/create-hook-renderer.ts": ["types"],
+ "packages/react-hooks-testing/src/index.ts": ["exports", "types"]
},
"workspaces": {
".": {
@@ -16,6 +18,10 @@
"entry": ["src/use-*.ts"],
"project": ["src/**/*.{ts,tsx}"],
"includeEntryExports": true
+ },
+ "packages/react-hooks-testing": {
+ "project": ["src/**/*.{ts,tsx}"],
+ "includeEntryExports": true
}
}
}
diff --git a/packages/react-hooks-testing/README.ko.md b/packages/react-hooks-testing/README.ko.md
new file mode 100644
index 0000000..e31590a
--- /dev/null
+++ b/packages/react-hooks-testing/README.ko.md
@@ -0,0 +1,317 @@
+# react-use-hook-kit-testing
+
+React hook을 client render와 SSR render 양쪽에서 테스트하기 위한 유틸리티입니다.
+
+## 왜 필요한가
+
+이 패키지가 없어도 client-only hook 테스트는 `@testing-library/react`로 할 수 있습니다.
+간단한 경우에는 아래처럼 충분히 동작합니다.
+
+```ts
+import { renderHook } from '@testing-library/react'
+
+const { result, unmount } = renderHook(() => useIsMounted())
+
+expect(result.current()).toBe(true)
+
+unmount()
+
+expect(result.current()).toBe(false)
+```
+
+문제는 hook에 SSR 테스트가 필요해지는 순간부터입니다. `@testing-library/react`는
+아래 흐름을 hook 단위 API로 제공하지 않습니다.
+
+1. `renderToString`으로 hook을 server render 한다.
+2. server render 중 만들어진 hook 결과를 읽는다.
+3. 같은 hook을 `hydrateRoot`로 hydrate 한다.
+4. hydration 이후에만 rerender 한다.
+5. client 테스트와 server 테스트의 결과 처리 방식을 일관되게 유지한다.
+
+이 패키지가 없다면 SSR hook 테스트마다 harness component, 결과 저장소,
+필요하다면 error boundary, server markup container, hydration root, cleanup 코드를
+직접 만들어야 합니다. 최소 형태만 적어도 아래 정도가 필요합니다.
+
+```ts
+import { createElement, useEffect, useState } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { renderToString } from 'react-dom/server'
+
+let value: boolean | undefined
+
+function TestComponent() {
+ const [hydrated, setHydrated] = useState(false)
+
+ useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ value = hydrated
+
+ return null
+}
+
+const markup = renderToString(createElement(TestComponent))
+
+expect(value).toBe(false)
+
+const container = document.createElement('div')
+container.innerHTML = markup
+
+hydrateRoot(container, createElement(TestComponent))
+```
+
+이런 setup은 미묘하게 잘못 작성하기 쉽습니다. 또한 hook이 throw한 error를 테스트 가능한
+result 값으로 저장하지 않고, render history도 남기지 않으며, client 테스트와 SSR
+테스트가 같은 result shape을 쓰지도 못합니다.
+
+이 패키지를 쓰면 같은 SSR lifecycle을 명시적이고 재사용 가능한 API로 테스트할 수 있습니다.
+
+```ts
+import { renderHookServer } from 'react-use-hook-kit-testing'
+
+const { result, hydrate } = await renderHookServer(() => useHydrationState())
+
+expect(result.value).toBe(false)
+
+await hydrate()
+
+expect(result.value).toBe(true)
+```
+
+client 테스트에서 이 패키지는 `@testing-library/react`가 hook을 render하지 못해서
+대체하는 것이 아닙니다. 목적은 client hook 테스트와 SSR hook 테스트가 같은 모델을
+쓰도록 만드는 것입니다.
+
+```ts
+result.value
+result.error
+result.all
+```
+
+즉 hook 라이브러리는 일반 browser 동작, server render 동작, hydration 동작,
+render error, result history를 하나의 API로 테스트할 수 있습니다.
+
+## Result 모델
+
+`renderHook`과 `renderHookServer`는 모두 같은 shape의 `result` 객체를 반환합니다.
+
+```ts
+result.value
+result.error
+result.all
+```
+
+`result.value`는 가장 최근에 성공한 hook render의 반환값입니다. `@testing-library/react`의
+`result.current`와 비슷한 역할을 하지만, `result.error`와 한 쌍으로 설계되어 성공한
+render와 실패한 render를 구분할 수 있습니다.
+
+```ts
+const { result } = await renderHook(() => useState('idle'))
+
+expect(result.error).toBeUndefined()
+expect(result.value[0]).toBe('idle')
+```
+
+hook이 render 중 throw하면, 각 테스트에서 error boundary를 직접 만들 필요 없이
+해당 error가 `result.error`에 저장됩니다.
+
+```ts
+const expectedError = new Error('missing provider')
+
+const { result } = await renderHook(() => {
+ throw expectedError
+})
+
+expect(result.value).toBeUndefined()
+expect(result.error).toBe(expectedError)
+```
+
+이 패키지가 없다면 render 중 throw하는 hook을 테스트하기 위해 보통 테스트 전용
+error boundary를 직접 작성해야 합니다.
+
+```tsx
+import { Component, createElement, type PropsWithChildren, type ReactNode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { act } from 'react'
+
+class TestErrorBoundary extends Component<
+ PropsWithChildren<{
+ onError(error: Error): void
+ }>,
+ {
+ hasError: boolean
+ }
+> {
+ state = {
+ hasError: false,
+ }
+
+ static getDerivedStateFromError() {
+ return {
+ hasError: true,
+ }
+ }
+
+ componentDidCatch(error: Error) {
+ this.props.onError(error)
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ return null
+ }
+
+ return this.props.children
+ }
+}
+
+let actualError: Error | undefined
+
+function TestComponent() {
+ useRequiredContext()
+
+ return null
+}
+
+const container = document.createElement('div')
+const root = createRoot(container)
+
+await act(async () => {
+ root.render(
+ createElement(
+ TestErrorBoundary,
+ {
+ onError(error) {
+ actualError = error
+ },
+ },
+ createElement(TestComponent),
+ ),
+ )
+})
+
+expect(actualError).toEqual(new Error('RequiredContext provider is missing'))
+
+await act(async () => {
+ root.unmount()
+})
+```
+
+이 코드는 hook의 비즈니스 동작 자체를 직접 테스트한다기보다, 대부분 테스트 인프라입니다.
+error boundary state, error capture, root 생성, `act`, rendering, cleanup을 모두
+테스트마다 구성해야 합니다. `renderHook`은 이 인프라를 테스트 유틸 내부에 숨깁니다.
+
+```ts
+const { result } = await renderHook(() => useRequiredContext())
+
+expect(result.error).toEqual(new Error('RequiredContext provider is missing'))
+```
+
+이 방식은 사용 제약을 의도적으로 강제하는 hook을 테스트할 때 특히 유용합니다.
+
+```ts
+function useRequiredContext() {
+ const value = useContext(RequiredContext)
+
+ if (!value) {
+ throw new Error('RequiredContext provider is missing')
+ }
+
+ return value
+}
+
+const { result } = await renderHook(() => useRequiredContext())
+
+expect(result.error).toEqual(new Error('RequiredContext provider is missing'))
+```
+
+`result.all`은 모든 render 결과를 순서대로 담습니다. 각 항목은 성공한 값이거나 error입니다.
+
+```ts
+type ResultValue =
+ | { value: T; error: undefined }
+ | { value: undefined; error: Error }
+```
+
+이 덕분에 중간 render 동작도 테스트할 수 있습니다. 예를 들어 rerender 후에도 첫 번째
+값을 잃지 않고 검증할 수 있습니다.
+
+```ts
+const { result, rerender } = await renderHook((value: string) => value, {
+ initialProps: 'first',
+})
+
+await rerender('second')
+
+expect(result.value).toBe('second')
+expect(result.all).toEqual([
+ { value: 'first', error: undefined },
+ { value: 'second', error: undefined },
+])
+```
+
+같은 history는 SSR 테스트에서도 사용할 수 있습니다. SSR 테스트에는 server render,
+hydration render, effect-driven update, 이후 client rerender처럼 서로 다른 phase가
+있기 때문에 이 점이 중요합니다.
+
+```ts
+const { result, hydrate } = await renderHookServer(() => useHydrationState())
+
+expect(result.value).toBe(false)
+
+await hydrate()
+
+expect(result.value).toBe(true)
+expect(result.all).toEqual([
+ { value: false, error: undefined }, // server render
+ { value: false, error: undefined }, // hydration render
+ { value: true, error: undefined }, // useEffect update after hydration
+])
+```
+
+여기서 값이 3개인 이유는 `hydrate()`가 끝난 뒤에도 hook 내부 `useEffect`가 state를 한 번
+더 변경하기 때문입니다. effect update가 없는 hook이라면 보통 server render 결과와
+hydration render 결과만 남아 2개가 될 수 있습니다.
+
+예시 코드에는 `act`가 직접 보이지 않지만, `hydrate()` 내부에서 `hydrateRoot`를 `act`로
+감싸서 실행합니다. 그래서 `useEffect(() => setState(...), [])`처럼 effect 안에서 동기적으로
+발생하는 update는 `await hydrate()` 이후에 반영됩니다. 단, `setTimeout`, network request,
+외부 promise처럼 effect 안에서 시작한 비동기 작업까지 자동으로 끝내주지는 않습니다.
+
+정확한 entry 개수는 hydration이나 effect-driven update 같은 React 동작에 따라 달라질 수
+있습니다. 따라서 중간 render 자체가 테스트 대상의 계약일 때만 `result.all` 전체를 엄격히
+검증하는 편이 좋습니다. 일반적인 테스트에서는 최신 `result.value`나 `result.error`를
+검증하는 쪽을 권장합니다.
+
+## Setup
+
+```ts
+import { hooksCleanup } from 'react-use-hook-kit-testing'
+import { afterEach } from 'vitest'
+
+afterEach(hooksCleanup)
+```
+
+## Client Hooks
+
+```ts
+import { act, renderHook } from 'react-use-hook-kit-testing'
+import { useState } from 'react'
+
+const { result } = await renderHook(() => useState('idle'))
+
+await act(async () => {
+ result.value[1]('ready')
+})
+```
+
+## SSR Hooks
+
+```ts
+import { renderHookServer } from 'react-use-hook-kit-testing'
+
+const { result, hydrate } = await renderHookServer(() => useIsMounted())
+
+await hydrate()
+```
diff --git a/packages/react-hooks-testing/README.md b/packages/react-hooks-testing/README.md
new file mode 100644
index 0000000..962d746
--- /dev/null
+++ b/packages/react-hooks-testing/README.md
@@ -0,0 +1,321 @@
+# react-use-hook-kit-testing
+
+Testing utilities for React hooks with client and SSR renderers.
+
+## Why This Exists
+
+Without this package, client-only hook tests can use `@testing-library/react`.
+That works for simple cases:
+
+```ts
+import { renderHook } from '@testing-library/react'
+
+const { result, unmount } = renderHook(() => useIsMounted())
+
+expect(result.current()).toBe(true)
+
+unmount()
+
+expect(result.current()).toBe(false)
+```
+
+The problem starts when a hook needs SSR coverage. `@testing-library/react`
+does not provide a hook API for this flow:
+
+1. render the hook with `renderToString`
+2. read the hook result produced during server render
+3. hydrate that same hook with `hydrateRoot`
+4. rerender only after hydration
+5. keep client and server test result handling consistent
+
+Without this package, each SSR hook test needs its own harness component,
+result storage, optional error boundary, server markup container, hydration
+root, and cleanup code. A minimal version looks like this:
+
+```ts
+import { createElement, useEffect, useState } from 'react'
+import { hydrateRoot } from 'react-dom/client'
+import { renderToString } from 'react-dom/server'
+
+let value: boolean | undefined
+
+function TestComponent() {
+ const [hydrated, setHydrated] = useState(false)
+
+ useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ value = hydrated
+
+ return null
+}
+
+const markup = renderToString(createElement(TestComponent))
+
+expect(value).toBe(false)
+
+const container = document.createElement('div')
+container.innerHTML = markup
+
+hydrateRoot(container, createElement(TestComponent))
+```
+
+That setup is easy to get subtly wrong. It also does not capture thrown hook
+errors as testable result values, does not keep a render history, and does not
+give client and SSR tests the same result shape.
+
+With this package, the same SSR lifecycle is explicit and reusable:
+
+```ts
+import { renderHookServer } from 'react-use-hook-kit-testing'
+
+const { result, hydrate } = await renderHookServer(() => useHydrationState())
+
+expect(result.value).toBe(false)
+
+await hydrate()
+
+expect(result.value).toBe(true)
+```
+
+For client tests, this package is not replacing `@testing-library/react`
+because it cannot render hooks. It exists so client and SSR hook tests can use
+the same model:
+
+```ts
+result.value
+result.error
+result.all
+```
+
+That means a hook library can test normal browser behavior, server render
+behavior, hydration behavior, render errors, and result history with one API.
+
+## Result Model
+
+Both `renderHook` and `renderHookServer` return a `result` object with the same
+shape:
+
+```ts
+result.value
+result.error
+result.all
+```
+
+`result.value` is the latest successful hook return value. It replaces the
+`result.current` style used by `@testing-library/react`, but it is paired with
+`result.error` so tests can distinguish successful renders from failed renders.
+
+```ts
+const { result } = await renderHook(() => useState('idle'))
+
+expect(result.error).toBeUndefined()
+expect(result.value[0]).toBe('idle')
+```
+
+When a hook throws during render, the error is stored on `result.error` instead
+of forcing every test to create its own error boundary.
+
+```ts
+const expectedError = new Error('missing provider')
+
+const { result } = await renderHook(() => {
+ throw expectedError
+})
+
+expect(result.value).toBeUndefined()
+expect(result.error).toBe(expectedError)
+```
+
+Without this package, testing a hook that throws during render usually requires
+building an error boundary just for the test:
+
+```tsx
+import { Component, createElement, type PropsWithChildren, type ReactNode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { act } from 'react'
+
+class TestErrorBoundary extends Component<
+ PropsWithChildren<{
+ onError(error: Error): void
+ }>,
+ {
+ hasError: boolean
+ }
+> {
+ state = {
+ hasError: false,
+ }
+
+ static getDerivedStateFromError() {
+ return {
+ hasError: true,
+ }
+ }
+
+ componentDidCatch(error: Error) {
+ this.props.onError(error)
+ }
+
+ render(): ReactNode {
+ if (this.state.hasError) {
+ return null
+ }
+
+ return this.props.children
+ }
+}
+
+let actualError: Error | undefined
+
+function TestComponent() {
+ useRequiredContext()
+
+ return null
+}
+
+const container = document.createElement('div')
+const root = createRoot(container)
+
+await act(async () => {
+ root.render(
+ createElement(
+ TestErrorBoundary,
+ {
+ onError(error) {
+ actualError = error
+ },
+ },
+ createElement(TestComponent),
+ ),
+ )
+})
+
+expect(actualError).toEqual(new Error('RequiredContext provider is missing'))
+
+await act(async () => {
+ root.unmount()
+})
+```
+
+That code is not testing the hook's business behavior directly. Most of it is
+test infrastructure: error boundary state, error capture, root creation,
+`act`, rendering, and cleanup. `renderHook` keeps that infrastructure inside
+the testing utility:
+
+```ts
+const { result } = await renderHook(() => useRequiredContext())
+
+expect(result.error).toEqual(new Error('RequiredContext provider is missing'))
+```
+
+This is useful for hooks that intentionally enforce usage constraints:
+
+```ts
+function useRequiredContext() {
+ const value = useContext(RequiredContext)
+
+ if (!value) {
+ throw new Error('RequiredContext provider is missing')
+ }
+
+ return value
+}
+
+const { result } = await renderHook(() => useRequiredContext())
+
+expect(result.error).toEqual(new Error('RequiredContext provider is missing'))
+```
+
+`result.all` contains every render result in order. Each entry is either a
+successful value or an error:
+
+```ts
+type ResultValue =
+ | { value: T; error: undefined }
+ | { value: undefined; error: Error }
+```
+
+This makes intermediate render behavior testable. For example, a rerender can
+be asserted without losing the first value:
+
+```ts
+const { result, rerender } = await renderHook((value: string) => value, {
+ initialProps: 'first',
+})
+
+await rerender('second')
+
+expect(result.value).toBe('second')
+expect(result.all).toEqual([
+ { value: 'first', error: undefined },
+ { value: 'second', error: undefined },
+])
+```
+
+The same history is available for SSR tests. That matters because SSR tests can
+have distinct phases: server render, hydration render, effect-driven updates,
+and later client rerenders.
+
+```ts
+const { result, hydrate } = await renderHookServer(() => useHydrationState())
+
+expect(result.value).toBe(false)
+
+await hydrate()
+
+expect(result.value).toBe(true)
+expect(result.all).toEqual([
+ { value: false, error: undefined }, // server render
+ { value: false, error: undefined }, // hydration render
+ { value: true, error: undefined }, // useEffect update after hydration
+])
+```
+
+This example has three entries because `hydrate()` is followed by a `useEffect`
+state update inside the hook. A hook without an effect-driven update may only
+produce two entries: one for server render and one for hydration render.
+
+The example does not call `act` directly because `hydrate()` wraps `hydrateRoot`
+with this package's `act` helper internally. That means synchronous updates
+scheduled by effects, such as `useEffect(() => setState(...), [])`, are reflected
+after `await hydrate()`. It does not automatically finish asynchronous work that
+an effect starts, such as `setTimeout`, network requests, or external promises.
+
+The exact number of entries can vary with React behavior such as hydration and
+effect-driven updates, so tests should assert the full history only when those
+intermediate renders are part of the contract. For ordinary tests, prefer
+asserting the latest `result.value` or `result.error`.
+
+## Setup
+
+```ts
+import { hooksCleanup } from 'react-use-hook-kit-testing'
+import { afterEach } from 'vitest'
+
+afterEach(hooksCleanup)
+```
+
+## Client Hooks
+
+```ts
+import { act, renderHook } from 'react-use-hook-kit-testing'
+import { useState } from 'react'
+
+const { result } = await renderHook(() => useState('idle'))
+
+await act(async () => {
+ result.value[1]('ready')
+})
+```
+
+## SSR Hooks
+
+```ts
+import { renderHookServer } from 'react-use-hook-kit-testing'
+
+const { result, hydrate } = await renderHookServer(() => useIsMounted())
+
+await hydrate()
+```
diff --git a/packages/react-hooks-testing/package.json b/packages/react-hooks-testing/package.json
new file mode 100644
index 0000000..679bc00
--- /dev/null
+++ b/packages/react-hooks-testing/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "react-use-hook-kit-testing",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Testing utilities for React hooks with SSR support.",
+ "type": "module",
+ "sideEffects": false,
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "import": "./src/index.ts"
+ },
+ "./package.json": "./package.json"
+ },
+ "scripts": {
+ "clean": "rm -rf *.tsbuildinfo",
+ "test": "vitest run",
+ "typecheck": "tsc -b"
+ },
+ "devDependencies": {
+ "@types/react": "catalog:",
+ "@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "catalog:",
+ "jsdom": "catalog:",
+ "react": "catalog:",
+ "react-dom": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:"
+ },
+ "peerDependencies": {
+ "react": ">=18 <20",
+ "react-dom": ">=18 <20"
+ }
+}
diff --git a/packages/react-hooks-testing/src/act.ts b/packages/react-hooks-testing/src/act.ts
new file mode 100644
index 0000000..22b9765
--- /dev/null
+++ b/packages/react-hooks-testing/src/act.ts
@@ -0,0 +1,48 @@
+import { act as reactAct } from 'react'
+
+type ActGlobal = typeof globalThis & {
+ IS_REACT_ACT_ENVIRONMENT?: boolean
+}
+
+/**
+ * React는 act() 내부에서 발생한 업데이트를 검증할 때
+ * `globalThis.IS_REACT_ACT_ENVIRONMENT` 값을 함께 확인한다. 이 값이 true가
+ * 아니면 React는 현재 런타임이 act()를 올바르게 지원하도록 구성된 테스트
+ * 환경인지 확신할 수 없어서, 테스트 중 상태 변경이나 effect flush가
+ * act()로 감싸져 있더라도 "현재 테스트 환경이 act(...)를 지원하도록
+ * 설정되지 않았다"는 경고를 낼 수 있다.
+ *
+ * 이 패키지는 hook 테스트 도구이기 때문에 render, hydrate, rerender,
+ * unmount 같은 모든 React 작업을 내부에서 이미 act()로 감싼다. 따라서
+ * 소비자가 Vitest, Jest, jsdom 등 각 테스트 러너 설정마다 이 전역 값을
+ * 직접 맞추지 않아도 동일하게 동작하도록, 패키지의 act() 실행 범위 안에서만
+ * 플래그를 true로 올린다.
+ *
+ * 플래그를 설정하지 않으면 테스트 결과가 통과하더라도 React 경고가 계속
+ * 출력될 수 있고, 사용자는 실제로 act()가 빠진 문제인지 테스트 환경 설정이
+ * 빠진 문제인지 구분하기 어렵다. 반대로 플래그를 영구적으로 바꾸면 같은
+ * 프로세스에서 실행되는 다른 테스트의 React 환경 설정을 침범할 수 있으므로,
+ * 작업이 끝난 뒤에는 반드시 이전 값으로 되돌린다.
+ *
+ * @see https://react.dev/reference/react/act#im-getting-an-error-the-current-testing-environment-is-not-configured-to-support-act
+ */
+function setReactActEnvironment() {
+ const globalObject = globalThis as ActGlobal
+ const previousValue = globalObject.IS_REACT_ACT_ENVIRONMENT
+
+ globalObject.IS_REACT_ACT_ENVIRONMENT = true
+
+ return () => {
+ globalObject.IS_REACT_ACT_ENVIRONMENT = previousValue
+ }
+}
+
+export async function act(callback: () => Promise): Promise {
+ const restoreEnvironment = setReactActEnvironment()
+
+ try {
+ return await reactAct(callback)
+ } finally {
+ restoreEnvironment()
+ }
+}
diff --git a/packages/react-hooks-testing/src/cleanup.ts b/packages/react-hooks-testing/src/cleanup.ts
new file mode 100644
index 0000000..ddadb0d
--- /dev/null
+++ b/packages/react-hooks-testing/src/cleanup.ts
@@ -0,0 +1,66 @@
+/**
+ * hook renderer가 등록하는 정리 작업입니다.
+ *
+ * renderer는 `renderHook` 또는 `renderHookServer`가 만든 React root를 unmount하는
+ * 것처럼 테스트가 끝난 뒤 반드시 실행해야 하는 teardown 작업을 이 callback 형태로
+ * 등록합니다. unmount는 `act`로 감싸질 수 있고 React가 effect flush를 끝낼 때까지
+ * 기다려야 할 수 있으므로 동기 함수와 비동기 함수 모두 허용합니다.
+ */
+export type CleanupCallback = () => Promise | void
+
+const callbacks = new Set()
+
+/**
+ * `hooksCleanup`이 실행할 cleanup callback을 등록합니다.
+ *
+ * `createHookRenderer`는 hook render가 성공한 뒤 이 함수를 호출합니다. 일반적인
+ * 테스트 설정에서는 `afterEach(hooksCleanup)`을 등록하므로, 테스트가 끝날 때마다
+ * 등록된 hook instance가 자동으로 unmount됩니다.
+ *
+ * 내부 저장소는 `Set`이므로 같은 callback을 여러 번 추가해도 한 번만 예약됩니다.
+ * `unmountHook`처럼 renderer가 소유한 callback이 중복 등록되어도 같은 React root를
+ * 두 번 unmount하려고 시도하지 않게 해 줍니다.
+ */
+export function cleanupAdd(callback: CleanupCallback): void {
+ callbacks.add(callback)
+}
+
+/**
+ * 이전에 등록한 cleanup callback을 제거합니다.
+ *
+ * test-level cleanup이 실행되기 전에 hook을 수동으로 unmount할 때 renderer가 이
+ * 함수를 호출합니다. 예를 들어 `unmountHook`은 먼저 자기 자신을 등록 해제한 뒤
+ * renderer별 unmount 작업을 수행합니다. 이렇게 하면 이후 `hooksCleanup`이 호출되어도
+ * 같은 React root를 다시 unmount하려고 시도하지 않습니다.
+ *
+ * 현재 등록되어 있지 않은 callback을 넘겨도 `Set.delete`와 동일하게 아무 일도
+ * 일어나지 않습니다.
+ */
+export function cleanupRemove(callback: CleanupCallback): void {
+ callbacks.delete(callback)
+}
+
+/**
+ * 등록된 모든 hook cleanup callback을 실행하고 registry를 비웁니다.
+ *
+ * 테스트 프레임워크에서 사용하는 공개 cleanup 진입점입니다.
+ *
+ * ```ts
+ * afterEach(hooksCleanup)
+ * ```
+ *
+ * 현재 callback set을 snapshot으로 복사한 뒤, callback을 실행하기 전에 registry를 먼저
+ * 비우고, 등록 순서대로 각 callback을 await합니다. 먼저 비우는 이유는 cleanup 도중
+ * 오류가 나거나 cleanup이 다시 진입하더라도 이미 예약되어 있던 callback이 registry에
+ * 남지 않게 하기 위해서입니다. 또한 cleanup callback이 새 cleanup 작업을 등록하더라도
+ * 그 작업이 같은 cleanup pass에서 바로 소비되지 않게 합니다.
+ */
+export async function hooksCleanup(): Promise {
+ const pendingCallbacks = [...callbacks]
+
+ callbacks.clear()
+
+ for (const callback of pendingCallbacks) {
+ await callback()
+ }
+}
diff --git a/packages/react-hooks-testing/src/create-hook-renderer.ts b/packages/react-hooks-testing/src/create-hook-renderer.ts
new file mode 100644
index 0000000..5b1729c
--- /dev/null
+++ b/packages/react-hooks-testing/src/create-hook-renderer.ts
@@ -0,0 +1,184 @@
+import type { JSXElementConstructor, ReactNode } from 'react'
+import type { RootOptions } from 'react-dom/client'
+import { cleanupAdd, cleanupRemove } from './cleanup.js'
+
+/**
+ * hook render 한 번의 결과를 나타냅니다.
+ *
+ * hook이 정상적으로 값을 반환하면 `value`에 결과가 들어가고 `error`는 `undefined`가
+ * 됩니다. 반대로 render 중 error boundary가 잡은 오류나 server render에서 발생한
+ * 오류는 `error`에 저장되고 `value`는 `undefined`가 됩니다.
+ *
+ * 이 union 형태 덕분에 테스트는 성공 결과와 실패 결과를 같은 history 안에서 순서대로
+ * 다룰 수 있습니다.
+ */
+export type ResultValue =
+ | {
+ readonly value: T
+ readonly error: undefined
+ }
+ | {
+ readonly value: undefined
+ readonly error: Error
+ }
+
+/**
+ * 현재 hook 결과와 전체 render history를 함께 노출하는 result 객체입니다.
+ *
+ * `value`와 `error`는 가장 최근 render 결과를 바로 읽기 위한 shortcut이고, `all`은
+ * 초기 render부터 rerender, hydration 이후 update까지 누적된 모든 결과를 순서대로
+ * 확인하기 위한 배열입니다. 중간 render가 테스트 계약에 포함되는 hook에서는 `all`을
+ * 사용하고, 일반적인 테스트에서는 최신 `value` 또는 `error`만 검증하면 됩니다.
+ */
+export type ResultValues = ResultValue & {
+ readonly all: Array>
+}
+
+/**
+ * hook renderer를 만들 때 전달하는 공통 옵션입니다.
+ *
+ * `initialProps`는 첫 render에 넘길 props이고, `wrapper`는 Context Provider처럼
+ * 테스트 대상 hook을 감싸야 하는 React component입니다. `onCaughtError`와
+ * `onRecoverableError`는 React root 생성 또는 hydration 과정에서 React가 제공하는
+ * error callback을 그대로 전달하기 위한 옵션입니다.
+ */
+export type RendererOptions = {
+ initialProps?: Props
+ wrapper?: JSXElementConstructor<{ children: ReactNode }>
+} & Pick
+
+/**
+ * 실제 renderer 구현이 hook harness에 전달하는 bridge 객체입니다.
+ *
+ * `callback`은 사용자가 `renderHook(() => ...)` 또는 `renderHookServer(() => ...)`에
+ * 넘긴 테스트 대상 hook 실행 함수입니다. `setValue`와 `setError`는 harness가 hook
+ * 실행 결과를 result store에 기록할 때 사용합니다.
+ */
+export type RendererProps = {
+ callback(props: Props): Result
+ setValue(value: Result): void
+ setError(error: Error): void
+}
+
+/**
+ * DOM renderer와 server renderer가 공통으로 구현해야 하는 lifecycle입니다.
+ *
+ * `render`는 최초 render를 수행하고, `rerender`는 같은 hook instance에 새 props를
+ * 반영하며, `unmount`는 React root를 정리합니다. 각 단계는 React `act` 또는 hydration
+ * 같은 비동기 작업을 포함할 수 있으므로 모두 `Promise`를 반환합니다.
+ */
+export type Renderer = {
+ render(props: Props | undefined): Promise
+ rerender(props: Props | undefined): Promise
+ unmount(): Promise
+}
+
+/**
+ * renderer별 lifecycle 구현을 생성하는 factory 타입입니다.
+ *
+ * client renderer는 `createRoot` 기반 lifecycle을, server renderer는
+ * `renderToString`과 `hydrateRoot` 기반 lifecycle을 이 형태로 제공합니다.
+ * `createHookRenderer`는 이 차이를 몰라도 result 저장, rerender props 관리, cleanup
+ * 등록을 같은 방식으로 처리할 수 있습니다.
+ */
+export type RendererFactory> = (
+ rendererProps: RendererProps,
+ options?: RendererOptions>,
+) => TRenderer
+
+/**
+ * hook render 결과를 저장하고 조회하는 작은 store를 만듭니다.
+ *
+ * renderer는 hook을 실행할 때마다 `setValue` 또는 `setError`를 호출하고, 테스트 코드는
+ * 반환된 `result` 객체를 통해 최신 결과와 전체 history를 읽습니다. `all` getter는
+ * 내부 배열을 직접 노출하지 않도록 매번 복사본을 반환합니다.
+ */
+function createResultStore() {
+ const results: Array> = []
+
+ const result = {
+ get all() {
+ return [...results]
+ },
+ get value() {
+ return results.at(-1)?.value
+ },
+ get error() {
+ return results.at(-1)?.error
+ },
+ } as ResultValues
+
+ return {
+ result,
+ setValue(value: T) {
+ results.push(Object.freeze({ value, error: undefined }))
+ },
+ setError(error: Error) {
+ results.push(Object.freeze({ value: undefined, error }))
+ },
+ }
+}
+
+/**
+ * renderer별 구현을 공통 `renderHook` API 형태로 감싸는 factory입니다.
+ *
+ * 이 함수는 DOM과 SSR renderer가 공유하는 흐름을 담당합니다. 사용자의 hook callback과
+ * result store를 renderer에 연결하고, 최초 render를 수행한 뒤, `rerender`와 `unmount`
+ * helper를 만들어 반환합니다. renderer가 `hydrate`처럼 추가 메서드를 제공하는 경우
+ * 그 메서드도 결과 객체에 그대로 포함됩니다.
+ *
+ * 최초 render가 끝나면 `unmountHook`을 cleanup registry에 등록합니다. 사용자가 직접
+ * `unmount`를 호출하면 먼저 registry에서 제거한 뒤 unmount하므로, 이후
+ * `afterEach(hooksCleanup)`이 실행되어도 같은 hook instance가 중복 정리되지 않습니다.
+ */
+export function createHookRenderer>(
+ createRenderer: RendererFactory,
+) {
+ return async (callback: (props: Props) => Result, options?: RendererOptions) => {
+ const { result, setValue, setError } = createResultStore()
+ let currentProps = options?.initialProps
+ const { render, rerender, unmount, ...rendererRest } = createRenderer(
+ {
+ callback,
+ setValue,
+ setError,
+ },
+ options,
+ )
+
+ await render(currentProps)
+
+ /**
+ * 다음 props로 같은 hook instance를 다시 render합니다.
+ *
+ * 인자를 생략하면 마지막으로 사용한 props를 유지합니다. 이 동작은 state update 후
+ * wrapper나 renderer 상태는 유지하면서 hook callback만 다시 평가하고 싶을 때 쓰는
+ * `renderHook` 계열 API의 rerender semantics입니다.
+ */
+ const rerenderHook = async (nextProps?: Props) => {
+ currentProps = nextProps ?? currentProps
+ await rerender(currentProps)
+ }
+
+ /**
+ * 이 hook instance를 수동으로 unmount합니다.
+ *
+ * 먼저 cleanup registry에서 자기 자신을 제거한 다음 renderer별 unmount를 실행합니다.
+ * 이렇게 해야 수동 unmount와 test-level `hooksCleanup`이 같은 React root를 두 번
+ * 정리하지 않습니다.
+ */
+ const unmountHook = async () => {
+ cleanupRemove(unmountHook)
+ await unmount()
+ }
+
+ cleanupAdd(unmountHook)
+
+ return {
+ result,
+ rerender: rerenderHook,
+ unmount: unmountHook,
+ ...rendererRest,
+ }
+ }
+}
diff --git a/packages/react-hooks-testing/src/harness.tsx b/packages/react-hooks-testing/src/harness.tsx
new file mode 100644
index 0000000..fa47b83
--- /dev/null
+++ b/packages/react-hooks-testing/src/harness.tsx
@@ -0,0 +1,170 @@
+import { Component, createElement } from 'react'
+import type { JSXElementConstructor, PropsWithChildren, ReactNode } from 'react'
+import type { RendererProps } from './create-hook-renderer.js'
+
+/**
+ * hook 실행 중 발생한 render error를 result store에 전달하기 위한 error boundary
+ * props입니다.
+ *
+ * `onError`는 잡힌 error와 boundary 상태를 되돌리는 `reset` 함수를 함께 받습니다.
+ * harness는 이 `reset` 함수를 보관해 두었다가 다음 render 직전에 호출해서, 한 번
+ * error가 난 hook도 이후 rerender에서 다시 평가될 수 있게 합니다.
+ */
+type ErrorBoundaryProps = PropsWithChildren<{
+ onError(error: Error, reset: () => void): void
+}>
+
+/**
+ * error boundary가 현재 fallback 상태인지 나타냅니다.
+ *
+ * React error boundary는 error를 잡은 뒤 state를 바꿔 children을 다시 render하지
+ * 않으므로, 다음 hook render를 시도하려면 이 값을 다시 `false`로 reset해야 합니다.
+ */
+type ErrorBoundaryState = {
+ hasError: boolean
+}
+
+/**
+ * hook render 중 throw된 error를 잡아 테스트 result로 기록하는 boundary입니다.
+ *
+ * 이 패키지는 hook이 throw한 error를 테스트 자체의 예외로 바로 터뜨리지 않고
+ * `result.error`에 저장합니다. 그래야 사용 제약을 의도적으로 검증하는 hook도
+ * `expect(result.error)` 형태로 테스트할 수 있습니다.
+ */
+class ErrorBoundary extends Component {
+ override state: ErrorBoundaryState = {
+ hasError: false,
+ }
+
+ /**
+ * React가 render phase error를 감지했을 때 fallback 상태로 전환합니다.
+ *
+ * 여기서는 별도 fallback UI를 보여 주지 않고 `render`에서 `null`을 반환하게 하여,
+ * 테스트 대상 hook 실행을 중단한 상태로 유지합니다.
+ */
+ static getDerivedStateFromError(): ErrorBoundaryState {
+ return {
+ hasError: true,
+ }
+ }
+
+ /**
+ * 이전 render error 상태를 해제합니다.
+ *
+ * harness는 다음 render를 시작하기 전에 이 함수를 호출합니다. 이렇게 해야 error를
+ * 낸 이전 render 때문에 boundary가 계속 `null`만 반환하는 상태에 머물지 않습니다.
+ */
+ reset = () => {
+ this.setState({
+ hasError: false,
+ })
+ }
+
+ /**
+ * 잡힌 error를 harness로 전달합니다.
+ *
+ * `reset` 함수도 함께 넘겨서 harness가 다음 render 직전에 boundary 상태를 복구할 수
+ * 있게 합니다. 실제 error 기록은 result store를 알고 있는 harness가 담당합니다.
+ */
+ override componentDidCatch(error: Error): void {
+ this.props.onError(error, this.reset)
+ }
+
+ /**
+ * 정상 상태에서는 children을 render하고, error 상태에서는 아무것도 render하지 않습니다.
+ *
+ * 테스트 유틸은 DOM 출력이 아니라 hook callback의 반환값과 error history가 목적이므로
+ * 별도 fallback UI가 필요하지 않습니다.
+ */
+ override render(): ReactNode {
+ if (this.state.hasError) {
+ return null
+ }
+
+ return this.props.children
+ }
+}
+
+/**
+ * hook callback을 React component tree 안에서 실행하는 harness를 만듭니다.
+ *
+ * React hook은 component body 안에서만 호출할 수 있으므로, renderer는 이 harness가
+ * 반환하는 element factory를 React root에 render합니다. 내부 `TestComponent`가
+ * 사용자의 hook callback을 실행하고, 성공하면 `setValue`, throw하면 `ErrorBoundary`를
+ * 통해 `setError`로 result store에 기록합니다.
+ *
+ * `wrapper`가 전달되면 Context Provider 같은 테스트용 상위 component로
+ * `TestComponent`를 감쌉니다. client renderer와 server renderer 모두 같은 harness를
+ * 사용하므로 result 저장과 error capture 방식이 동일하게 유지됩니다.
+ */
+export function createHookHarness(
+ { callback, setValue, setError }: RendererProps,
+ wrapper?: JSXElementConstructor<{ children: ReactNode }>,
+) {
+ /**
+ * 실제로 테스트 대상 hook callback을 실행하는 component입니다.
+ *
+ * hook callback의 반환값은 DOM에 그리지 않고 즉시 result store에 저장합니다. 이
+ * component가 `null`을 반환하는 이유는 테스트 대상이 화면 출력이 아니라 hook의
+ * return value와 render error이기 때문입니다.
+ */
+ function TestComponent({ hookProps }: { hookProps: Props }) {
+ setValue(callback(hookProps))
+ return null
+ }
+
+ let resetErrorBeforeNextRender: (() => void) | undefined
+
+ /**
+ * error boundary가 잡은 render error를 result store에 기록합니다.
+ *
+ * React는 render 중 error가 발생하면 `getDerivedStateFromError`로 boundary state를
+ * `hasError: true`로 바꾼 뒤, 해당 render pass를 fallback render로 마무리합니다.
+ * 이 파일의 fallback은 UI를 보여 주지 않는 `null`입니다.
+ *
+ * 여기서 `reset()`을 바로 호출하지 않고 `resetErrorBeforeNextRender`에 저장하는 이유는,
+ * 현재 render pass는 이미 "이번 hook render는 실패했고 boundary가 fallback을
+ * 렌더링한다"는 흐름으로 진행 중이기 때문입니다. 같은 흐름 안에서 즉시 boundary를
+ * 되돌리기보다, error는 `result.error`에 기록하고 이번 render는 실패 결과로 끝냅니다.
+ *
+ * 이후 사용자가 `rerender`를 호출하면 아래 element factory가 새 element를 만들기 전에
+ * 저장된 `resetErrorBeforeNextRender`를 실행합니다. 그때 boundary state를
+ * `hasError: false`로 되돌려야 이전 error 때문에 계속 `null`만 반환하지 않고
+ * `TestComponent`를 다시 렌더링할 수 있습니다.
+ */
+ const handleError = (error: Error, reset: () => void) => {
+ resetErrorBeforeNextRender = () => {
+ /**
+ * 저장된 reset closure는 한 번만 사용하고 바로 비웁니다.
+ *
+ * 비우지 않으면 이후 render마다 같은 reset이 반복 실행될 수 있고, 이미 처리한
+ * 이전 ErrorBoundary instance의 `reset` 함수 참조도 계속 남게 됩니다.
+ */
+ resetErrorBeforeNextRender = undefined
+ reset()
+ }
+
+ setError(error)
+ }
+
+ /**
+ * renderer가 `render` 또는 `rerender` 때마다 호출하는 element factory입니다.
+ *
+ * 이전 render에서 error가 있었다면 `resetErrorBeforeNextRender?.()`가 먼저 boundary
+ * state를 `hasError: false`로 되돌립니다. 이 reset이 없으면 React error boundary는 계속
+ * fallback 상태이므로 children을 렌더링하지 않고, 결과적으로 새 props를 넘겨도
+ * `TestComponent`와 hook callback이 다시 실행되지 않습니다.
+ *
+ * reset 후에는 새 props로 `TestComponent`를 구성합니다. wrapper가 있으면 hook이
+ * 필요한 provider 환경 안에서 실행되도록 감싸고, 마지막으로 error boundary를 씌워
+ * 다음 render에서 발생하는 error도 `result.error`로 수집합니다.
+ */
+ return (props: Props) => {
+ resetErrorBeforeNextRender?.()
+
+ const hookElement: ReactNode =
+ const children = wrapper ? createElement(wrapper, null, hookElement) : hookElement
+
+ return {children}
+ }
+}
diff --git a/packages/react-hooks-testing/src/index.ts b/packages/react-hooks-testing/src/index.ts
new file mode 100644
index 0000000..e05ffca
--- /dev/null
+++ b/packages/react-hooks-testing/src/index.ts
@@ -0,0 +1,6 @@
+export { act } from './act.js'
+export { cleanupAdd, cleanupRemove, hooksCleanup } from './cleanup.js'
+export type { CleanupCallback } from './cleanup.js'
+export { renderHook } from './render-hook.js'
+export { renderHookServer } from './render-hook-server.js'
+export type { ResultValue, ResultValues } from './create-hook-renderer.js'
diff --git a/packages/react-hooks-testing/src/render-hook-server.test.ts b/packages/react-hooks-testing/src/render-hook-server.test.ts
new file mode 100644
index 0000000..dcb1ff4
--- /dev/null
+++ b/packages/react-hooks-testing/src/render-hook-server.test.ts
@@ -0,0 +1,78 @@
+import { useEffect, useState } from 'react'
+import { afterEach, describe, expect, test } from 'vitest'
+import { hooksCleanup, renderHookServer } from './index.js'
+
+describe(renderHookServer.name, () => {
+ afterEach(async () => {
+ await hooksCleanup()
+ })
+
+ test('renders a hook on the server before hydration', async () => {
+ // given
+ function useIsHydrated() {
+ const [hydrated, setHydrated] = useState(false)
+
+ useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return hydrated
+ }
+
+ // when
+ const { result } = await renderHookServer(() => useIsHydrated())
+ const actual = result.value
+
+ // then
+ expect(actual).toBe(false)
+ })
+
+ test('hydrates a server-rendered hook', async () => {
+ // given
+ function useIsHydrated() {
+ const [hydrated, setHydrated] = useState(false)
+
+ useEffect(() => {
+ setHydrated(true)
+ }, [])
+
+ return hydrated
+ }
+ const { result, hydrate } = await renderHookServer(() => useIsHydrated())
+
+ // when
+ await hydrate()
+ const actual = result.value
+
+ // then
+ expect(actual).toBe(true)
+ })
+
+ test('requires hydration before rerendering', async () => {
+ // given
+ const { rerender } = await renderHookServer((value: string) => value, {
+ initialProps: 'first',
+ })
+
+ // when
+ const actual = rerender('second')
+
+ // then
+ await expect(actual).rejects.toThrow('Cannot rerender before hydrating the hook.')
+ })
+
+ test('captures server render errors as hook results', async () => {
+ // given
+ const expectedError = new Error('server hook failed')
+
+ // when
+ const { result } = await renderHookServer(() => {
+ throw expectedError
+ })
+ const actual = result.error
+
+ // then
+ expect(actual).toBe(expectedError)
+ expect(result.value).toBeUndefined()
+ })
+})
diff --git a/packages/react-hooks-testing/src/render-hook-server.ts b/packages/react-hooks-testing/src/render-hook-server.ts
new file mode 100644
index 0000000..0d0d899
--- /dev/null
+++ b/packages/react-hooks-testing/src/render-hook-server.ts
@@ -0,0 +1,169 @@
+import { hydrateRoot } from 'react-dom/client'
+import type { Root } from 'react-dom/client'
+import { renderToString } from 'react-dom/server'
+import { act } from './act.js'
+import { createHookRenderer } from './create-hook-renderer.js'
+import type { RendererOptions, RendererProps } from './create-hook-renderer.js'
+import { createHookHarness } from './harness.js'
+
+/**
+ * SSR hook 테스트용 renderer lifecycle을 만듭니다.
+ *
+ * 이 renderer는 최초 render에서 React DOM을 만들지 않고 `renderToString`으로 server
+ * markup만 생성합니다. 이후 사용자가 반환 객체의 `hydrate()`를 호출하면 그 markup을
+ * 실제 DOM container에 넣고 `hydrateRoot`로 client root를 연결합니다.
+ *
+ * `createHookRenderer`는 이 renderer가 제공하는 `render`, `rerender`, `unmount` 공통
+ * lifecycle에 더해 `hydrate` 같은 추가 메서드도 결과 객체에 그대로 노출합니다. 그래서
+ * `renderHookServer` 사용자는 server render 결과를 먼저 검증한 뒤, 필요한 시점에
+ * hydration을 명시적으로 진행할 수 있습니다.
+ */
+function createServerRenderer(
+ rendererProps: RendererProps,
+ options?: RendererOptions>,
+) {
+ /**
+ * hydration에 사용할 DOM container입니다.
+ *
+ * 값이 있으면 이미 hydration이 시작된 상태로 간주합니다. 같은 server markup을 같은
+ * renderer instance에서 두 번 hydrate하면 React root와 container lifecycle이 꼬이므로
+ * `hydrate()`에서 중복 호출을 막습니다.
+ */
+ let container: HTMLDivElement | undefined
+
+ /**
+ * hydration 이후 생성되는 React client root입니다.
+ *
+ * server render만 끝난 상태에서는 아직 client root가 없으므로 `rerender`를 허용하지
+ * 않습니다. hydration이 끝난 뒤에만 같은 root에 `root.render(...)`로 rerender할 수
+ * 있습니다.
+ */
+ let root: Root | undefined
+
+ /**
+ * `renderToString`으로 만든 server markup입니다.
+ *
+ * 최초 `render`에서 채워지고, `hydrate()`가 DOM container의 `innerHTML`로 사용합니다.
+ * hook이 server render 중 throw하면 markup은 비어 있을 수 있지만, 그 error는
+ * `rendererProps.setError`로 result store에 기록됩니다.
+ */
+ let markup = ''
+
+ /**
+ * server render와 hydration/rerender 사이에서 유지되는 최신 props입니다.
+ *
+ * hydration은 server render와 같은 props로 시작해야 markup과 client tree가 맞습니다.
+ * 이후 `rerender`가 호출되면 이 값을 갱신하고 같은 hydrated root에 새 props를
+ * 반영합니다.
+ */
+ let currentProps: Props | undefined
+
+ /**
+ * 사용자의 hook callback을 React element로 실행하는 공통 harness입니다.
+ *
+ * server render와 client hydration이 같은 harness를 사용해야 result 저장 방식,
+ * wrapper 적용 방식, render error capture 방식이 client renderer와 일관됩니다.
+ */
+ const harness = createHookHarness(rendererProps, options?.wrapper)
+
+ return {
+ /**
+ * hook을 server 환경에서 최초 render하고 markup을 저장합니다.
+ *
+ * `renderToString`은 server markup을 만들면서 hook callback을 실행합니다. 정상적으로
+ * hook이 값을 반환하면 harness가 `setValue`로 result store에 기록합니다.
+ *
+ * server render 중 throw된 error는 client error boundary의 `componentDidCatch` 흐름을
+ * 거치지 않을 수 있으므로 여기서 직접 잡아 `setError`에 기록합니다. throw된 값이
+ * `Error`가 아니어도 테스트 result는 항상 `Error` 형태를 기대하므로 `String(error)`로
+ * 감싼 새 `Error`로 변환합니다.
+ */
+ async render(props: Props | undefined) {
+ currentProps = props
+
+ try {
+ markup = renderToString(harness(props as Props))
+ } catch (error) {
+ rendererProps.setError(error instanceof Error ? error : new Error(String(error)))
+ }
+ },
+
+ /**
+ * 저장된 server markup을 DOM에 넣고 React client root로 hydrate합니다.
+ *
+ * hydration은 server render 이후 client lifecycle을 시작하는 단계입니다. 이 메서드는
+ * `act`로 `hydrateRoot`를 감싸므로 hydration 중 동기 effect update가 있으면
+ * `await hydrate()` 이후 result store에 반영됩니다.
+ *
+ * 같은 renderer instance에서 hydration을 두 번 실행하면 같은 markup과 container에
+ * 여러 root를 연결하려는 상태가 되므로 `container` 존재 여부로 중복 호출을 막습니다.
+ */
+ async hydrate() {
+ if (container) {
+ throw new Error('The hook has already been hydrated.')
+ }
+
+ container = document.createElement('div')
+ container.innerHTML = markup
+
+ await act(async () => {
+ root = hydrateRoot(container as HTMLDivElement, harness(currentProps as Props), {
+ onCaughtError(...args) {
+ options?.onCaughtError?.(...args)
+ },
+ onRecoverableError: options?.onRecoverableError,
+ })
+ })
+ },
+
+ /**
+ * hydration이 끝난 hook instance를 새 props로 다시 render합니다.
+ *
+ * SSR flow에서는 server markup만 있는 상태와 hydrated client root가 있는 상태를
+ * 구분해야 합니다. hydration 전에는 `root`가 없고 React가 update를 적용할 대상도
+ * 없으므로 rerender를 허용하지 않습니다.
+ *
+ * hydration 이후에는 최신 props를 저장한 뒤 같은 root에 harness element를 다시
+ * render합니다. 이때도 `act`로 감싸 React update와 effect flush가 테스트에서
+ * 관찰 가능한 시점까지 끝나도록 합니다.
+ */
+ async rerender(props: Props | undefined) {
+ if (!root) {
+ throw new Error('Cannot rerender before hydrating the hook.')
+ }
+
+ currentProps = props
+
+ await act(async () => {
+ root?.render(harness(currentProps as Props))
+ })
+ },
+
+ /**
+ * hydration으로 만들어진 client root를 정리합니다.
+ *
+ * server render만 수행하고 hydrate하지 않은 테스트에서는 client root가 없으므로 아무
+ * 작업도 하지 않습니다. hydration이 끝난 경우에는 `act` 안에서 `root.unmount()`를
+ * 호출해 effect cleanup까지 React 테스트 흐름 안에서 처리되게 합니다.
+ */
+ async unmount() {
+ if (!root) {
+ return
+ }
+
+ await act(async () => {
+ root?.unmount()
+ })
+
+ root = undefined
+ },
+ }
+}
+
+/**
+ * SSR hook 테스트에 사용하는 공개 API입니다.
+ *
+ * `createServerRenderer`를 공통 hook renderer factory에 연결해 `result`, `rerender`,
+ * `unmount`와 SSR 전용 `hydrate`를 함께 제공하는 `renderHookServer` 함수를 만듭니다.
+ */
+export const renderHookServer = createHookRenderer(createServerRenderer)
diff --git a/packages/react-hooks-testing/src/render-hook.test.ts b/packages/react-hooks-testing/src/render-hook.test.ts
new file mode 100644
index 0000000..5e60696
--- /dev/null
+++ b/packages/react-hooks-testing/src/render-hook.test.ts
@@ -0,0 +1,103 @@
+import { useEffect, useState } from 'react'
+import { afterEach, describe, expect, test } from 'vitest'
+import { act, hooksCleanup, renderHook } from './index.js'
+
+describe(renderHook.name, () => {
+ afterEach(async () => {
+ await hooksCleanup()
+ })
+
+ test('renders a hook and stores its result history', async () => {
+ // given
+ type Props = {
+ value: string
+ }
+
+ // when
+ const { result, rerender } = await renderHook((props: Props) => props.value, {
+ initialProps: {
+ value: 'first',
+ },
+ })
+ await rerender({
+ value: 'second',
+ })
+
+ // then
+ expect(result.value).toBe('second')
+ expect(result.error).toBeUndefined()
+ expect(result.all).toEqual([
+ {
+ value: 'first',
+ error: undefined,
+ },
+ {
+ value: 'second',
+ error: undefined,
+ },
+ ])
+ })
+
+ test('updates state inside async act', async () => {
+ // given
+ const { result } = await renderHook(() => useState('idle'))
+ expect(result.error).toBeUndefined()
+ if (result.error) {
+ throw result.error
+ }
+ const [, setStatus] = result.value
+
+ // when
+ await act(async () => {
+ setStatus('ready')
+ })
+ const actual = result.value[0]
+
+ // then
+ expect(actual).toBe('ready')
+ })
+
+ test('captures render errors as hook results', async () => {
+ // given
+ const expectedError = new Error('broken hook')
+
+ // when
+ const { result } = await renderHook(() => {
+ throw expectedError
+ })
+ const actual = result.error
+
+ // then
+ expect(actual).toBe(expectedError)
+ expect(result.value).toBeUndefined()
+ })
+
+ test('unmounts registered hooks with hooksCleanup', async () => {
+ // given
+ let unmountCount = 0
+ await renderHook(() => {
+ useEffect(() => {
+ return () => {
+ unmountCount += 1
+ }
+ }, [])
+
+ return null
+ })
+ await renderHook(() => {
+ useEffect(() => {
+ return () => {
+ unmountCount += 1
+ }
+ }, [])
+
+ return null
+ })
+ // when
+ await hooksCleanup()
+ const actual = unmountCount
+
+ // then
+ expect(actual).toBe(2)
+ })
+})
diff --git a/packages/react-hooks-testing/src/render-hook.ts b/packages/react-hooks-testing/src/render-hook.ts
new file mode 100644
index 0000000..f20570a
--- /dev/null
+++ b/packages/react-hooks-testing/src/render-hook.ts
@@ -0,0 +1,110 @@
+import { createRoot } from 'react-dom/client'
+import type { Root } from 'react-dom/client'
+import { act } from './act.js'
+import { createHookRenderer } from './create-hook-renderer.js'
+import type { RendererOptions, RendererProps } from './create-hook-renderer.js'
+import { createHookHarness } from './harness.js'
+
+/**
+ * client hook 테스트용 renderer lifecycle을 만듭니다.
+ *
+ * 이 renderer는 `createRoot`로 React client root를 만들고, 공통 harness를 그 root에
+ * 렌더링해서 사용자의 hook callback을 component body 안에서 실행합니다. SSR renderer와
+ * 달리 server markup이나 hydration 단계가 없으므로 최초 `render` 직후 바로 rerender와
+ * unmount가 가능합니다.
+ */
+function createDomRenderer(
+ rendererProps: RendererProps,
+ options?: RendererOptions>,
+) {
+ /**
+ * 현재 hook instance를 렌더링하는 React client root입니다.
+ *
+ * 최초 `render`에서 생성되고 `unmount`에서 정리됩니다. `root`가 없다는 것은 아직
+ * render되지 않았거나 이미 unmount된 상태이므로 `rerender`할 대상이 없다는 뜻입니다.
+ */
+ let root: Root | undefined
+
+ /**
+ * hook callback을 React element로 실행하는 공통 harness입니다.
+ *
+ * harness는 hook 반환값을 result store에 기록하고, render 중 throw된 error를
+ * `result.error`로 수집하며, 필요하면 `wrapper`로 테스트용 provider 환경을 구성합니다.
+ */
+ const harness = createHookHarness(rendererProps, options?.wrapper)
+
+ return {
+ /**
+ * hook을 client root에 최초 render합니다.
+ *
+ * 테스트 대상은 DOM 출력이 아니라 hook 결과이므로 root는 문서에 붙이지 않은 새
+ * `div`에 생성합니다. React는 이 detached container 안에서도 hook lifecycle과 effect
+ * 처리를 수행할 수 있습니다.
+ *
+ * `createRoot`와 `root.render`를 `act`로 감싸서 render 중 발생하는 동기 update와
+ * effect flush가 테스트에서 관찰 가능한 시점까지 처리되도록 합니다. React root
+ * option인 `onCaughtError`와 `onRecoverableError`는 사용자가 넘긴 callback으로
+ * 전달합니다.
+ */
+ async render(props: Props | undefined) {
+ await act(async () => {
+ root = createRoot(document.createElement('div'), {
+ onCaughtError(...args) {
+ options?.onCaughtError?.(...args)
+ },
+ onRecoverableError: options?.onRecoverableError,
+ })
+ root.render(harness(props as Props))
+ })
+ },
+
+ /**
+ * 같은 hook instance를 새 props로 다시 render합니다.
+ *
+ * 최초 `render`가 root를 만든 뒤에만 rerender할 수 있습니다. root가 없으면 React가
+ * update를 적용할 대상이 없고, result history도 어떤 hook instance에 이어지는지
+ * 정의할 수 없으므로 명시적으로 error를 던집니다.
+ *
+ * rerender도 `act` 안에서 수행해 state update, render error capture, effect flush가
+ * 테스트 흐름 안에서 안정적으로 반영되게 합니다.
+ */
+ async rerender(props: Props | undefined) {
+ if (!root) {
+ throw new Error('Cannot rerender before the hook is rendered.')
+ }
+
+ await act(async () => {
+ root?.render(harness(props as Props))
+ })
+ },
+
+ /**
+ * 현재 hook instance를 unmount하고 React root 참조를 비웁니다.
+ *
+ * 이미 unmount되었거나 render가 시작되지 않았다면 정리할 root가 없으므로 바로
+ * 반환합니다. root가 있는 경우에는 `act` 안에서 `root.unmount()`를 호출해 effect
+ * cleanup이 React 테스트 흐름 안에서 실행되게 하고, 이후 중복 unmount나 rerender를
+ * 막기 위해 `root`를 `undefined`로 되돌립니다.
+ */
+ async unmount() {
+ if (!root) {
+ return
+ }
+
+ await act(async () => {
+ root?.unmount()
+ })
+
+ root = undefined
+ },
+ }
+}
+
+/**
+ * client hook 테스트에 사용하는 공개 API입니다.
+ *
+ * `createDomRenderer`를 공통 hook renderer factory에 연결해 `result`, `rerender`,
+ * `unmount`를 제공하는 `renderHook` 함수를 만듭니다. 테스트가 끝날 때는
+ * `hooksCleanup`을 통해 등록된 unmount 작업이 자동으로 실행될 수 있습니다.
+ */
+export const renderHook = createHookRenderer(createDomRenderer)
diff --git a/packages/react-hooks-testing/tsconfig.json b/packages/react-hooks-testing/tsconfig.json
new file mode 100644
index 0000000..991b781
--- /dev/null
+++ b/packages/react-hooks-testing/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "composite": true,
+ "jsx": "react-jsx",
+ "types": ["vitest/globals"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "vitest.config.ts"]
+}
diff --git a/packages/react-hooks-testing/vitest.config.ts b/packages/react-hooks-testing/vitest.config.ts
new file mode 100644
index 0000000..f4edb11
--- /dev/null
+++ b/packages/react-hooks-testing/vitest.config.ts
@@ -0,0 +1,10 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b543a32..6b11773 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -157,6 +157,33 @@ importers:
specifier: 'catalog:'
version: 4.1.6(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.12(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))
+ packages/react-hooks-testing:
+ devDependencies:
+ '@types/react':
+ specifier: 'catalog:'
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: 'catalog:'
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: 'catalog:'
+ version: 6.0.1(vite@8.0.12(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))
+ jsdom:
+ specifier: 'catalog:'
+ version: 29.1.1
+ react:
+ specifier: 'catalog:'
+ version: 19.2.6
+ react-dom:
+ specifier: 'catalog:'
+ version: 19.2.6(react@19.2.6)
+ typescript:
+ specifier: 'catalog:'
+ version: 6.0.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.6(@vitest/coverage-v8@4.1.6)(jsdom@29.1.1)(vite@8.0.12(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.9.0))
+
packages:
'@andrewbranch/untar.js@1.0.3':
diff --git a/sheriff.config.ts b/sheriff.config.ts
index b9d23c7..6087d63 100644
--- a/sheriff.config.ts
+++ b/sheriff.config.ts
@@ -5,17 +5,20 @@ export const config: SheriffConfig = {
entryPoints: {
docs: './apps/docs/src/pages/index.astro',
+ 'react-hooks-testing': './packages/react-hooks-testing/src/index.ts',
},
modules: {
'apps/docs/src': 'app:docs',
'packages/react-hook-kit/src': 'lib:react-hook-kit',
+ 'packages/react-hooks-testing/src': 'lib:react-hooks-testing',
},
depRules: {
'app:*': [sameTag, 'lib:react-hook-kit'],
'lib:react-hook-kit': noDependencies,
- root: ['app:*', 'lib:react-hook-kit', 'noTag'],
- noTag: ['noTag', 'lib:react-hook-kit'],
+ 'lib:react-hooks-testing': noDependencies,
+ root: ['app:*', 'lib:react-hook-kit', 'lib:react-hooks-testing', 'noTag'],
+ noTag: ['noTag', 'lib:react-hook-kit', 'lib:react-hooks-testing'],
},
}
diff --git a/tsconfig.json b/tsconfig.json
index c83bd80..e0f3ec2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,5 +2,5 @@
"extends": "./tsconfig.base.json",
"compilerOptions": {},
"files": [],
- "references": [{ "path": "packages/react-hook-kit" }]
+ "references": [{ "path": "packages/react-hooks-testing" }, { "path": "packages/react-hook-kit" }]
}
diff --git a/vitest.workspace.ts b/vitest.workspace.ts
index b55ad05..38fc64a 100644
--- a/vitest.workspace.ts
+++ b/vitest.workspace.ts
@@ -1 +1 @@
-export default ['packages/react-hook-kit']
+export default ['packages/react-hook-kit', 'packages/react-hooks-testing']