이벤트 가져오기 위젯 테스트 추가 및 테스트 훅 도입
This commit is contained in:
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user