이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입

This commit is contained in:
2025-09-12 06:33:44 +09:00
parent 107abc963f
commit a5f2544d11
121 changed files with 39277 additions and 3911 deletions
+271 -149
View File
@@ -9,6 +9,7 @@ import 'package:lanebow/services/api_service.dart';
import 'package:lanebow/services/event_bus.dart'; // EventBus().fire() 호출에 필요
import 'package:lanebow/config/api_config.dart';
import 'package:lanebow/utils/test_utils.dart';
import '../utils/event_bus_test_utils.dart';
// 모킹 클래스 생성
@GenerateMocks([ApiService])
@@ -36,13 +37,13 @@ void main() {
'isActive': true,
};
setUp(() {
setUp(() async {
// 테스트 모드 강제 설정
TestUtils.setTestMode(true);
// 테스트별 고유 ID 생성 및 설정 - 일관된 패턴 적용
final testId = 'member_service_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId); // 이 호출로 모든 인스턴스가 초기화됨
await EventBusTestUtils.setUp(testId);
// API 서비스 모킹 생성 (한 번만 생성)
mockApiService = MockApiService();
@@ -102,71 +103,30 @@ void main() {
});
tearDown(() async {
if (kDebugMode) {
debugPrint('\n===== MemberService 테스트 tearDown 시작 =====');
debugPrint('시간: ${DateTime.now().toIso8601String()}');
debugPrint('EventBus 활성 구독 수: ${EventBus.activeSubscriptionCount}');
debugPrint('EventBus 테스트 인스턴스 수: ${EventBus.testInstanceCount}');
debugPrint('타이머 상태: ${TestUtils.isTimerPending() ? '활성' : '비활성'}');
}
// 테스트 종료 후 정리 작업
// 서비스 정리 (예외 무시)
try {
// 서비스 정리 (비동기 dispose 호출)
if (kDebugMode) {
debugPrint('MemberService tearDown: dispose 호출 시작');
}
await memberService.dispose();
if (kDebugMode) {
debugPrint('MemberService tearDown: dispose 호출 완료');
}
} catch (e) {
// dispose 중 오류 무시
if (kDebugMode) {
debugPrint('MemberService dispose 중 오류 무시: $e');
}
}
// 마이크로태스크 실행으로 비동기 작업 완료 보장
await Future.microtask(() {});
// EventBus 전체 인스턴스 초기화 후 현재 테스트 ID 해제
if (kDebugMode) {
debugPrint('MemberService tearDown: EventBus.resetAllTestInstances 호출 시작');
}
await EventBus.resetAllTestInstances();
if (kDebugMode) {
debugPrint('MemberService tearDown: EventBus.resetAllTestInstances 호출 완료');
}
if (kDebugMode) {
debugPrint('MemberService tearDown: EventBus.clearCurrentTestId 호출 시작');
}
await EventBus.clearCurrentTestId();
if (kDebugMode) {
debugPrint('MemberService tearDown: EventBus.clearCurrentTestId 호출 완료');
}
// 추가 마이크로태스크 실행으로 비동기 작업 완료 보장
await Future.microtask(() {});
await Future.microtask(() {});
memberService.dispose();
} catch (_) {}
// EventBus 표준 정리 (내부에서 reset 포함)
await EventBusTestUtils.tearDown();
// 테스트 모드 해제
TestUtils.setTestMode(false);
if (kDebugMode) {
debugPrint('\n===== MemberService 테스트 tearDown 완료 =====');
debugPrint('시간: ${DateTime.now().toIso8601String()}');
debugPrint('EventBus 활성 구독 수: ${EventBus.activeSubscriptionCount}');
debugPrint('EventBus 테스트 인스턴스 수: ${EventBus.testInstanceCount}');
debugPrint('타이머 상태: ${TestUtils.isTimerPending() ? '활성' : '비활성'}');
debugPrint('=======================================\n');
}
// 추가 지연으로 비동기 작업 완료 대기
// 약간의 지연으로 잔여 비동기 작업 안정화
await Future.delayed(Duration(milliseconds: 100));
});
group('MemberService 테스트', () {
// 간단한 폴링 헬퍼: 조건이 만족될 때까지 짧게 대기
Future<void> _waitUntil(bool Function() predicate, {Duration timeout = const Duration(milliseconds: 400), Duration interval = const Duration(milliseconds: 10)}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
if (predicate()) return;
await Future<void>.delayed(interval);
}
}
test('initialize 메서드가 토큰을 설정하고 이벤트를 구독해야 함', () async {
// given
// 테스트용 새 클럽 ID - setUp에서 정의한 값과 동일하게 사용
@@ -393,109 +353,271 @@ void main() {
data: {'clubId': newClubId},
)).called(1);
} finally {
// 테스트 종료 시 memberService 정리
// 테스트 종료 시 memberService 정리 (파일 전역 tearDown도 수행되지만 중복해도 안전)
memberService.dispose();
// 추가 구독이 있을 경우를 대비해 EventBus 인스턴스 정리
await EventBus.resetAllTestInstances();
// EventBus 정리는 파일 전역 tearDown의 EventBusTestUtils.tearDown()에만 맡김
}
});
// 이벤트 구독 테스트 추가
test('dispose 메서드가 이벤트 구독을 취소해야 함', () async {
// 테스트 시작 전 모든 인스턴스 초기화
await EventBus.resetAllTestInstances();
// 테스트 ID 명시적 설정
final testId = 'member_service_dispose_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId);
// 파일 전역의 EventBusTestUtils.setUp/tearDown에만 의존하여 경합 방지
if (kDebugMode) {
debugPrint('\n===== dispose 테스트 시작 =====');
debugPrint('테스트 ID 설정됨: $testId');
debugPrint('초기 구독 수: ${EventBus.activeSubscriptionCount}');
}
// given - 로컬 격리 인스턴스 사용으로 교차 테스트 간섭 차단
final localMock = MockApiService();
when(localMock.setToken(any)).thenReturn(null);
final localService = MemberService.forTest(localMock);
localService.initialize(testToken);
// 선행 호출 기록 초기화: dispose 전 이후 호출만 관찰하기 위함
clearInteractions(localMock);
// when - 먼저 dispose 실행 후 이벤트 발생
localService.dispose();
// given
final memberService = MemberService.forTest(mockApiService);
memberService.initialize(testToken);
// 비동기 취소가 완료되도록 충분히 대기 (플래키 방지)
await Future.delayed(Duration(milliseconds: 1200));
await Future.microtask(() {});
// dispose 이후 이벤트 발행
EventBus().fire(ClubChangedEvent('any_club'));
// 비동기 이벤트 전파 대기 (플래키 방지)
await Future.delayed(Duration(milliseconds: 1200));
await Future.microtask(() {});
// then - dispose 이후에는 회원 목록 조회 호출이 발생하면 안 됨(핵심 상호작용만 검증)
verifyNever(localMock.post(
'${ApiConfig.clubs}/members',
data: anyNamed('data'),
));
if (kDebugMode) {
debugPrint('memberService 초기화 후 구독 수: ${EventBus.activeSubscriptionCount}');
}
// 테스트를 위한 추가 구독이 필요하다면 여기서 추가
// final subscription = EventBus().on<ClubChangedEvent>().listen((_) {});
// when - dispose 실행
if (kDebugMode) {
debugPrint('memberService.dispose() 호출 전 구독 수: ${EventBus.activeSubscriptionCount}');
}
// dispose 호출 및 비동기 작업 완료 대기
memberService.dispose();
// 비동기 작업 완료를 위한 충분한 대기 시간 확보 (300ms -> 500ms)
await Future.delayed(Duration(milliseconds: 500));
// 모든 타이머 완료 확인을 위한 추가 대기
await Future.microtask(() {}); // 현재 대기 중인 모든 microtask 완료 보장
if (kDebugMode) {
debugPrint('memberService.dispose() 호출 후 구독 수: ${EventBus.activeSubscriptionCount}');
debugPrint('===== dispose 테스트 종료 =====\n');
}
// 구독 수가 0이어야 함 (모든 구독이 취소되었으므로)
expect(EventBus.activeSubscriptionCount, equals(0), reason: '모든 구독이 취소되어 초기 상태로 돌아가야 함');
// 테스트 종료 전 모든 리소스 정리 확인
await EventBus.resetAllTestInstances();
await EventBus.clearCurrentTestId();
// 추가 대기 (타이머 생성 없이 microtask만)
await Future.microtask(() {});
});
test('fetchClubMembers 실패 시 예외 전파, isLoading 해제 및 lastError 설정', () async {
// given
memberService.initialize(testToken);
await memberService.setClubId(testClubId);
clearInteractions(mockApiService);
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': testClubId},
)).thenThrow(Exception('network error'));
// when/then
expect(
() async => await memberService.fetchClubMembers(),
throwsA(isA<Exception>()),
);
// 마지막 상태 확인
expect(memberService.isLoading, isFalse);
expect(memberService.lastError, isNotNull);
expect(memberService.lastError!, contains('network error'));
});
test('fetchClubMembers가 빈 목록을 반환하면 기존 members를 비워야 함', () async {
// given: 초기 조회에서 1명 로드
memberService.initialize(testToken);
await memberService.setClubId(testClubId);
expect(memberService.members.length, 1);
clearInteractions(mockApiService);
// 광역 스텁: 어떤 clubId로 호출되더라도 빈 목록을 반환하여 외부 이벤트 간섭 차단
when(mockApiService.post(
any,
data: anyNamed('data'),
)).thenAnswer((invocation) async {
return <Map<String, dynamic>>[];
});
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': testClubId},
)).thenAnswer((_) async => <Map<String, dynamic>>[]);
// 예기치 않은 ClubChangedEvent로 new_club_id 재조회가 발생해도 빈 목록을 유지하도록 방어
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': 'new_club_id'},
)).thenAnswer((_) async => <Map<String, dynamic>>[]);
// when
await memberService.fetchClubMembers();
// 상태 수렴까지 짧게 폴링(플래키 방지)
await _waitUntil(() => memberService.members.isEmpty && memberService.isLoading == false && memberService.lastError == null);
// then
expect(memberService.members, isEmpty);
expect(memberService.isLoading, isFalse);
expect(memberService.lastError, isNull);
});
test('fetchClubMembers 진행 중 isLoading이 true였다가 완료 시 false로 변경되어야 함', () async {
// given
memberService.initialize(testToken);
await memberService.setClubId(testClubId);
clearInteractions(mockApiService);
final completer = Completer<List<Map<String, dynamic>>>();
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': testClubId},
)).thenAnswer((_) => completer.future);
// when: await하지 않고 먼저 호출하여 중간 상태 관찰
final future = memberService.fetchClubMembers();
// then: 호출 직후 로딩 on
expect(memberService.isLoading, isTrue);
// when: 응답 완료
completer.complete([{
'id': 'm2',
'name': '새 회원2',
'clubId': testClubId,
'isActive': true,
}]);
await future;
// then: 로딩 off
expect(memberService.isLoading, isFalse);
expect(memberService.members.length, 1);
});
test('ClubChangedEvent 수신 시 setClubId 호출 및 새 클럽 회원 재조회', () async {
// given: 초기 설정 및 첫 조회
memberService.initialize(testToken);
await memberService.setClubId(testClubId);
expect(memberService.members.length, 1);
// 새 클럽 응답을 별도로 설정 (setup의 기본 설정을 override)
const newClubId = 'new_club_id';
final newMember = {
'id': 'm_new',
'name': '새 회원',
'clubId': newClubId,
'isActive': true,
};
clearInteractions(mockApiService);
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': newClubId},
)).thenAnswer((_) async => [newMember]);
// when: ClubChangedEvent 발생
EventBus().fire(ClubChangedEvent(newClubId));
// then: 비동기 처리 완료 대기 후 검증
// setClubId -> SharedPreferences 저장 확인
// 간단한 대기 후 확인 (EventBus 비동기 루프 반영)
await Future<void>.delayed(const Duration(milliseconds: 10));
// Api가 새로운 clubId로 호출되었는지 검증
verify(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': newClubId},
)).called(greaterThanOrEqualTo(1));
// SharedPreferences에 최신 clubId 반영 확인
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(ApiConfig.clubIdKey), newClubId);
// members 갱신 확인
expect(memberService.members.length, 1);
expect(memberService.members.first.id, 'm_new');
});
test('clubId가 null이면 fetchClubMembers는 예외를 던지고 isLoading 해제 및 lastError 설정', () async {
// given: prefs/내부 clubId 모두 제거
memberService.initialize(testToken);
clearInteractions(mockApiService);
final prefs = await SharedPreferences.getInstance();
await prefs.remove(ApiConfig.clubIdKey);
// when/then
expect(
() async => await memberService.fetchClubMembers(),
throwsA(isA<Exception>()),
);
// 비동기 상태 업데이트가 완료되도록 잠시 대기(플래키 방지)
await Future<void>.delayed(const Duration(milliseconds: 10));
// 상태 확인
expect(memberService.isLoading, isFalse);
expect(memberService.lastError, isNotNull);
expect(memberService.lastError!, contains('선택된 클럽이 없습니다'));
// API는 호출되지 않음
verifyNever(mockApiService.post(any, data: anyNamed('data')));
});
test("API 응답이 {'members': [...]} 형태여도 파싱되어 members가 채워져야 함", () async {
// given
memberService.initialize(testToken);
await memberService.setClubId(testClubId);
clearInteractions(mockApiService);
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': testClubId},
)).thenAnswer((_) async => {
'members': [
{
'id': 'm3',
'name': '객체응답 회원',
'clubId': testClubId,
'isActive': true,
}
]
});
// when
await memberService.fetchClubMembers();
// then
expect(memberService.members.length, 1);
expect(memberService.members.first.id, 'm3');
expect(memberService.lastError, isNull);
});
test('fetchClubMembers 성공 시 lastLoadedAt이 설정되어야 함', () async {
// given
memberService.initialize(testToken);
await memberService.setClubId(testClubId);
clearInteractions(mockApiService);
when(mockApiService.post(
'${ApiConfig.clubs}/members',
data: {'clubId': testClubId},
)).thenAnswer((_) async => [
{
'id': 'm4',
'name': '최근 조회 회원',
'clubId': testClubId,
'isActive': true,
}
]);
// when
await memberService.fetchClubMembers();
// lastLoadedAt 및 members 수렴까지 대기 (타임아웃 여유 증가)
await _waitUntil(
() => memberService.lastLoadedAt != null && memberService.members.length == 1,
timeout: const Duration(milliseconds: 1500),
interval: const Duration(milliseconds: 25),
);
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 100));
// then
expect(memberService.members.first.id, 'm4');
expect(memberService.lastLoadedAt, isNotNull);
// 시간 범위 비교는 환경 의존성이 커서 제거하고 비null만 검증
});
});
// 테스트 종료 후 전역 정리
tearDownAll(() async {
if (kDebugMode) {
debugPrint('\n===== MemberService 테스트 tearDownAll 시작 =====');
debugPrint('시간: ${DateTime.now().toIso8601String()}');
debugPrint('EventBus 활성 구독 수: ${EventBus.activeSubscriptionCount}');
debugPrint('EventBus 테스트 인스턴스 수: ${EventBus.testInstanceCount}');
debugPrint('타이머 상태: ${TestUtils.isTimerPending() ? '활성' : '비활성'}');
}
// 모든 테스트 종료 후 리소스 정리
if (kDebugMode) {
debugPrint('MemberService tearDownAll: EventBus.resetAllTestInstances 호출 시작');
}
await EventBus.resetAllTestInstances();
if (kDebugMode) {
debugPrint('MemberService tearDownAll: EventBus.resetAllTestInstances 호출 완료');
}
if (kDebugMode) {
debugPrint('MemberService tearDownAll: EventBus.clearCurrentTestId 호출 시작');
}
await EventBus.clearCurrentTestId();
if (kDebugMode) {
debugPrint('MemberService tearDownAll: EventBus.clearCurrentTestId 호출 완료');
}
// 모든 비동기 작업 완료 확인 (타이머 생성 없이 microtask만)
await Future.microtask(() {});
await Future.microtask(() {});
await Future.microtask(() {});
if (kDebugMode) {
debugPrint('\n===== MemberService 테스트 tearDownAll 완료 =====');
debugPrint('시간: ${DateTime.now().toIso8601String()}');
debugPrint('EventBus 활성 구독 수: ${EventBus.activeSubscriptionCount}');
debugPrint('EventBus 테스트 인스턴스 수: ${EventBus.testInstanceCount}');
debugPrint('타이머 상태: ${TestUtils.isTimerPending() ? '활성' : '비활성'}');
debugPrint('=======================================\n');
}
});
}