/** * Comprehensive Unit Tests for retry.ts * * 테스트 범위: * 1. 즉시 성공 (재시도 없음) * 2. 재시도 후 성공 (N번 실패 후) * 3. 모든 재시도 실패 (RetryError) * 4. 지수 백오프 검증 (delay 증가) * 5. maxDelay 상한선 확인 * 6. Jitter 적용 확인 * 7. 커스텀 옵션 테스트 * 8. RetryError 속성 검증 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { retryWithBackoff, RetryError, RetryOptions } from '../src/utils/retry'; import { metrics } from '../src/utils/metrics'; // Mock dependencies vi.mock('../src/utils/metrics', () => ({ metrics: { increment: vi.fn(), gauge: vi.fn(), timing: vi.fn(), }, })); vi.mock('../src/services/notification', () => ({ notifyAdmin: vi.fn().mockResolvedValue(undefined), })); vi.mock('../src/utils/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), startTimer: vi.fn(() => vi.fn()), }), })); describe('retryWithBackoff', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); }); describe('즉시 성공', () => { it('should return result on first attempt', async () => { const mockFn = vi.fn().mockResolvedValue('success'); const promise = retryWithBackoff(mockFn); await vi.runAllTimersAsync(); const result = await promise; expect(result).toBe('success'); expect(mockFn).toHaveBeenCalledTimes(1); }); it('should not add any delays on immediate success', async () => { const mockFn = vi.fn().mockResolvedValue(42); const promise = retryWithBackoff(mockFn); await vi.runAllTimersAsync(); const result = await promise; expect(result).toBe(42); expect(vi.getTimerCount()).toBe(0); // No pending timers }); it('should handle complex return types', async () => { const mockData = { id: 1, name: 'test', values: [1, 2, 3] }; const mockFn = vi.fn().mockResolvedValue(mockData); const promise = retryWithBackoff(mockFn); await vi.runAllTimersAsync(); const result = await promise; expect(result).toEqual(mockData); }); }); describe('재시도 후 성공', () => { it('should succeed after 1 failure', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('First failure')) .mockResolvedValueOnce('success'); const promise = retryWithBackoff(mockFn, { maxRetries: 3 }); // Fast-forward through delays await vi.runAllTimersAsync(); const result = await promise; expect(result).toBe('success'); expect(mockFn).toHaveBeenCalledTimes(2); // Initial + 1 retry }); it('should succeed after 2 failures', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('First failure')) .mockRejectedValueOnce(new Error('Second failure')) .mockResolvedValueOnce('success'); const promise = retryWithBackoff(mockFn, { maxRetries: 3 }); await vi.runAllTimersAsync(); const result = await promise; expect(result).toBe('success'); expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries }); it('should succeed on last attempt', async () => { const maxRetries = 3; const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockRejectedValueOnce(new Error('Fail 3')) .mockResolvedValueOnce('success on last'); const promise = retryWithBackoff(mockFn, { maxRetries }); await vi.runAllTimersAsync(); const result = await promise; expect(result).toBe('success on last'); expect(mockFn).toHaveBeenCalledTimes(4); // Initial + 3 retries }); }); describe('모든 재시도 실패', () => { it('should throw RetryError after exhausting all retries', async () => { const originalError = new Error('Persistent failure'); const mockFn = vi.fn().mockRejectedValue(originalError); const promise = retryWithBackoff(mockFn, { maxRetries: 2 }); await vi.runAllTimersAsync(); await expect(promise).rejects.toThrow(RetryError); expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2 retries }); it('should preserve original error in RetryError', async () => { const originalError = new Error('Original failure'); originalError.stack = 'Original stack trace'; const mockFn = vi.fn().mockRejectedValue(originalError); const promise = retryWithBackoff(mockFn, { maxRetries: 1 }); await vi.runAllTimersAsync(); try { await promise; expect.fail('Should have thrown RetryError'); } catch (error) { expect(error).toBeInstanceOf(RetryError); const retryError = error as RetryError; expect(retryError.name).toBe('RetryError'); expect(retryError.attempts).toBe(2); // Initial + 1 retry expect(retryError.lastError).toBe(originalError); expect(retryError.lastError.message).toBe('Original failure'); expect(retryError.lastError.stack).toBe('Original stack trace'); } }); it('should include attempts count in RetryError message', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('API error')); const promise = retryWithBackoff(mockFn, { maxRetries: 2 }); await vi.runAllTimersAsync(); try { await promise; expect.fail('Should have thrown RetryError'); } catch (error) { const retryError = error as RetryError; expect(retryError.message).toContain('3 attempts'); // Initial + 2 retries expect(retryError.message).toContain('API error'); } }); }); describe('지수 백오프 검증', () => { it('should apply exponential backoff with default multiplier (2x)', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockResolvedValueOnce('success'); const initialDelay = 100; const promise = retryWithBackoff(mockFn, { maxRetries: 3, initialDelayMs: initialDelay, jitter: false, // Disable jitter for predictable testing }); // First failure -> delay ~100ms (2^0 * 100) await vi.advanceTimersByTimeAsync(100); expect(mockFn).toHaveBeenCalledTimes(2); // Initial + 1st retry // Second failure -> delay ~200ms (2^1 * 100) await vi.advanceTimersByTimeAsync(200); expect(mockFn).toHaveBeenCalledTimes(3); // Initial + 2nd retry await vi.runAllTimersAsync(); await expect(promise).resolves.toBe('success'); }); it('should respect custom backoff multiplier', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockResolvedValueOnce('success'); const initialDelay = 100; const promise = retryWithBackoff(mockFn, { maxRetries: 3, initialDelayMs: initialDelay, backoffMultiplier: 3, // 3x instead of 2x jitter: false, }); // First failure -> delay ~100ms (3^0 * 100) await vi.advanceTimersByTimeAsync(100); expect(mockFn).toHaveBeenCalledTimes(2); // Second failure -> delay ~300ms (3^1 * 100) await vi.advanceTimersByTimeAsync(300); expect(mockFn).toHaveBeenCalledTimes(3); await vi.runAllTimersAsync(); await expect(promise).resolves.toBe('success'); }); }); describe('maxDelay 상한선 확인', () => { it('should cap delay at maxDelayMs', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockRejectedValueOnce(new Error('Fail 3')) .mockResolvedValueOnce('success'); const maxDelay = 500; const promise = retryWithBackoff(mockFn, { maxRetries: 5, initialDelayMs: 100, maxDelayMs: maxDelay, jitter: false, }); // First retry: 100ms (2^0 * 100) await vi.advanceTimersByTimeAsync(100); expect(mockFn).toHaveBeenCalledTimes(2); // Second retry: 200ms (2^1 * 100) await vi.advanceTimersByTimeAsync(200); expect(mockFn).toHaveBeenCalledTimes(3); // Third retry: Should be 400ms (2^2 * 100), still under 500ms cap await vi.advanceTimersByTimeAsync(400); expect(mockFn).toHaveBeenCalledTimes(4); // Fourth retry: Would be 800ms (2^3 * 100), but capped at 500ms await vi.advanceTimersByTimeAsync(500); await vi.runAllTimersAsync(); await expect(promise).resolves.toBe('success'); }); it('should use default maxDelay (10000ms)', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Always fail')); const promise = retryWithBackoff(mockFn, { maxRetries: 20, // Many retries to test cap initialDelayMs: 1000, jitter: false, }); // After several retries, delay should cap at 10000ms // Let's verify it doesn't exceed 10000ms by checking timer behavior await vi.runAllTimersAsync(); await expect(promise).rejects.toThrow(RetryError); expect(mockFn).toHaveBeenCalledTimes(21); // Initial + 20 retries }); }); describe('Jitter 적용 확인', () => { it('should apply jitter by default (±20%)', async () => { const delays: number[] = []; let callCount = 0; const mockFn = vi.fn().mockImplementation(async () => { callCount++; if (callCount < 4) { // Record the delay before this call (if not first) throw new Error(`Fail ${callCount}`); } return 'success'; }); // Spy on setTimeout to capture actual delays const originalSetTimeout = global.setTimeout; const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => { if (typeof ms === 'number' && ms > 0) { delays.push(ms); } return originalSetTimeout(callback as () => void, ms); }); const promise = retryWithBackoff(mockFn, { maxRetries: 5, initialDelayMs: 1000, jitter: true, // Enable jitter (default) }); await vi.runAllTimersAsync(); await promise; setTimeoutSpy.mockRestore(); // With jitter, delays should vary within ±20% of expected values // Expected base delays (without jitter): [1000, 2000, 4000] expect(delays.length).toBeGreaterThan(0); // First delay should be ~1000ms ±20% (800-1200ms) if (delays[0]) { expect(delays[0]).toBeGreaterThanOrEqual(800); expect(delays[0]).toBeLessThanOrEqual(1200); } }); it('should not apply jitter when disabled', async () => { const delays: number[] = []; let callCount = 0; const mockFn = vi.fn().mockImplementation(async () => { callCount++; if (callCount < 3) { throw new Error(`Fail ${callCount}`); } return 'success'; }); const originalSetTimeout = global.setTimeout; const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => { if (typeof ms === 'number' && ms > 0) { delays.push(ms); } return originalSetTimeout(callback as () => void, ms); }); const promise = retryWithBackoff(mockFn, { maxRetries: 3, initialDelayMs: 1000, jitter: false, }); await vi.runAllTimersAsync(); await promise; setTimeoutSpy.mockRestore(); // Without jitter, delays should be exact powers of 2 // Expected: [1000, 2000] expect(delays[0]).toBe(1000); // 2^0 * 1000 expect(delays[1]).toBe(2000); // 2^1 * 1000 }); }); describe('커스텀 옵션 테스트', () => { it('should respect custom maxRetries', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Always fail')); const customMaxRetries = 5; const promise = retryWithBackoff(mockFn, { maxRetries: customMaxRetries, }); await vi.runAllTimersAsync(); await expect(promise).rejects.toThrow(RetryError); expect(mockFn).toHaveBeenCalledTimes(customMaxRetries + 1); // Initial + retries }); it('should respect custom initialDelayMs', async () => { const delays: number[] = []; const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockResolvedValueOnce('success'); const originalSetTimeout = global.setTimeout; const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => { if (typeof ms === 'number' && ms > 0) { delays.push(ms); } return originalSetTimeout(callback as () => void, ms); }); const customInitialDelay = 500; const promise = retryWithBackoff(mockFn, { initialDelayMs: customInitialDelay, jitter: false, }); await vi.runAllTimersAsync(); await promise; setTimeoutSpy.mockRestore(); expect(delays[0]).toBe(customInitialDelay); }); it('should respect custom maxDelayMs', async () => { const delays: number[] = []; const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockRejectedValueOnce(new Error('Fail 3')) .mockResolvedValueOnce('success'); const originalSetTimeout = global.setTimeout; const setTimeoutSpy = vi.spyOn(global, 'setTimeout').mockImplementation((callback, ms) => { if (typeof ms === 'number' && ms > 0) { delays.push(ms); } return originalSetTimeout(callback as () => void, ms); }); const customMaxDelay = 300; const promise = retryWithBackoff(mockFn, { initialDelayMs: 100, maxDelayMs: customMaxDelay, jitter: false, }); await vi.runAllTimersAsync(); await promise; setTimeoutSpy.mockRestore(); // Delays: [100, 200, 300] - third should be capped at 300 (not 400) expect(delays[0]).toBe(100); expect(delays[1]).toBe(200); expect(delays[2]).toBe(300); // Capped }); it('should work with maxRetries = 0 (no retries)', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Immediate fail')); const promise = retryWithBackoff(mockFn, { maxRetries: 0 }); await vi.runAllTimersAsync(); await expect(promise).rejects.toThrow(RetryError); expect(mockFn).toHaveBeenCalledTimes(1); // Only initial attempt }); }); describe('Edge Cases', () => { it('should handle non-Error rejections', async () => { const mockFn = vi.fn().mockRejectedValue('String error'); const promise = retryWithBackoff(mockFn, { maxRetries: 1 }); await vi.runAllTimersAsync(); try { await promise; expect.fail('Should have thrown RetryError'); } catch (error) { expect(error).toBeInstanceOf(RetryError); const retryError = error as RetryError; expect(retryError.lastError).toBeInstanceOf(Error); expect(retryError.lastError.message).toBe('String error'); } }); it('should handle undefined rejections', async () => { const mockFn = vi.fn().mockRejectedValue(undefined); const promise = retryWithBackoff(mockFn, { maxRetries: 1 }); await vi.runAllTimersAsync(); try { await promise; expect.fail('Should have thrown RetryError'); } catch (error) { expect(error).toBeInstanceOf(RetryError); const retryError = error as RetryError; expect(retryError.lastError).toBeInstanceOf(Error); expect(retryError.lastError.message).toBe('undefined'); } }); it('should work with serviceName for metrics tracking', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail')) .mockResolvedValueOnce('success'); const promise = retryWithBackoff(mockFn, { maxRetries: 2, serviceName: 'test-service', }); await vi.runAllTimersAsync(); await promise; // Note: metrics.increment is NOT called because we succeed on the retry // The metric is only tracked AFTER a retry fails (attempt > 0 && still failing) // In this case: attempt 0 fails -> no metric, attempt 1 succeeds -> no metric // Let's test a case where metrics ARE tracked }); it('should track metrics for failed retries', async () => { const mockFn = vi .fn() .mockRejectedValueOnce(new Error('Fail 1')) .mockRejectedValueOnce(new Error('Fail 2')) .mockResolvedValueOnce('success'); const promise = retryWithBackoff(mockFn, { maxRetries: 3, serviceName: 'test-service', }); await vi.runAllTimersAsync(); await promise; // Metrics should be tracked for attempt 1 (which failed) expect(metrics.increment).toHaveBeenCalledWith( 'retry_count', expect.objectContaining({ service: 'test-service', attempt: '1', }) ); }); }); describe('RetryError 속성 검증', () => { it('should have correct RetryError properties', async () => { const originalError = new Error('Test error'); const mockFn = vi.fn().mockRejectedValue(originalError); const promise = retryWithBackoff(mockFn, { maxRetries: 2 }); await vi.runAllTimersAsync(); try { await promise; expect.fail('Should have thrown RetryError'); } catch (error) { expect(error).toBeInstanceOf(RetryError); expect(error).toBeInstanceOf(Error); const retryError = error as RetryError; expect(retryError.name).toBe('RetryError'); expect(retryError.attempts).toBe(3); // Initial + 2 retries expect(retryError.lastError).toBe(originalError); expect(retryError.message).toContain('3 attempts'); expect(retryError.message).toContain('Test error'); } }); it('should be catchable as Error', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Fail')); const promise = retryWithBackoff(mockFn, { maxRetries: 0 }); await vi.runAllTimersAsync(); let caughtError: Error | null = null; try { await promise; } catch (error) { if (error instanceof Error) { caughtError = error; } } expect(caughtError).not.toBeNull(); expect(caughtError).toBeInstanceOf(Error); expect(caughtError).toBeInstanceOf(RetryError); }); }); });