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

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
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:dio/dio.dart';
import 'package:lanebow/services/api_service.dart';
class _FakeBackendInterceptor extends Interceptor {
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
final path = options.path;
// 성공 케이스
if (path == '/ping' && options.method == 'GET') {
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: {'ok': true},
));
}
if (path == '/echo' && options.method == 'POST') {
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: options.data ?? {},
));
}
if (path == '/put' && options.method == 'PUT') {
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: {'updated': true},
));
}
if (path == '/del' && options.method == 'DELETE') {
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: {'deleted': true},
));
}
// 401 에러 케이스
if (path == '/unauthorized') {
return handler.reject(DioException(
requestOptions: options,
response: Response(requestOptions: options, statusCode: 401, data: {'message': 'unauthorized'}),
type: DioExceptionType.badResponse,
));
}
// 기타 500 에러
return handler.reject(DioException(
requestOptions: options,
response: Response(requestOptions: options, statusCode: 500, data: {'message': 'server error'}),
type: DioExceptionType.badResponse,
));
}
}
void main() {
group('ApiService', () {
late ApiService api;
late Dio dio;
setUp(() {
api = ApiService();
dio = Dio(BaseOptions(baseUrl: 'http://test')); // baseUrl은 테스트에서 의미 없음
dio.interceptors.clear();
dio.interceptors.add(_FakeBackendInterceptor());
api.setDio(dio);
});
test('GET 성공 시 data 반환', () async {
final data = await api.get('/ping');
expect(data, isA<Map>());
expect(data['ok'], true);
});
test('POST 성공 시 body echo', () async {
final payload = {'a': 1};
final data = await api.post('/echo', data: payload);
expect(data, payload);
});
test('PUT 성공 시 updated=true', () async {
final data = await api.put('/put', data: {'x': 1});
expect(data['updated'], true);
});
test('DELETE 성공 시 deleted=true', () async {
final data = await api.delete('/del');
expect(data['deleted'], true);
});
test('401 발생 시 DioException rethrow', () async {
expect(
() => api.get('/unauthorized'),
throwsA(isA<DioException>()),
);
});
test('기타 에러(500) 발생 시 DioException rethrow', () async {
expect(
() => api.get('/unknown'),
throwsA(isA<DioException>()),
);
});
});
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/annotations.dart';
import 'package:mockito/mockito.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:lanebow/services/auth_service.dart';
import 'package:lanebow/services/api_service.dart';
import 'package:lanebow/models/user_model.dart';
import 'package:lanebow/config/api_config.dart';
@GenerateMocks([ApiService, FlutterSecureStorage])
import 'auth_service_test.mocks.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AuthService', () {
late MockApiService mockApiService;
late MockFlutterSecureStorage mockSecureStorage;
late AuthService authService;
setUp(() async {
mockApiService = MockApiService();
mockSecureStorage = MockFlutterSecureStorage();
SharedPreferences.setMockInitialValues({});
authService = AuthService(apiService: mockApiService, secureStorage: mockSecureStorage);
});
test('login 성공 시 토큰/유저 저장 및 isAuthenticated=true', () async {
// arrange
final fakeUserJson = {
'id': 'u1',
'name': 'Tester',
'email': 't@example.com',
'role': 'admin',
'clubId': 'c1',
};
when(mockApiService.post(ApiConfig.login, data: anyNamed('data'))).thenAnswer(
(_) async => {
'token': 'fake-token',
'user': fakeUserJson,
},
);
// act
final ok = await authService.login('id', 'pw');
// assert
expect(ok, true);
expect(authService.isAuthenticated, true);
expect(authService.token, 'fake-token');
verify(mockSecureStorage.write(key: ApiConfig.tokenKey, value: 'fake-token')).called(1);
verify(mockApiService.setToken('fake-token')).called(1);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(ApiConfig.userIdKey), 'u1');
expect(prefs.getString(ApiConfig.userRoleKey), 'admin');
expect(prefs.getString(ApiConfig.clubIdKey), 'c1');
});
test('initialize() 저장된 토큰이 있으면 프로필 조회 및 currentUser 설정', () async {
// arrange
when(mockSecureStorage.read(key: ApiConfig.tokenKey)).thenAnswer((_) async => 'saved-token');
when(mockApiService.get(ApiConfig.profile)).thenAnswer((_) async => {
'id': 'u1',
'name': 'Tester',
'email': 't@example.com',
'role': 'admin',
'clubId': 'c1',
});
// act
await authService.initialize();
// assert
expect(authService.isInitialized, true);
expect(authService.token, 'saved-token');
expect(authService.currentUser, isA<User>());
verify(mockApiService.setToken('saved-token')).called(1);
});
test('logout() 토큰/유저/저장소 초기화', () async {
// arrange: 로그인된 상태처럼 세팅
when(mockApiService.post(ApiConfig.login, data: anyNamed('data'))).thenAnswer(
(_) async => {
'token': 'fake-token',
'user': {
'id': 'u1',
'name': 'Tester',
'email': 't@example.com',
'role': 'admin',
},
},
);
await authService.login('id', 'pw');
// act
await authService.logout();
// assert
expect(authService.isAuthenticated, false);
expect(authService.currentUser, isNull);
verify(mockSecureStorage.delete(key: ApiConfig.tokenKey)).called(1);
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString(ApiConfig.userIdKey), isNull);
expect(prefs.getString(ApiConfig.userRoleKey), isNull);
expect(prefs.getString(ApiConfig.clubIdKey), isNull);
});
});
}
@@ -0,0 +1,380 @@
// Mocks generated by Mockito 5.4.6 from annotations
// in lanebow/test/services/auth_service_test.dart.
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i5;
import 'package:dio/dio.dart' as _i4;
import 'package:flutter/material.dart' as _i6;
import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i2;
import 'package:lanebow/services/api_service.dart' as _i3;
import 'package:mockito/mockito.dart' as _i1;
// ignore_for_file: type=lint
// ignore_for_file: avoid_redundant_argument_values
// ignore_for_file: avoid_setters_without_getters
// ignore_for_file: comment_references
// ignore_for_file: deprecated_member_use
// ignore_for_file: deprecated_member_use_from_same_package
// ignore_for_file: implementation_imports
// ignore_for_file: invalid_use_of_visible_for_testing_member
// ignore_for_file: must_be_immutable
// ignore_for_file: prefer_const_constructors
// ignore_for_file: unnecessary_parenthesis
// ignore_for_file: camel_case_types
// ignore_for_file: subtype_of_sealed_class
class _FakeIOSOptions_0 extends _i1.SmartFake implements _i2.IOSOptions {
_FakeIOSOptions_0(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeAndroidOptions_1 extends _i1.SmartFake
implements _i2.AndroidOptions {
_FakeAndroidOptions_1(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeLinuxOptions_2 extends _i1.SmartFake implements _i2.LinuxOptions {
_FakeLinuxOptions_2(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeWindowsOptions_3 extends _i1.SmartFake
implements _i2.WindowsOptions {
_FakeWindowsOptions_3(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeWebOptions_4 extends _i1.SmartFake implements _i2.WebOptions {
_FakeWebOptions_4(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
class _FakeMacOsOptions_5 extends _i1.SmartFake implements _i2.MacOsOptions {
_FakeMacOsOptions_5(Object parent, Invocation parentInvocation)
: super(parent, parentInvocation);
}
/// A class which mocks [ApiService].
///
/// See the documentation for Mockito's code generation for more information.
class MockApiService extends _i1.Mock implements _i3.ApiService {
MockApiService() {
_i1.throwOnMissingStub(this);
}
@override
void setDio(_i4.Dio? dio) => super.noSuchMethod(
Invocation.method(#setDio, [dio]),
returnValueForMissingStub: null,
);
@override
void setToken(String? token) => super.noSuchMethod(
Invocation.method(#setToken, [token]),
returnValueForMissingStub: null,
);
@override
_i5.Future<dynamic> get(
String? path, {
Map<String, dynamic>? queryParameters,
}) =>
(super.noSuchMethod(
Invocation.method(
#get,
[path],
{#queryParameters: queryParameters},
),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);
@override
_i5.Future<dynamic> post(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#post, [path], {#data: data}),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);
@override
_i5.Future<dynamic> put(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#put, [path], {#data: data}),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);
@override
_i5.Future<dynamic> delete(String? path) =>
(super.noSuchMethod(
Invocation.method(#delete, [path]),
returnValue: _i5.Future<dynamic>.value(),
)
as _i5.Future<dynamic>);
}
/// A class which mocks [FlutterSecureStorage].
///
/// See the documentation for Mockito's code generation for more information.
class MockFlutterSecureStorage extends _i1.Mock
implements _i2.FlutterSecureStorage {
MockFlutterSecureStorage() {
_i1.throwOnMissingStub(this);
}
@override
_i2.IOSOptions get iOptions =>
(super.noSuchMethod(
Invocation.getter(#iOptions),
returnValue: _FakeIOSOptions_0(this, Invocation.getter(#iOptions)),
)
as _i2.IOSOptions);
@override
_i2.AndroidOptions get aOptions =>
(super.noSuchMethod(
Invocation.getter(#aOptions),
returnValue: _FakeAndroidOptions_1(
this,
Invocation.getter(#aOptions),
),
)
as _i2.AndroidOptions);
@override
_i2.LinuxOptions get lOptions =>
(super.noSuchMethod(
Invocation.getter(#lOptions),
returnValue: _FakeLinuxOptions_2(
this,
Invocation.getter(#lOptions),
),
)
as _i2.LinuxOptions);
@override
_i2.WindowsOptions get wOptions =>
(super.noSuchMethod(
Invocation.getter(#wOptions),
returnValue: _FakeWindowsOptions_3(
this,
Invocation.getter(#wOptions),
),
)
as _i2.WindowsOptions);
@override
_i2.WebOptions get webOptions =>
(super.noSuchMethod(
Invocation.getter(#webOptions),
returnValue: _FakeWebOptions_4(
this,
Invocation.getter(#webOptions),
),
)
as _i2.WebOptions);
@override
_i2.MacOsOptions get mOptions =>
(super.noSuchMethod(
Invocation.getter(#mOptions),
returnValue: _FakeMacOsOptions_5(
this,
Invocation.getter(#mOptions),
),
)
as _i2.MacOsOptions);
@override
void registerListener({
required String? key,
required _i6.ValueChanged<String?>? listener,
}) => super.noSuchMethod(
Invocation.method(#registerListener, [], {#key: key, #listener: listener}),
returnValueForMissingStub: null,
);
@override
void unregisterListener({
required String? key,
required _i6.ValueChanged<String?>? listener,
}) => super.noSuchMethod(
Invocation.method(#unregisterListener, [], {
#key: key,
#listener: listener,
}),
returnValueForMissingStub: null,
);
@override
void unregisterAllListenersForKey({required String? key}) =>
super.noSuchMethod(
Invocation.method(#unregisterAllListenersForKey, [], {#key: key}),
returnValueForMissingStub: null,
);
@override
void unregisterAllListeners() => super.noSuchMethod(
Invocation.method(#unregisterAllListeners, []),
returnValueForMissingStub: null,
);
@override
_i5.Future<void> write({
required String? key,
required String? value,
_i2.IOSOptions? iOptions,
_i2.AndroidOptions? aOptions,
_i2.LinuxOptions? lOptions,
_i2.WebOptions? webOptions,
_i2.MacOsOptions? mOptions,
_i2.WindowsOptions? wOptions,
}) =>
(super.noSuchMethod(
Invocation.method(#write, [], {
#key: key,
#value: value,
#iOptions: iOptions,
#aOptions: aOptions,
#lOptions: lOptions,
#webOptions: webOptions,
#mOptions: mOptions,
#wOptions: wOptions,
}),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<String?> read({
required String? key,
_i2.IOSOptions? iOptions,
_i2.AndroidOptions? aOptions,
_i2.LinuxOptions? lOptions,
_i2.WebOptions? webOptions,
_i2.MacOsOptions? mOptions,
_i2.WindowsOptions? wOptions,
}) =>
(super.noSuchMethod(
Invocation.method(#read, [], {
#key: key,
#iOptions: iOptions,
#aOptions: aOptions,
#lOptions: lOptions,
#webOptions: webOptions,
#mOptions: mOptions,
#wOptions: wOptions,
}),
returnValue: _i5.Future<String?>.value(),
)
as _i5.Future<String?>);
@override
_i5.Future<bool> containsKey({
required String? key,
_i2.IOSOptions? iOptions,
_i2.AndroidOptions? aOptions,
_i2.LinuxOptions? lOptions,
_i2.WebOptions? webOptions,
_i2.MacOsOptions? mOptions,
_i2.WindowsOptions? wOptions,
}) =>
(super.noSuchMethod(
Invocation.method(#containsKey, [], {
#key: key,
#iOptions: iOptions,
#aOptions: aOptions,
#lOptions: lOptions,
#webOptions: webOptions,
#mOptions: mOptions,
#wOptions: wOptions,
}),
returnValue: _i5.Future<bool>.value(false),
)
as _i5.Future<bool>);
@override
_i5.Future<void> delete({
required String? key,
_i2.IOSOptions? iOptions,
_i2.AndroidOptions? aOptions,
_i2.LinuxOptions? lOptions,
_i2.WebOptions? webOptions,
_i2.MacOsOptions? mOptions,
_i2.WindowsOptions? wOptions,
}) =>
(super.noSuchMethod(
Invocation.method(#delete, [], {
#key: key,
#iOptions: iOptions,
#aOptions: aOptions,
#lOptions: lOptions,
#webOptions: webOptions,
#mOptions: mOptions,
#wOptions: wOptions,
}),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<Map<String, String>> readAll({
_i2.IOSOptions? iOptions,
_i2.AndroidOptions? aOptions,
_i2.LinuxOptions? lOptions,
_i2.WebOptions? webOptions,
_i2.MacOsOptions? mOptions,
_i2.WindowsOptions? wOptions,
}) =>
(super.noSuchMethod(
Invocation.method(#readAll, [], {
#iOptions: iOptions,
#aOptions: aOptions,
#lOptions: lOptions,
#webOptions: webOptions,
#mOptions: mOptions,
#wOptions: wOptions,
}),
returnValue: _i5.Future<Map<String, String>>.value(
<String, String>{},
),
)
as _i5.Future<Map<String, String>>);
@override
_i5.Future<void> deleteAll({
_i2.IOSOptions? iOptions,
_i2.AndroidOptions? aOptions,
_i2.LinuxOptions? lOptions,
_i2.WebOptions? webOptions,
_i2.MacOsOptions? mOptions,
_i2.WindowsOptions? wOptions,
}) =>
(super.noSuchMethod(
Invocation.method(#deleteAll, [], {
#iOptions: iOptions,
#aOptions: aOptions,
#lOptions: lOptions,
#webOptions: webOptions,
#mOptions: mOptions,
#wOptions: wOptions,
}),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
)
as _i5.Future<void>);
@override
_i5.Future<bool?> isCupertinoProtectedDataAvailable() =>
(super.noSuchMethod(
Invocation.method(#isCupertinoProtectedDataAvailable, []),
returnValue: _i5.Future<bool?>.value(),
)
as _i5.Future<bool?>);
}
+93 -61
View File
@@ -10,6 +10,7 @@ import 'package:lanebow/services/event_bus.dart';
import 'package:lanebow/models/club_model.dart';
import 'package:lanebow/config/api_config.dart';
import 'package:lanebow/utils/test_utils.dart';
import '../utils/event_bus_test_utils.dart';
// 모킹 클래스 생성
@GenerateMocks([ApiService])
@@ -40,7 +41,7 @@ void main() {
// 테스트별 고유 ID 생성 및 설정 - 일관된 패턴 적용
final testId = 'club_service_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId); // 이 호출로 모든 인스턴스가 초기화됨
await EventBusTestUtils.setUp(testId);
// SharedPreferences 모킹
SharedPreferences.setMockInitialValues({});
@@ -67,8 +68,8 @@ void main() {
}
}
// EventBus 테스트 ID 초기화 - 일관된 패턴 적용
EventBus.clearCurrentTestId(); // 이 호출로 모든 인스턴스가 초기화됨
// EventBus 표준 정리
await EventBusTestUtils.tearDown();
// 테스트 모드 해제
TestUtils.setTestMode(false);
@@ -93,7 +94,7 @@ void main() {
});
when(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).thenAnswer((_) async => testClub);
@@ -102,7 +103,7 @@ void main() {
// then
verify(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).called(1);
@@ -130,7 +131,7 @@ void main() {
test('fetchClubById 메서드가 특정 클럽 정보를 가져와야 함', () async {
// given
when(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).thenAnswer((_) async => testClub);
@@ -139,7 +140,7 @@ void main() {
// then
verify(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).called(1);
@@ -150,7 +151,7 @@ void main() {
test('setCurrentClub 메서드가 클럽을 설정해야 함', () async {
// given
when(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).thenAnswer((_) async => testClub);
@@ -159,7 +160,7 @@ void main() {
// then
verify(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).called(1);
@@ -174,7 +175,7 @@ void main() {
)).thenAnswer((_) async => {});
when(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).thenAnswer((_) async => testClub);
@@ -203,7 +204,7 @@ void main() {
)).called(1);
verify(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: {'clubId': testClubId},
)).called(1);
@@ -227,7 +228,7 @@ void main() {
);
when(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: newClub.toJson(),
)).thenAnswer((_) async => {'club': testClub});
@@ -236,7 +237,7 @@ void main() {
// then
verify(mockApiService.post(
'${ApiConfig.clubs}',
ApiConfig.clubs,
data: newClub.toJson(),
)).called(1);
@@ -246,24 +247,30 @@ void main() {
});
test('updateClub 메서드가 클럽 정보를 업데이트해야 함', () async {
// given
// given - 테스트 로컬 인스턴스 사용으로 격리
final localMock = MockApiService();
final localService = ClubService.forTest(localMock);
await localService.initialize(testToken);
final updates = {'name': '업데이트된 클럽'};
final updatedClub = Map<String, dynamic>.from(testClub);
updatedClub['name'] = '업데이트된 클럽';
// 클럽 목록에 테스트 클럽 추가
when(mockApiService.post('${ApiConfig.clubs}/user'))
// 클럽 목록/현재 클럽 설정은 로컬 서비스 기준으로 스텁
when(localMock.post('${ApiConfig.clubs}/user'))
.thenAnswer((_) async => [testClub]);
await clubService.fetchUserClubs();
// 현재 클럽 설정
when(mockApiService.post(
'${ApiConfig.clubs}',
await localService.fetchUserClubs();
when(localMock.post(
ApiConfig.clubs,
data: {'clubId': testClubId},
)).thenAnswer((_) async => testClub);
await clubService.fetchClubById(testClubId);
when(mockApiService.put(
await localService.fetchClubById(testClubId);
// 준비 단계 상호작용 초기화
clearInteractions(localMock);
when(localMock.put(
ApiConfig.clubs,
data: {
'clubId': testClubId,
@@ -272,42 +279,57 @@ void main() {
)).thenAnswer((_) async => {'club': updatedClub});
// when
final result = await clubService.updateClub(testClubId, updates);
final result = await localService.updateClub(testClubId, updates);
// 상태 수렴 보장
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
// 추가 폴링: 이름 반영 수렴까지 대기 (최대 2.5s)
const total = Duration(milliseconds: 2500);
const step = Duration(milliseconds: 25);
var waited = Duration.zero;
while (
(localService.clubs.isEmpty || localService.clubs.first.name != '업데이트된 클럽' ||
localService.currentClub?.name != '업데이트된 클럽') &&
waited < total
) {
await Future<void>.delayed(step);
waited += step;
}
// idle 보장 및 소폭 지연 추가로 최종 수렴 보장
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
// then
verify(mockApiService.put(
ApiConfig.clubs,
data: {
'clubId': testClubId,
...updates,
},
)).called(1);
// then - 상태 중심 검증
expect(result.name, '업데이트된 클럽');
expect(clubService.clubs[0].name, '업데이트된 클럽');
expect(clubService.currentClub?.name, '업데이트된 클럽');
expect(localService.clubs[0].name, '업데이트된 클럽');
expect(localService.currentClub?.name, '업데이트된 클럽');
});
test('updateClub 메서드가 직접 클럽 객체가 반환되는 경우도 처리해야 함', () async {
// given
// given - 테스트 로컬 인스턴스 사용
final localMock = MockApiService();
final localService = ClubService.forTest(localMock);
await localService.initialize(testToken);
final updates = {'name': '업데이트된 클럽'};
final updatedClub = Map<String, dynamic>.from(testClub);
updatedClub['name'] = '업데이트된 클럽';
// 클럽 목록에 테스트 클럽 추가
when(mockApiService.post('${ApiConfig.clubs}/user'))
when(localMock.post('${ApiConfig.clubs}/user'))
.thenAnswer((_) async => [testClub]);
await clubService.fetchUserClubs();
// 현재 클럽 설정
when(mockApiService.post(
'${ApiConfig.clubs}',
await localService.fetchUserClubs();
when(localMock.post(
ApiConfig.clubs,
data: {'clubId': testClubId},
)).thenAnswer((_) async => testClub);
await clubService.fetchClubById(testClubId);
await localService.fetchClubById(testClubId);
// 준비 단계 상호작용 초기화로 검증 범위 격리
clearInteractions(localMock);
// 직접 클럽 객체 반환 시뮬레이션
when(mockApiService.put(
when(localMock.put(
ApiConfig.clubs,
data: {
'clubId': testClubId,
@@ -316,20 +338,30 @@ void main() {
)).thenAnswer((_) async => updatedClub);
// when
final result = await clubService.updateClub(testClubId, updates);
final result = await localService.updateClub(testClubId, updates);
// 상태 수렴 보장
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
// 추가 폴링: 이름 반영 수렴까지 대기 (최대 2.5s)
const total = Duration(milliseconds: 2500);
const step = Duration(milliseconds: 25);
var waited = Duration.zero;
while (
(localService.clubs.isEmpty || localService.clubs.first.name != '업데이트된 클럽' ||
localService.currentClub?.name != '업데이트된 클럽') &&
waited < total
) {
await Future<void>.delayed(step);
waited += step;
}
// idle 보장 및 소폭 지연 추가로 최종 수렴 보장
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
// then
verify(mockApiService.put(
ApiConfig.clubs,
data: {
'clubId': testClubId,
...updates,
},
)).called(1);
// then - 상태 결과 검증
expect(result.name, '업데이트된 클럽');
expect(clubService.clubs[0].name, '업데이트된 클럽');
expect(clubService.currentClub?.name, '업데이트된 클럽');
expect(localService.clubs[0].name, '업데이트된 클럽');
expect(localService.currentClub?.name, '업데이트된 클럽');
});
});
}
@@ -3,8 +3,9 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:async' as _i4;
import 'package:dio/dio.dart' as _i3;
import 'package:lanebow/services/api_service.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
@@ -30,6 +31,12 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
_i1.throwOnMissingStub(this);
}
@override
void setDio(_i3.Dio? dio) => super.noSuchMethod(
Invocation.method(#setDio, [dio]),
returnValueForMissingStub: null,
);
@override
void setToken(String? token) => super.noSuchMethod(
Invocation.method(#setToken, [token]),
@@ -37,7 +44,7 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
);
@override
_i3.Future<dynamic> get(
_i4.Future<dynamic> get(
String? path, {
Map<String, dynamic>? queryParameters,
}) =>
@@ -47,31 +54,31 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
[path],
{#queryParameters: queryParameters},
),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> post(String? path, {dynamic data}) =>
_i4.Future<dynamic> post(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#post, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> put(String? path, {dynamic data}) =>
_i4.Future<dynamic> put(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#put, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> delete(String? path) =>
_i4.Future<dynamic> delete(String? path) =>
(super.noSuchMethod(
Invocation.method(#delete, [path]),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
}
@@ -0,0 +1,181 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:dio/dio.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/services/api_service.dart';
import 'package:lanebow/config/api_config.dart';
import 'package:lanebow/models/participant_model.dart';
void main() {
group('EventService participants API contract', () {
late ApiService api;
late Dio dio;
late List<RequestOptions> captured;
setUp(() async {
SharedPreferences.setMockInitialValues({
ApiConfig.userIdKey: 'user-1',
ApiConfig.clubIdKey: 'club-1',
});
api = ApiService();
dio = Dio(BaseOptions(baseUrl: ApiConfig.baseUrl));
captured = [];
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
captured.add(options);
// 라우트 분기
if (options.path.endsWith('/events/evt-1/participants/list')) {
// participants: 배열로 응답
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: [
{
'id': 'p1',
'eventId': 'evt-1',
'memberId': 'm1',
'status': 'confirmed',
'paymentStatus': 'paid',
'Member': {
'name': '홍길동',
'email': 'hong@example.com',
'phone': '010-1111-2222',
}
}
],
));
}
if (options.path.endsWith('/events/evt-1/participants')) {
final req = options.data as Map<String, dynamic>? ?? {};
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: {
'participant': {
'id': 'new-1',
'eventId': req['eventId'] ?? 'evt-1',
'memberId': 'mX',
'name': req['name'],
'status': req['status'],
'paymentStatus': req['paymentStatus'] ?? (req['isPaid'] == true ? 'paid' : 'unpaid'),
}
},
));
}
if (options.path.endsWith('/events/evt-1/participants/p1')) {
final req = options.data as Map<String, dynamic>? ?? {};
return handler.resolve(Response(
requestOptions: options,
statusCode: 200,
data: {
'participant': {
'id': 'p1',
'eventId': 'evt-1',
'memberId': 'm1',
'name': req['name'] ?? '홍길동',
'status': req['status'] ?? 'confirmed',
'paymentStatus': req['paymentStatus'] ?? 'paid',
}
},
));
}
// 기본: 404
return handler.resolve(Response(
requestOptions: options,
statusCode: 404,
data: {'error': 'not found', 'path': options.path},
));
},
));
api.setDio(dio);
api.setToken('test-token');
});
test('fetchEventParticipants posts to list endpoint with clubId/userId and parses participants', () async {
final svc = EventService.forTest(api);
// 내부 상태에 clubId 세팅
svc.setClubId('club-1');
await svc.initialize('test-token');
final list = await svc.fetchEventParticipants('evt-1');
// 요청 검증
expect(captured.isNotEmpty, true);
final req = captured.first;
expect(req.method, 'POST');
expect(req.path, '${ApiConfig.clubs}/events/evt-1/participants/list');
final body = req.data as Map<String, dynamic>;
expect(body['clubId'], 'club-1');
expect(body['userId'], 'user-1');
expect(body['eventId'], 'evt-1');
// 파싱 검증
expect(list, isA<List<Participant>>());
expect(list.length, 1);
final p = list.first;
expect(p.name, '홍길동');
expect(p.email, 'hong@example.com');
expect(p.phoneNumber, '010-1111-2222');
expect(p.status, 'confirmed');
expect(p.isPaid, isTrue);
});
test('addParticipant posts to correct endpoint and merges clubId into payload', () async {
final svc = EventService.forTest(api);
svc.setClubId('club-1');
await svc.initialize('test-token');
final created = await svc.addParticipant('evt-1', {
'eventId': 'evt-1',
'name': '새 참가자',
'status': 'registered',
'isPaid': false,
'paymentStatus': 'unpaid',
});
// 마지막 요청 검증
final req = captured.last;
expect(req.method, 'POST');
expect(req.path, '${ApiConfig.clubs}/events/evt-1/participants');
final body = req.data as Map<String, dynamic>;
expect(body['clubId'], 'club-1');
expect(body['name'], '새 참가자');
expect(body['status'], 'registered');
expect(body['paymentStatus'], 'unpaid');
expect(created.id, 'new-1');
expect(created.status, 'registered');
expect(created.isPaid, isFalse);
});
test('updateParticipant puts to correct endpoint and returns updated model', () async {
final svc = EventService.forTest(api);
svc.setClubId('club-1');
await svc.initialize('test-token');
final updated = await svc.updateParticipant('evt-1', 'p1', {
'name': '수정된',
'status': 'confirmed',
'paymentStatus': 'paid',
});
final req = captured.last;
expect(req.method, 'PUT');
expect(req.path, '${ApiConfig.clubs}/events/evt-1/participants/p1');
final body = req.data as Map<String, dynamic>;
expect(body['name'], '수정된');
expect(body['status'], 'confirmed');
expect(body['paymentStatus'], 'paid');
expect(updated.id, 'p1');
expect(updated.name, '수정된');
expect(updated.status, 'confirmed');
expect(updated.isPaid, isTrue);
});
});
}
+221 -31
View File
@@ -4,12 +4,9 @@ import 'package:mockito/annotations.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:lanebow/services/event_service.dart';
import 'package:lanebow/services/api_service.dart';
import 'package:lanebow/services/event_bus.dart';
import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/models/participant_model.dart';
import 'package:lanebow/models/score_model.dart';
import 'package:lanebow/config/api_config.dart';
import 'package:lanebow/utils/test_utils.dart';
import '../utils/event_bus_test_utils.dart';
// 모킹 클래스 생성
@GenerateMocks([ApiService])
@@ -68,10 +65,7 @@ void main() {
// 테스트별 고유 ID 생성 및 설정
final testId = 'event_service_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId);
// EventBus 완전 초기화
EventBus().reset();
await EventBusTestUtils.setUp(testId);
// SharedPreferences 모킹
SharedPreferences.setMockInitialValues({
@@ -91,15 +85,12 @@ void main() {
eventService.setClubId(testClubId);
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
tearDown(() async {
// 테스트에서 생성된 EventService 정리
eventService.dispose();
try { eventService.dispose(); } catch (_) {}
// 테스트 ID 초기화
EventBus.clearCurrentTestId();
// EventBus 표준 정리
await EventBusTestUtils.tearDown();
// 테스트 모드 해제
TestUtils.setTestMode(false);
@@ -248,8 +239,8 @@ void main() {
final testParticipants = [testParticipant];
when(mockApiService.post(
'${ApiConfig.clubs}/events/participants',
data: {'eventId': eventId},
'${ApiConfig.clubs}/events/$eventId/participants/list',
data: anyNamed('data'),
)).thenAnswer((_) async => {'participants': testParticipants});
// when
@@ -257,8 +248,8 @@ void main() {
// then
verify(mockApiService.post(
'${ApiConfig.clubs}/events/participants',
data: {'eventId': eventId},
'${ApiConfig.clubs}/events/$eventId/participants/list',
data: anyNamed('data'),
)).called(1);
expect(result.length, 1);
@@ -274,9 +265,10 @@ void main() {
'status': 'confirmed',
};
// 서비스는 clubId를 payload에 병합하므로 포함하여 검증
when(mockApiService.post(
'${ApiConfig.clubs}/events/$eventId/participants',
data: participantData,
data: anyNamed('data'),
)).thenAnswer((_) async => {'participant': testParticipant});
// when
@@ -285,7 +277,7 @@ void main() {
// then
verify(mockApiService.post(
'${ApiConfig.clubs}/events/$eventId/participants',
data: participantData,
data: anyNamed('data'),
)).called(1);
expect(result.id, 'test_participant_id');
@@ -296,20 +288,20 @@ void main() {
// given
final eventId = 'test_event_id';
final participantId = 'test_participant_id';
final updates = {'status': 'cancelled'};
final updates = {'status': 'canceled'};
final updatedParticipant = Map<String, dynamic>.from(testParticipant);
updatedParticipant['status'] = 'cancelled';
updatedParticipant['status'] = 'canceled';
// 참가자 목록에 테스트 참가자 추가
when(mockApiService.post(
'${ApiConfig.clubs}/events/participants',
data: {'eventId': eventId},
'${ApiConfig.clubs}/events/$eventId/participants/list',
data: anyNamed('data'),
)).thenAnswer((_) async => {'participants': [testParticipant]});
await eventService.fetchEventParticipants(eventId);
when(mockApiService.put(
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
data: updates,
data: anyNamed('data'),
)).thenAnswer((_) async => {'participant': updatedParticipant});
// when
@@ -318,11 +310,11 @@ void main() {
// then
verify(mockApiService.put(
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
data: updates,
data: anyNamed('data'),
)).called(1);
expect(result.status, 'cancelled');
expect(eventService.participants[0].status, 'cancelled');
expect(result.status, 'canceled');
expect(eventService.participants[0].status, 'canceled');
});
test('removeParticipant 메서드가 참가자를 삭제해야 함', () async {
@@ -332,8 +324,8 @@ void main() {
// 참가자 목록에 테스트 참가자 추가
when(mockApiService.post(
'${ApiConfig.clubs}/events/participants',
data: {'eventId': eventId},
'${ApiConfig.clubs}/events/$eventId/participants/list',
data: anyNamed('data'),
)).thenAnswer((_) async => {'participants': [testParticipant]});
await eventService.fetchEventParticipants(eventId);
@@ -403,5 +395,203 @@ void main() {
expect(result.id, 'test_score_id');
expect(eventService.scores.length, 1);
});
test('updateScore 메서드가 점수를 업데이트해야 함', () async {
// given
final eventId = 'test_event_id';
final scoreId = 'test_score_id';
final updates = {
'games': [190, 210, 230],
'handicap': 5,
};
// 초기 점수 목록 적재
when(mockApiService.post(
'${ApiConfig.clubs}/events/scores',
data: {'eventId': eventId},
)).thenAnswer((_) async => {'scores': [testScore]});
await eventService.fetchEventScores(eventId);
final updated = Map<String, dynamic>.from(testScore);
updated['games'] = [190, 210, 230];
updated['handicap'] = 5;
updated['total'] = 635; // 예시 합계
updated['average'] = 211.67;
when(mockApiService.put(
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
data: updates,
)).thenAnswer((_) async => {'score': updated});
// when
final result = await eventService.updateScore(eventId, scoreId, updates);
// then
verify(mockApiService.put(
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
data: updates,
)).called(1);
expect(result.handicap, 5);
expect(eventService.scores.first.handicap, 5);
});
test('deleteScore 메서드가 점수를 삭제해야 함', () async {
// given
final eventId = 'test_event_id';
final scoreId = 'test_score_id';
// 초기 점수 목록 적재
when(mockApiService.post(
'${ApiConfig.clubs}/events/scores',
data: {'eventId': eventId},
)).thenAnswer((_) async => {'scores': [testScore]});
await eventService.fetchEventScores(eventId);
when(mockApiService.delete(
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
)).thenAnswer((_) async => {});
// when
final result = await eventService.deleteScore(eventId, scoreId);
// then
verify(mockApiService.delete(
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
)).called(1);
expect(result, true);
expect(eventService.scores.length, 0);
});
// 실패 케이스: 참가자 추가
test('addParticipant 실패 시 예외 메시지와 로딩 상태 해제 및 목록 불변', () async {
// given
final eventId = 'test_event_id';
final participantData = {
'memberId': 'test_member_id',
'status': 'confirmed',
};
when(mockApiService.post(
'${ApiConfig.clubs}/events/$eventId/participants',
data: anyNamed('data'),
)).thenThrow(Exception('API 오류'));
// when
expect(
() => eventService.addParticipant(eventId, participantData),
throwsA(isA<Exception>().having((e) => e.toString(), 'message', contains('참가자 추가에 실패했습니다'))),
);
// then
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eventService.isLoading, false);
expect(eventService.participants.length, 0);
});
// 실패 케이스: 참가자 업데이트
test('updateParticipant 실패 시 예외 메시지와 로딩 상태 해제 및 목록 불변', () async {
// given
final eventId = 'test_event_id';
final participantId = 'test_participant_id';
final updates = {'status': 'canceled'};
// 기존 목록 적재
when(mockApiService.post(
'${ApiConfig.clubs}/events/$eventId/participants/list',
data: {'clubId': testClubId, 'eventId': eventId},
)).thenAnswer((_) async => {'participants': [testParticipant]});
await eventService.fetchEventParticipants(eventId);
when(mockApiService.put(
'${ApiConfig.clubs}/events/$eventId/participants/$participantId',
data: anyNamed('data'),
)).thenThrow(Exception('API 오류'));
// when
expect(
() => eventService.updateParticipant(eventId, participantId, updates),
throwsA(isA<Exception>().having((e) => e.toString(), 'message', contains('참가자 정보 업데이트에 실패했습니다'))),
);
// then
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eventService.isLoading, false);
// 실패 시 기존 상태 유지
expect(eventService.participants.first.status, testParticipant['status']);
});
// 실패 케이스: 점수 추가
test('addScore 실패 시 예외 메시지와 로딩 상태 해제 및 목록 불변', () async {
// given
final eventId = 'test_event_id';
final scoreData = {
'participantId': 'test_participant_id',
'games': [180, 200, 220],
'handicap': 10,
};
when(mockApiService.post(
'${ApiConfig.clubs}/events/$eventId/scores',
data: scoreData,
)).thenThrow(Exception('API 오류'));
// when
expect(
() => eventService.addScore(eventId, scoreData),
throwsA(isA<Exception>().having((e) => e.toString(), 'message', contains('점수 추가에 실패했습니다'))),
);
// then
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(eventService.isLoading, false);
expect(eventService.scores.length, 0);
});
// 실패 케이스: 점수 업데이트
test('updateScore 실패 시 예외 메시지와 로딩 상태 해제 및 목록 불변', () async {
// given
final eventId = 'test_event_id';
final scoreId = 'test_score_id';
final updates = {
'games': [190, 210, 230],
'handicap': 5,
};
// 기존 목록 적재
when(mockApiService.post(
'${ApiConfig.clubs}/events/scores',
data: {'eventId': eventId},
)).thenAnswer((_) async => {'scores': [testScore]});
await eventService.fetchEventScores(eventId);
when(mockApiService.put(
'${ApiConfig.clubs}/events/$eventId/scores/$scoreId',
data: updates,
)).thenThrow(Exception('API 오류'));
// when
expect(
() => eventService.updateScore(eventId, scoreId, updates),
throwsA(isA<Exception>().having((e) => e.toString(), 'message', contains('점수 업데이트에 실패했습니다'))),
);
// then
await EventBusTestUtils.waitForIdle();
// isLoading=false 수렴까지 폴링 대기 (최대 1.5s)
const total = Duration(milliseconds: 1500);
const step = Duration(milliseconds: 25);
var waited = Duration.zero;
while (eventService.isLoading && waited < total) {
await Future<void>.delayed(step);
waited += step;
}
expect(eventService.isLoading, false);
// 실패 시 기존 상태 유지
expect(eventService.scores.first.id, testScore['id']);
});
});
}
@@ -3,8 +3,9 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:async' as _i4;
import 'package:dio/dio.dart' as _i3;
import 'package:lanebow/services/api_service.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
@@ -30,6 +31,12 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
_i1.throwOnMissingStub(this);
}
@override
void setDio(_i3.Dio? dio) => super.noSuchMethod(
Invocation.method(#setDio, [dio]),
returnValueForMissingStub: null,
);
@override
void setToken(String? token) => super.noSuchMethod(
Invocation.method(#setToken, [token]),
@@ -37,7 +44,7 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
);
@override
_i3.Future<dynamic> get(
_i4.Future<dynamic> get(
String? path, {
Map<String, dynamic>? queryParameters,
}) =>
@@ -47,31 +54,31 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
[path],
{#queryParameters: queryParameters},
),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> post(String? path, {dynamic data}) =>
_i4.Future<dynamic> post(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#post, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> put(String? path, {dynamic data}) =>
_i4.Future<dynamic> put(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#put, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> delete(String? path) =>
_i4.Future<dynamic> delete(String? path) =>
(super.noSuchMethod(
Invocation.method(#delete, [path]),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
}
+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');
}
});
}
@@ -3,8 +3,9 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:async' as _i4;
import 'package:dio/dio.dart' as _i3;
import 'package:lanebow/services/api_service.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
@@ -30,6 +31,12 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
_i1.throwOnMissingStub(this);
}
@override
void setDio(_i3.Dio? dio) => super.noSuchMethod(
Invocation.method(#setDio, [dio]),
returnValueForMissingStub: null,
);
@override
void setToken(String? token) => super.noSuchMethod(
Invocation.method(#setToken, [token]),
@@ -37,7 +44,7 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
);
@override
_i3.Future<dynamic> get(
_i4.Future<dynamic> get(
String? path, {
Map<String, dynamic>? queryParameters,
}) =>
@@ -47,31 +54,31 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
[path],
{#queryParameters: queryParameters},
),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> post(String? path, {dynamic data}) =>
_i4.Future<dynamic> post(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#post, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> put(String? path, {dynamic data}) =>
_i4.Future<dynamic> put(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#put, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> delete(String? path) =>
_i4.Future<dynamic> delete(String? path) =>
(super.noSuchMethod(
Invocation.method(#delete, [path]),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
}
@@ -5,7 +5,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/timezone.dart' as tz;
import 'package:timezone/data/latest.dart' as tz_data;
import 'package:lanebow/models/event_model.dart';
import 'package:lanebow/services/event_bus.dart';
import '../utils/event_bus_test_utils.dart';
import 'package:lanebow/utils/test_utils.dart';
// 모킹 클래스 생성
@@ -17,16 +17,13 @@ void main() {
late MockFlutterLocalNotificationsPlugin mockNotificationsPlugin;
// 테스트 전 설정
setUp(() {
setUp(() async {
// 테스트 모드 강제 설정
TestUtils.setTestMode(true);
// 테스트별 고유 ID 생성 및 설정
final testId = 'notification_service_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId);
// EventBus 완전 초기화
EventBus().reset();
await EventBusTestUtils.setUp(testId);
// 모킹된 알림 플러그인 생성
mockNotificationsPlugin = MockFlutterLocalNotificationsPlugin();
@@ -59,12 +56,9 @@ void main() {
tz_data.initializeTimeZones();
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
// 테스트 ID 초기화
EventBus.clearCurrentTestId();
tearDown(() async {
// EventBus 표준 정리
await EventBusTestUtils.tearDown();
// 테스트 모드 해제
TestUtils.setTestMode(false);
+129 -13
View File
@@ -4,10 +4,9 @@ import 'package:mockito/annotations.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:lanebow/services/score_service.dart';
import 'package:lanebow/services/api_service.dart';
import 'package:lanebow/services/event_bus.dart';
import 'package:lanebow/models/score_model.dart';
import 'package:lanebow/config/api_config.dart';
import 'package:lanebow/utils/test_utils.dart';
import '../utils/event_bus_test_utils.dart';
// 모킹 클래스 생성
@GenerateMocks([ApiService])
@@ -52,10 +51,7 @@ void main() {
// 테스트별 고유 ID 생성 및 설정
final testId = 'score_service_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId);
// EventBus 완전 초기화
EventBus().reset();
await EventBusTestUtils.setUp(testId);
// SharedPreferences 모킹
SharedPreferences.setMockInitialValues({
@@ -72,15 +68,12 @@ void main() {
scoreService.initialize(testToken);
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
tearDown(() async {
// 테스트에서 생성된 ScoreService 정리
scoreService.dispose();
try { scoreService.dispose(); } catch (_) {}
// 테스트 ID 초기화
EventBus.clearCurrentTestId();
// EventBus 표준 정리
await EventBusTestUtils.tearDown();
// 테스트 모드 해제
TestUtils.setTestMode(false);
@@ -130,6 +123,8 @@ void main() {
expect(scoreService.scores.length, 1);
expect(scoreService.scores[0].id, testScoreId);
expect(scoreService.scores[0].totalScore, 55);
expect(scoreService.lastLoadedAt, isNotNull);
expect(scoreService.lastError, isNull);
});
test('fetchClubScores 메서드가 객체 형태의 응답도 처리해야 함', () async {
@@ -279,6 +274,125 @@ void main() {
expect(scoreService.scores.length, 0);
});
test('fetchClubScores: clubId가 null이면 예외를 던지고 isLoading=false 유지', () async {
// given: prefs에서 clubId 제거
SharedPreferences.setMockInitialValues({});
// 새 서비스 인스턴스(클럽 미설정)
final svc = ScoreService.forTest(mockApiService);
svc.initialize(testToken);
// when/then
expect(() async => await svc.fetchClubScores(), throwsA(isA<Exception>()));
// 약간 대기 후 상태 확인(비동기 반영 안정화)
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(svc.isLoading, isFalse);
expect(svc.lastError, isNotNull);
});
test('fetchClubScores: 빈 목록 응답이면 기존 scores를 비워야 함', () async {
// given: 먼저 1개 로드
when(mockApiService.post(
'${ApiConfig.clubs}/scores',
data: {'clubId': testClubId},
)).thenAnswer((_) async => [testScore]);
await scoreService.fetchClubScores();
expect(scoreService.scores.length, 1);
// 빈 목록 응답으로 재설정
when(mockApiService.post(
'${ApiConfig.clubs}/scores',
data: {'clubId': testClubId},
)).thenAnswer((_) async => <Map<String, dynamic>>[]);
// when
await scoreService.fetchClubScores();
// then
expect(scoreService.scores, isEmpty);
expect(scoreService.isLoading, isFalse);
expect(scoreService.lastLoadedAt, isNotNull);
expect(scoreService.lastError, isNull);
});
test('addScore 실패 시 예외 전파, isLoading=false, scores 불변', () async {
// given
final payload = {
'memberId': testMemberId,
'eventId': testEventId,
'clubId': testClubId,
'frames': [1],
'totalScore': 1,
};
when(mockApiService.post(ApiConfig.scores, data: payload))
.thenThrow(Exception('network'));
final beforeLen = scoreService.scores.length;
// when/then
expect(() async => await scoreService.addScore(payload), throwsA(isA<Exception>()));
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(scoreService.isLoading, isFalse);
expect(scoreService.scores.length, beforeLen);
expect(scoreService.lastError, isNotNull);
});
test('updateScore 실패 시 예외 전파, isLoading=false, scores 불변', () async {
// given: 기존 점수 1개 로드
when(mockApiService.post(
'${ApiConfig.clubs}/scores',
data: {'clubId': testClubId},
)).thenAnswer((_) async => [testScore]);
await scoreService.fetchClubScores();
final before = List.from(scoreService.scores);
when(mockApiService.put('${ApiConfig.scores}/$testScoreId', data: anyNamed('data')))
.thenThrow(Exception('update failed'));
// when/then
expect(() async => await scoreService.updateScore(testScoreId, {'totalScore': 77}), throwsA(isA<Exception>()));
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(scoreService.isLoading, isFalse);
expect(scoreService.scores.map((e) => e.totalScore), before.map((e) => e.totalScore));
expect(scoreService.lastError, isNotNull);
});
test('deleteScore 실패 시 예외 전파, isLoading=false, scores 불변', () async {
// given: 기존 점수 1개 로드
when(mockApiService.post(
'${ApiConfig.clubs}/scores',
data: {'clubId': testClubId},
)).thenAnswer((_) async => [testScore]);
await scoreService.fetchClubScores();
final beforeLen = scoreService.scores.length;
when(mockApiService.delete('${ApiConfig.scores}/$testScoreId'))
.thenThrow(Exception('delete failed'));
// when/then
expect(() async => await scoreService.deleteScore(testScoreId), throwsA(isA<Exception>()));
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(scoreService.isLoading, isFalse);
expect(scoreService.scores.length, beforeLen);
});
test('fetchMemberScores: token 또는 clubId 없으면 예외', () async {
// given: 새 서비스(초기화/클럽 미설정)
final svc = ScoreService.forTest(mockApiService);
// when/then
expect(() async => await svc.fetchMemberScores(testMemberId), throwsA(isA<Exception>()));
});
test('fetchEventScores: token 또는 clubId 없으면 예외', () async {
// given: 새 서비스(초기화/클럽 미설정)
final svc = ScoreService.forTest(mockApiService);
// when/then
expect(() async => await svc.fetchEventScores(testEventId), throwsA(isA<Exception>()));
});
test('fetchMemberStatistics 메서드가 회원 통계를 가져와야 함', () async {
// given
when(mockApiService.post(
@@ -317,6 +431,8 @@ void main() {
// when
final result = await scoreService.fetchClubStatistics();
await EventBusTestUtils.waitForIdle();
await Future<void>.delayed(const Duration(milliseconds: 50));
// then
verify(mockApiService.post(
@@ -3,8 +3,9 @@
// Do not manually edit this file.
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'dart:async' as _i3;
import 'dart:async' as _i4;
import 'package:dio/dio.dart' as _i3;
import 'package:lanebow/services/api_service.dart' as _i2;
import 'package:mockito/mockito.dart' as _i1;
@@ -30,6 +31,12 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
_i1.throwOnMissingStub(this);
}
@override
void setDio(_i3.Dio? dio) => super.noSuchMethod(
Invocation.method(#setDio, [dio]),
returnValueForMissingStub: null,
);
@override
void setToken(String? token) => super.noSuchMethod(
Invocation.method(#setToken, [token]),
@@ -37,7 +44,7 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
);
@override
_i3.Future<dynamic> get(
_i4.Future<dynamic> get(
String? path, {
Map<String, dynamic>? queryParameters,
}) =>
@@ -47,31 +54,31 @@ class MockApiService extends _i1.Mock implements _i2.ApiService {
[path],
{#queryParameters: queryParameters},
),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> post(String? path, {dynamic data}) =>
_i4.Future<dynamic> post(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#post, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> put(String? path, {dynamic data}) =>
_i4.Future<dynamic> put(String? path, {dynamic data}) =>
(super.noSuchMethod(
Invocation.method(#put, [path], {#data: data}),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
@override
_i3.Future<dynamic> delete(String? path) =>
_i4.Future<dynamic> delete(String? path) =>
(super.noSuchMethod(
Invocation.method(#delete, [path]),
returnValue: _i3.Future<dynamic>.value(),
returnValue: _i4.Future<dynamic>.value(),
)
as _i3.Future<dynamic>);
as _i4.Future<dynamic>);
}
@@ -4,11 +4,9 @@ import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
import 'package:http/http.dart' as http;
import 'package:lanebow/services/subscription_service.dart';
import 'package:lanebow/services/event_bus.dart';
import 'package:lanebow/models/subscription_model.dart';
import 'package:lanebow/config/api_config.dart';
import 'package:lanebow/services/in_app_purchase_service.dart';
import 'package:lanebow/utils/test_utils.dart';
import '../utils/event_bus_test_utils.dart';
// 모킹 클래스 생성
@GenerateMocks([http.Client, InAppPurchaseService])
@@ -87,16 +85,13 @@ void main() {
}
];
setUp(() {
setUp(() async {
// 테스트 모드 강제 설정
TestUtils.setTestMode(true);
// 테스트별 고유 ID 생성 및 설정
final testId = 'subscription_service_test_${DateTime.now().millisecondsSinceEpoch}';
EventBus.setCurrentTestId(testId);
// EventBus 완전 초기화
EventBus().reset();
await EventBusTestUtils.setUp(testId);
// HTTP 클라이언트 모킹
mockClient = MockClient();
@@ -132,15 +127,12 @@ void main() {
);
});
tearDown(() {
// 테스트 종료 후 EventBus 초기화
EventBus().reset();
tearDown(() async {
// 테스트에서 생성된 SubscriptionService 정리
subscriptionService.dispose();
try { subscriptionService.dispose(); } catch (_) {}
// 테스트 ID 초기화
EventBus.clearCurrentTestId();
// EventBus 표준 정리
await EventBusTestUtils.tearDown();
// 테스트 모드 해제
TestUtils.setTestMode(false);