/** * Comprehensive Unit Tests for circuit-breaker.ts * * 테스트 범위: * 1. 초기 상태 검증 * 2. CLOSED → OPEN 전환 (failureThreshold) * 3. OPEN → HALF_OPEN 전환 (resetTimeout) * 4. HALF_OPEN → CLOSED 전환 (성공 시) * 5. HALF_OPEN → OPEN 전환 (실패 시) * 6. 메서드 동작 검증 * 7. Monitoring window 기반 failure cleanup * 8. 통계 및 메트릭 검증 */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { CircuitBreaker, CircuitState, CircuitBreakerError, CircuitBreakerOptions, } from '../src/utils/circuit-breaker'; // Mock dependencies vi.mock('../src/utils/metrics', () => ({ metrics: { record: vi.fn(), increment: vi.fn(), }, })); vi.mock('../src/services/notification', () => ({ notifyAdmin: vi.fn().mockResolvedValue(undefined), })); vi.mock('../src/utils/logger', () => ({ createLogger: vi.fn(() => ({ info: vi.fn(), error: vi.fn(), warn: vi.fn(), })), })); describe('CircuitBreaker', () => { let breaker: CircuitBreaker; beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); describe('Initial State', () => { it('should start in CLOSED state', () => { breaker = new CircuitBreaker(); expect(breaker.getState()).toBe(CircuitState.CLOSED); }); it('should allow execution in CLOSED state', async () => { breaker = new CircuitBreaker(); const mockFn = vi.fn().mockResolvedValue('success'); const result = await breaker.execute(mockFn); expect(result).toBe('success'); expect(mockFn).toHaveBeenCalledTimes(1); }); it('should initialize with default options', () => { breaker = new CircuitBreaker(); const stats = breaker.getStats(); expect(stats.config).toEqual({ failureThreshold: 5, resetTimeoutMs: 60000, monitoringWindowMs: 120000, }); }); it('should initialize with custom options', () => { breaker = new CircuitBreaker({ failureThreshold: 3, resetTimeoutMs: 30000, monitoringWindowMs: 60000, serviceName: 'test-service', }); const stats = breaker.getStats(); expect(stats.config).toEqual({ failureThreshold: 3, resetTimeoutMs: 30000, monitoringWindowMs: 60000, }); }); }); describe('CLOSED → OPEN Transition', () => { beforeEach(() => { breaker = new CircuitBreaker({ failureThreshold: 3, serviceName: 'test-service', }); }); it('should open circuit after threshold failures', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // 3번 실패 for (let i = 0; i < 3; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected failure } } expect(breaker.getState()).toBe(CircuitState.OPEN); expect(mockFn).toHaveBeenCalledTimes(3); }); it('should block requests when circuit is OPEN', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Trigger opening for (let i = 0; i < 3; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.OPEN); // Next request should be blocked await expect( breaker.execute(mockFn) ).rejects.toThrow(CircuitBreakerError); // mockFn should not be called (circuit is open) expect(mockFn).toHaveBeenCalledTimes(3); // Only from previous calls }); it('should throw CircuitBreakerError with correct state', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open the circuit for (let i = 0; i < 3; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } try { await breaker.execute(mockFn); expect.fail('Should have thrown CircuitBreakerError'); } catch (error) { expect(error).toBeInstanceOf(CircuitBreakerError); expect((error as CircuitBreakerError).state).toBe(CircuitState.OPEN); expect((error as CircuitBreakerError).message).toContain('Circuit breaker is open'); } }); it('should record failures within threshold without opening', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // 2번 실패 (threshold는 3) for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.CLOSED); const stats = breaker.getStats(); expect(stats.failures).toBe(2); }); }); describe('OPEN → HALF_OPEN Transition', () => { beforeEach(() => { breaker = new CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 10000, // 10초 serviceName: 'test-service', }); }); it('should transition to HALF_OPEN after resetTimeout', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.OPEN); // Advance time by resetTimeout vi.advanceTimersByTime(10000); // Next execute() should check reset timeout mockFn.mockResolvedValueOnce('success'); await breaker.execute(mockFn); // Should have transitioned to HALF_OPEN, then CLOSED after success expect(breaker.getState()).toBe(CircuitState.CLOSED); }); it('should allow one test request in HALF_OPEN state', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } // Wait for reset timeout vi.advanceTimersByTime(10000); // Test request should succeed mockFn.mockResolvedValueOnce('success'); const result = await breaker.execute(mockFn); expect(result).toBe('success'); }); it('should not transition before resetTimeout elapsed', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.OPEN); // Advance time but not enough vi.advanceTimersByTime(5000); // Only 5 seconds // Should still be OPEN await expect( breaker.execute(mockFn) ).rejects.toThrow(CircuitBreakerError); expect(breaker.getState()).toBe(CircuitState.OPEN); }); }); describe('HALF_OPEN → CLOSED Transition', () => { beforeEach(() => { breaker = new CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 10000, serviceName: 'test-service', }); }); it('should close circuit on successful test request', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } // Wait and succeed vi.advanceTimersByTime(10000); mockFn.mockResolvedValueOnce('success'); await breaker.execute(mockFn); expect(breaker.getState()).toBe(CircuitState.CLOSED); }); it('should clear failures after closing', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getStats().failures).toBe(2); // Wait and succeed vi.advanceTimersByTime(10000); mockFn.mockResolvedValueOnce('success'); await breaker.execute(mockFn); // Failures should be cleared expect(breaker.getStats().failures).toBe(0); expect(breaker.getState()).toBe(CircuitState.CLOSED); }); }); describe('HALF_OPEN → OPEN Transition', () => { beforeEach(() => { breaker = new CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 10000, serviceName: 'test-service', }); }); it('should reopen circuit on failed test request', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } // Wait for reset timeout vi.advanceTimersByTime(10000); // Test request fails try { await breaker.execute(mockFn); } catch (error) { // Expected } // Should be back to OPEN expect(breaker.getState()).toBe(CircuitState.OPEN); }); it('should block subsequent requests after reopening', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } // Wait and fail test vi.advanceTimersByTime(10000); try { await breaker.execute(mockFn); } catch (error) { // Expected } // Next request should be blocked await expect( breaker.execute(mockFn) ).rejects.toThrow(CircuitBreakerError); }); }); describe('reset() Method', () => { it('should manually reset circuit to CLOSED', async () => { breaker = new CircuitBreaker({ failureThreshold: 2 }); const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.OPEN); // Manual reset breaker.reset(); expect(breaker.getState()).toBe(CircuitState.CLOSED); }); it('should clear all failures and stats on reset', async () => { breaker = new CircuitBreaker({ failureThreshold: 2 }); const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Record some failures for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } const statsBefore = breaker.getStats(); expect(statsBefore.failures).toBe(2); expect(statsBefore.stats.totalFailures).toBe(2); breaker.reset(); const statsAfter = breaker.getStats(); expect(statsAfter.failures).toBe(0); expect(statsAfter.stats.totalFailures).toBe(0); expect(statsAfter.stats.totalSuccesses).toBe(0); }); it('should allow execution after reset', async () => { breaker = new CircuitBreaker({ failureThreshold: 2 }); const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } breaker.reset(); // Should allow execution mockFn.mockResolvedValueOnce('success'); const result = await breaker.execute(mockFn); expect(result).toBe('success'); }); }); describe('Monitoring Window Cleanup', () => { it('should clean up old failures outside monitoring window', async () => { breaker = new CircuitBreaker({ failureThreshold: 5, monitoringWindowMs: 10000, // 10초 }); const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Record 2 failures for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getStats().failures).toBe(2); // Advance time beyond monitoring window vi.advanceTimersByTime(11000); // Record another failure (triggers cleanup) try { await breaker.execute(mockFn); } catch (error) { // Expected } // Old failures should be cleaned up const stats = breaker.getStats(); expect(stats.failures).toBe(1); // Only the latest failure }); it('should not open circuit if old failures are cleaned up', async () => { breaker = new CircuitBreaker({ failureThreshold: 3, monitoringWindowMs: 10000, }); const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); // Record 2 failures for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } // Advance time to clean up failures vi.advanceTimersByTime(11000); // Record one more failure (total recent: 1) try { await breaker.execute(mockFn); } catch (error) { // Expected } // Should still be CLOSED (only 1 recent failure) expect(breaker.getState()).toBe(CircuitState.CLOSED); }); }); describe('Statistics and Metrics', () => { beforeEach(() => { breaker = new CircuitBreaker({ failureThreshold: 3, serviceName: 'test-service', }); }); it('should track success count', async () => { const mockFn = vi.fn().mockResolvedValue('success'); await breaker.execute(mockFn); await breaker.execute(mockFn); const stats = breaker.getStats(); expect(stats.stats.totalSuccesses).toBe(2); expect(stats.stats.totalRequests).toBe(2); }); it('should track failure count', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } const stats = breaker.getStats(); expect(stats.stats.totalFailures).toBe(2); expect(stats.failures).toBe(2); }); it('should track last failure time', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); const beforeTime = Date.now(); try { await breaker.execute(mockFn); } catch (error) { // Expected } const afterTime = Date.now(); const stats = breaker.getStats(); expect(stats.lastFailureTime).toBeDefined(); const failureTime = stats.lastFailureTime!.getTime(); expect(failureTime).toBeGreaterThanOrEqual(beforeTime); expect(failureTime).toBeLessThanOrEqual(afterTime); }); it('should return correct state in stats', async () => { const mockFn = vi.fn().mockRejectedValue(new Error('Service error')); const initialStats = breaker.getStats(); expect(initialStats.state).toBe(CircuitState.CLOSED); // Open circuit for (let i = 0; i < 3; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } const openStats = breaker.getStats(); expect(openStats.state).toBe(CircuitState.OPEN); }); }); describe('Edge Cases', () => { it('should handle synchronous errors', async () => { breaker = new CircuitBreaker({ failureThreshold: 2 }); const mockFn = vi.fn().mockImplementation(() => { throw new Error('Sync error'); }); try { await breaker.execute(mockFn); } catch (error) { expect(error).toBeInstanceOf(Error); } expect(breaker.getStats().failures).toBe(1); }); it('should handle non-Error throws', async () => { breaker = new CircuitBreaker({ failureThreshold: 2 }); const mockFn = vi.fn().mockRejectedValue('string error'); try { await breaker.execute(mockFn); } catch (error) { expect(error).toBeInstanceOf(Error); } expect(breaker.getStats().failures).toBe(1); }); it('should handle multiple state transitions', async () => { breaker = new CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 5000, }); const mockFn = vi.fn(); // CLOSED → OPEN mockFn.mockRejectedValue(new Error('Error')); for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.OPEN); // OPEN → HALF_OPEN → CLOSED vi.advanceTimersByTime(5000); mockFn.mockResolvedValueOnce('success'); await breaker.execute(mockFn); expect(breaker.getState()).toBe(CircuitState.CLOSED); // CLOSED → OPEN again mockFn.mockRejectedValue(new Error('Error')); for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.OPEN); }); it('should handle rapid successive calls', async () => { breaker = new CircuitBreaker({ failureThreshold: 5 }); const mockFn = vi.fn().mockResolvedValue('success'); // Execute 10 times rapidly const promises = Array.from({ length: 10 }, () => breaker.execute(mockFn) ); await Promise.all(promises); expect(mockFn).toHaveBeenCalledTimes(10); expect(breaker.getState()).toBe(CircuitState.CLOSED); const stats = breaker.getStats(); expect(stats.stats.totalSuccesses).toBe(10); }); }); describe('Configuration Options', () => { it('should respect custom failureThreshold', async () => { breaker = new CircuitBreaker({ failureThreshold: 10 }); const mockFn = vi.fn().mockRejectedValue(new Error('Error')); // 9 failures should not open circuit for (let i = 0; i < 9; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } expect(breaker.getState()).toBe(CircuitState.CLOSED); // 10th failure should open it try { await breaker.execute(mockFn); } catch (error) { // Expected } expect(breaker.getState()).toBe(CircuitState.OPEN); }); it('should respect custom resetTimeoutMs', async () => { breaker = new CircuitBreaker({ failureThreshold: 2, resetTimeoutMs: 20000, // 20초 }); const mockFn = vi.fn().mockRejectedValue(new Error('Error')); // Open circuit for (let i = 0; i < 2; i++) { try { await breaker.execute(mockFn); } catch (error) { // Expected } } // 10초는 부족 vi.advanceTimersByTime(10000); await expect(breaker.execute(mockFn)).rejects.toThrow(CircuitBreakerError); // 20초면 충분 vi.advanceTimersByTime(10000); mockFn.mockResolvedValueOnce('success'); await breaker.execute(mockFn); expect(breaker.getState()).toBe(CircuitState.CLOSED); }); it('should use custom serviceName in stats', () => { breaker = new CircuitBreaker({ serviceName: 'custom-service' }); // serviceName은 내부적으로만 사용되므로 logger mock으로 확인 // 실제로는 getStats()에 serviceName이 포함되지 않지만, // 초기화 시 logger.info에 전달되었는지 확인 가능 expect(breaker).toBeDefined(); }); }); });