# EventBus 및 MemberService 테스트 안정성 개선 가이드 ## 목차 1. [소개](#소개) 2. [문제 배경](#문제-배경) 3. [주요 문제점 분석](#주요-문제점-분석) 4. [해결 방법 및 개선 과정](#해결-방법-및-개선-과정) 5. [진단 및 디버깅 도구](#진단-및-디버깅-도구) 6. [최적화된 테스트 환경 설정](#최적화된-테스트-환경-설정) 7. [반복 테스트 실행 가이드](#반복-테스트-실행-가이드) 8. [참고 자료](#참고-자료) ## 소개 이 문서는 Flutter 애플리케이션에서 EventBus와 MemberService 관련 테스트의 안정성을 개선하는 과정과 해결 방법을 상세히 설명합니다. 비동기 작업, 이벤트 기반 아키텍처, 리소스 관리 등과 관련된 테스트 실패 문제를 해결하기 위한 심층적인 접근 방식을 제공합니다. ## 문제 배경 EventBus와 MemberService 테스트는 다음과 같은 문제로 인해 간헐적인 실패를 보였습니다: 1. **비동기 타이머 관리**: 테스트 종료 시점에 완료되지 않은 타이머가 남아있어 `!timersPending` assertion 실패 2. **리소스 정리 불완전**: `dispose()` 및 `tearDown` 과정에서 모든 리소스가 제대로 정리되지 않음 3. **테스트 환경 의존성**: 로컬, CI, 스트레스 테스트 등 환경별로 다른 결과 발생 4. **이벤트 구독 관리**: 테스트 간 이벤트 구독이 완전히 해제되지 않아 간섭 발생 5. **비동기 작업 동기화**: 비동기 작업 완료 시점을 정확히 파악하지 못해 테스트 불안정성 초래 ## 주요 문제점 분석 ### 1. EventBus 구독 관리 문제 ```dart // 문제 코드 void dispose() { _subscription?.cancel(); _subscription = null; } ``` **문제점**: - 구독 취소가 비동기적으로 이루어지지만, 완료를 기다리지 않음 - 다중 구독 시 일부만 취소되고 나머지는 남아있을 수 있음 - 구독 취소 상태를 추적하는 메커니즘 부재 ### 2. 타이머 및 비동기 작업 관리 문제 ```dart // 문제 코드 void tearDown() { memberService.dispose(); // 타이머 완료를 기다리지 않음 } ``` **문제점**: - 타이머 완료 여부를 확인하지 않고 테스트 종료 - 비동기 작업(Future, Stream)의 완료를 기다리지 않음 - 마이크로태스크 큐 비우기 작업 누락 ### 3. 테스트 환경 설정 문제 ```dart // 문제 코드 Future testMethod() async { // 고정된 지연 시간 사용 await Future.delayed(Duration(milliseconds: 500)); expect(result, equals(expected)); } ``` **문제점**: - 환경에 따라 다른 지연 시간이 필요하나 고정값 사용 - 타임아웃 설정이 환경별로 최적화되지 않음 - 테스트 격리 모드가 적절히 설정되지 않음 ## 해결 방법 및 개선 과정 ### 1. EventBus 개선 #### 1.1 구독 관리 개선 ```dart // 개선된 코드 class EventBus { // 테스트용 인스턴스 카운터 추가 static int _testInstanceCount = 0; static int get testInstanceCount => _testInstanceCount; // 구독 취소 로깅 추가 Future dispose() async { if (_subscription != null) { await _subscription!.cancel(); _logSubscriptionCancellation(); _subscription = null; } // 추가 구독 목록 정리 for (final sub in _additionalSubscriptions) { await sub.cancel(); _logSubscriptionCancellation(); } _additionalSubscriptions.clear(); } } ``` #### 1.2 테스트 환경 리셋 기능 강화 ```dart // 개선된 코드 static Future resetAllTestInstances() async { if (!TestUtils.isInTest) return; _log('EventBus: 테스트 인스턴스 리셋 시작 (현재 인스턴스: $_testInstanceCount)'); // 모든 인스턴스 정리 for (final instance in _testInstances.values) { await instance.dispose(); } _testInstances.clear(); _testInstanceCount = 0; // 마이크로태스크 큐 비우기 await Future.microtask(() {}); // 타이머 완료 확인 await _ensureAllTimersComplete(); _log('EventBus: 테스트 인스턴스 리셋 완료'); } ``` #### 1.3 타이머 완료 확인 메커니즘 추가 ```dart // 개선된 코드 static Future _ensureAllTimersComplete() async { final timerCompletionWait = TestConfig.current.timerCompletionWait; _log('타이머 완료 확인 시작 (대기 시간: ${timerCompletionWait}ms)'); // 첫 번째 확인 if (!TestUtils.isTimerPending) { _log('타이머 없음, 즉시 완료'); return; } // 타이머 완료 대기 await Future.delayed(Duration(milliseconds: timerCompletionWait)); // 두 번째 확인 if (!TestUtils.isTimerPending) { _log('타이머 완료됨'); return; } // 추가 마이크로태스크 큐 비우기 await Future.microtask(() {}); // 최종 확인 if (TestUtils.isTimerPending) { _log('경고: 타이머가 여전히 남아있음'); } else { _log('모든 타이머 완료됨'); } } ``` ### 2. MemberService 테스트 개선 #### 2.1 tearDown 및 tearDownAll 개선 ```dart // 개선된 코드 void tearDown() async { _log('테스트 정리 시작'); // 서비스 정리 await memberService.dispose(); // EventBus 리셋 await EventBus.resetAllTestInstances(); // 추가 대기 시간 await Future.delayed(Duration(milliseconds: TestConfig.current.testDelay)); // 마이크로태스크 큐 비우기 await Future.microtask(() {}); // 상태 확인 및 로깅 _log('테스트 정리 완료 (EventBus 인스턴스: ${EventBus.testInstanceCount}, 타이머 대기 중: ${TestUtils.isTimerPending})'); // 타이머 및 리소스 정리 확인 expect(EventBus.testInstanceCount, equals(0), reason: 'EventBus 인스턴스가 남아있습니다'); expect(TestUtils.isTimerPending, isFalse, reason: '완료되지 않은 타이머가 있습니다'); } ``` #### 2.2 실시간 리소스 상태 로깅 추가 ```dart // 개선된 코드 class MemberService { Future dispose() async { _log('MemberService 정리 시작'); // 구독 취소 if (_eventSubscription != null) { await _eventSubscription!.cancel(); _eventSubscription = null; _log('이벤트 구독 취소됨'); } // 추가 리소스 정리 _members.clear(); _clubMembers.clear(); // 상태 로깅 _log('MemberService 정리 완료'); } } ``` ### 3. 테스트 유틸리티 클래스 추가 ```dart // 새로 추가된 코드 class TestUtils { static bool get isInTest => Zone.current[#test.declarer] != null; static bool get isTimerPending { // 현재 활성 타이머 확인 로직 final binding = WidgetsBinding.instance as TestWidgetsFlutterBinding; return binding.hasScheduledFrame || binding.hasRunningAnimations; } static Future completeAllTimers() async { final binding = WidgetsBinding.instance as TestWidgetsFlutterBinding; await binding.pump(Duration(milliseconds: TestConfig.current.timerCompletionWait)); await Future.microtask(() {}); } static void logTestState(String prefix) { print('$prefix - 타이머 대기 중: ${isTimerPending}, EventBus 인스턴스: ${EventBus.testInstanceCount}'); } } ``` ## 진단 및 디버깅 도구 ### 1. 테스트 진단 로깅 시스템 테스트 실패 시 자동으로 진단 정보를 수집하고 기록하는 시스템을 구현했습니다: ```dart // test_diagnostics.dart class TestDiagnostics { static void logFailure(String testName, dynamic error, StackTrace stackTrace) { final logDir = Directory('test_diagnostics'); if (!logDir.existsSync()) { logDir.createSync(); } final logFile = File('${logDir.path}/failure_${DateTime.now().millisecondsSinceEpoch}.log'); final buffer = StringBuffer(); // 기본 정보 기록 buffer.writeln('=== 테스트 실패 진단 ==='); buffer.writeln('시간: ${DateTime.now()}'); buffer.writeln('테스트: $testName'); buffer.writeln('환경: ${TestConfig.currentEnvironment}'); buffer.writeln(); // 오류 정보 기록 buffer.writeln('=== 오류 정보 ==='); buffer.writeln('오류 유형: ${error.runtimeType}'); buffer.writeln('오류 메시지: $error'); buffer.writeln(); // 스택 트레이스 기록 buffer.writeln('=== 스택 트레이스 ==='); buffer.writeln(stackTrace); buffer.writeln(); // 시스템 상태 기록 buffer.writeln('=== 시스템 상태 ==='); buffer.writeln('EventBus 인스턴스 수: ${EventBus.testInstanceCount}'); buffer.writeln('타이머 대기 중: ${TestUtils.isTimerPending}'); buffer.writeln('테스트 설정:'); buffer.writeln(' - 비동기 타임아웃: ${TestConfig.current.asyncTimeout}ms'); buffer.writeln(' - 테스트 지연: ${TestConfig.current.testDelay}ms'); buffer.writeln(' - 타이머 완료 대기: ${TestConfig.current.timerCompletionWait}ms'); buffer.writeln(' - 격리 모드: ${TestConfig.current.isolationMode}'); // 로그 파일 저장 logFile.writeAsStringSync(buffer.toString()); print('진단 정보가 ${logFile.path}에 저장되었습니다.'); } } ``` ### 2. 실시간 리소스 모니터링 테스트 실행 중 리소스 상태를 실시간으로 모니터링하기 위한 도구: ```dart // resource_monitor.dart class ResourceMonitor { static Timer? _monitorTimer; static void startMonitoring() { if (!TestConfig.current.diagnosticsEnabled) return; _monitorTimer = Timer.periodic(Duration(seconds: 1), (_) { _logResourceState(); }); } static void stopMonitoring() { _monitorTimer?.cancel(); _monitorTimer = null; } static void _logResourceState() { print('=== 리소스 상태 (${DateTime.now()}) ==='); print('EventBus 인스턴스: ${EventBus.testInstanceCount}'); print('타이머 대기 중: ${TestUtils.isTimerPending}'); // 추가 리소스 상태 로깅 } } ``` ## 최적화된 테스트 환경 설정 테스트 환경별로 최적화된 설정을 `test_config.yaml` 파일에 정의했습니다: ```yaml # 로컬 환경 테스트 설정 local: # 비동기 작업 타임아웃 설정 (밀리초) # 테스트 실패 상황 분석 결과 3000ms가 적절함 async_timeout: 3000 # 테스트 간 지연 시간 (밀리초) # 이전 테스트의 비동기 작업이 완전히 정리되도록 500ms 유지 test_delay: 500 # 테스트 재시도 횟수 # 일시적 실패를 방지하기 위해 3회로 설정 retry_count: 3 # 테스트 병렬 실행 여부 # 로컬에서는 병렬 실행으로 시간 절약 parallel: true # 테스트 격리 모드 (strict, normal, relaxed) # 로컬 테스트에서는 normal이 적절함 isolation_mode: normal # 테스트 종료 전 타이머 완료 대기 시간 (밀리초) # EventBus와 MemberService 테스트에서 타이머 완료를 위해 적절한 대기 시간 timer_completion_wait: 1000 # 반복 테스트 실행 횟수 (기본값: 1) repeat_count: 1 ``` ## 반복 테스트 실행 가이드 테스트 안정성을 검증하기 위한 반복 실행 스크립트를 개선했습니다: ```bash # 테스트 실행 run_tests() { echo -e "${BLUE}테스트 실행 시작${NC}" # 테스트 명령 구성 TEST_CMD="flutter test" if [ -n "$TEST_PATTERN" ]; then TEST_CMD="$TEST_CMD --name=\"$TEST_PATTERN\"" fi # 반복 실행 설정 OVERALL_RESULT=0 FAILED_RUNS=0 SUCCESSFUL_RUNS=0 echo -e "${BLUE}테스트 반복 실행: $REPEAT_COUNT회${NC}" for ((i=1; i<=$REPEAT_COUNT; i++)); do if [ $REPEAT_COUNT -gt 1 ]; then echo -e "\n${YELLOW}===== 테스트 실행 $i/$REPEAT_COUNT =====${NC}\n" fi # 테스트 실행 및 결과 처리 # ... # 테스트 결과 처리 if [ $RUN_RESULT -eq 0 ]; then echo -e "${GREEN}테스트 성공! (실행 $i/$REPEAT_COUNT, 소요 시간: ${DURATION}초)${NC}" SUCCESSFUL_RUNS=$((SUCCESSFUL_RUNS + 1)) else echo -e "${RED}테스트 실패! (실행 $i/$REPEAT_COUNT, 종료 코드: $RUN_RESULT, 소요 시간: ${DURATION}초)${NC}" FAILED_RUNS=$((FAILED_RUNS + 1)) OVERALL_RESULT=1 # 진단 정보 분석 # ... fi done # 총결과 출력 if [ $REPEAT_COUNT -gt 1 ]; then echo -e "\n${BLUE}===== 테스트 반복 실행 총결과 =====${NC}" echo -e "${BLUE}총 실행 횟수: $REPEAT_COUNT${NC}" echo -e "${GREEN}성공한 실행: $SUCCESSFUL_RUNS${NC}" echo -e "${RED}실패한 실행: $FAILED_RUNS${NC}" if [ $OVERALL_RESULT -eq 0 ]; then echo -e "${GREEN}총결과: 모든 테스트 성공!${NC}" else echo -e "${RED}총결과: 실패한 테스트 발견!${NC}" fi fi return $OVERALL_RESULT } ``` ### 실행 방법 다음 명령으로 테스트를 반복 실행하여 안정성을 검증할 수 있습니다: ```bash # 로컬 환경에서 member_service_test.dart 3회 반복 실행 ./scripts/run_tests.sh -e local -p "MemberService" -r 3 # CI 환경 시뮬레이션으로 모든 테스트 5회 반복 실행 ./scripts/run_tests.sh -e ci -r 5 # 스트레스 테스트 환경에서 EventBus 테스트 10회 반복 실행 ./scripts/run_tests.sh -e stress -p "EventBus" -r 10 # 안정성 테스트 환경에서 특정 테스트 10회 반복 실행 ./scripts/run_tests.sh -e stability -p "특정 테스트 패턴" -r 10 ``` ## 참고 자료 - [Flutter 테스트 공식 문서](https://flutter.dev/docs/testing) - [Flutter 비동기 테스트 가이드](https://flutter.dev/docs/cookbook/testing/unit/introduction) - [EventBus 패턴 구현 가이드](https://pub.dev/packages/event_bus) - [Flutter 테스트 모범 사례](https://medium.com/flutter-community/flutter-testing-patterns-and-best-practices-29d4acd4535f)