test: 통합 테스트 오류 및 경고 수정
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

- 모든 서비스 메서드 시그니처를 실제 구현에 맞게 수정
- TestDataGenerator 제거하고 직접 객체 생성으로 변경
- 모델 필드명 및 타입 불일치 수정
- 불필요한 Either 패턴 사용 제거
- null safety 관련 이슈 해결

수정된 파일:
- test/integration/screens/company_integration_test.dart
- test/integration/screens/equipment_integration_test.dart
- test/integration/screens/user_integration_test.dart
- test/integration/screens/login_integration_test.dart
This commit is contained in:
JiWoong Sul
2025-08-05 20:24:05 +09:00
parent d6f34c0a52
commit 198aac6525
145 changed files with 41527 additions and 5220 deletions

View File

@@ -0,0 +1,274 @@
# BaseScreenTest 사용 가이드
## 개요
BaseScreenTest는 모든 화면 테스트의 기본 클래스로, 다음과 같은 기능을 제공합니다:
- ✅ 공통 CRUD 테스트 패턴의 표준화된 구현
- ✅ 에러 자동 진단 및 수정 플로우
- ✅ 테스트 데이터 자동 생성/정리
- ✅ 병렬 테스트 실행을 위한 격리 보장
- ✅ 화면별 특수 기능 테스트를 위한 확장 포인트
## 주요 개선사항
### 1. 자동 재시도 메커니즘
```dart
// 모든 CRUD 작업에 자동 재시도 로직이 적용됩니다
static const int maxRetryAttempts = 3;
static const Duration retryDelay = Duration(seconds: 1);
```
### 2. 에러 자동 수정 플로우
```dart
// 에러 발생 시 자동으로 진단하고 수정을 시도합니다
Future<bool> _handleCrudError(dynamic error, String operation, TestData data)
```
### 3. 병렬 실행 지원
```dart
// 고유한 세션 ID와 리소스 잠금으로 병렬 테스트가 가능합니다
late final String testSessionId;
static final Map<String, Completer<void>> _resourceLocks = {};
```
### 4. 향상된 로깅
```dart
// 모든 작업이 상세히 로깅되며, 리포트에도 자동으로 기록됩니다
void _log(String message)
```
## 구현 방법
### 1. 기본 구조
```dart
class YourScreenTest extends BaseScreenTest {
late YourService yourService;
YourScreenTest({
required ApiClient apiClient,
required GetIt getIt,
// ... 기타 필수 파라미터
}) : super(
apiClient: apiClient,
getIt: getIt,
// ... 부모 클래스에 전달
);
}
```
### 2. 필수 구현 메서드
#### 2.1 메타데이터 정의
```dart
@override
ScreenMetadata getScreenMetadata() {
return ScreenMetadata(
screenName: 'YourScreen',
controllerType: YourService,
relatedEndpoints: [
// API 엔드포인트 목록
],
screenCapabilities: {
'crud': {'create': true, 'read': true, 'update': true, 'delete': true},
'search': {'enabled': true, 'fields': ['name', 'code']},
'filter': {'enabled': true, 'fields': ['status', 'type']},
'pagination': {'enabled': true, 'defaultPerPage': 20},
},
);
}
```
#### 2.2 서비스 초기화
```dart
@override
Future<void> initializeServices() async {
yourService = getIt<YourService>();
}
@override
dynamic getService() => yourService;
@override
String getResourceType() => 'your_resource';
@override
Map<String, dynamic> getDefaultFilters() {
return {'status': 'active'};
}
```
#### 2.3 CRUD 작업 구현
```dart
@override
Future<dynamic> performCreateOperation(TestData data) async {
// 실제 생성 로직
final model = YourModel.fromJson(data.data);
return await yourService.create(model);
}
@override
Future<dynamic> performReadOperation(TestData data) async {
// 실제 읽기 로직
return await yourService.getList(
page: data.data['page'] ?? 1,
perPage: data.data['perPage'] ?? 20,
);
}
@override
Future<dynamic> performUpdateOperation(dynamic resourceId, Map<String, dynamic> updateData) async {
// 실제 업데이트 로직
return await yourService.update(resourceId, updateData);
}
@override
Future<void> performDeleteOperation(dynamic resourceId) async {
// 실제 삭제 로직
await yourService.delete(resourceId);
}
@override
dynamic extractResourceId(dynamic resource) {
// 리소스에서 ID 추출
return resource.id ?? resource['id'];
}
```
### 3. 선택적 구현 메서드
#### 3.1 데이터 검증
```dart
@override
Future<void> validateDataBeforeCreate(TestData data) async {
// 생성 전 데이터 검증 로직
if (data.data['name'] == null) {
throw ValidationError('이름은 필수입니다');
}
}
```
#### 3.2 업데이트 데이터 준비
```dart
@override
Future<Map<String, dynamic>> prepareUpdateData(TestData data, dynamic resourceId) async {
// 기본 구현을 사용하거나 커스터마이즈
final updateData = await super.prepareUpdateData(data, resourceId);
// 추가 로직
return updateData;
}
```
#### 3.3 추가 설정/정리
```dart
@override
Future<void> performAdditionalSetup() async {
// 화면별 추가 설정
}
@override
Future<void> performAdditionalCleanup() async {
// 화면별 추가 정리
}
```
### 4. 커스텀 기능 테스트
```dart
@override
Future<List<TestableFeature>> detectCustomFeatures(ScreenMetadata metadata) async {
final features = <TestableFeature>[];
features.add(TestableFeature(
featureName: 'Custom Feature',
type: FeatureType.custom,
testCases: [
TestCase(
name: 'Custom test case',
execute: (data) async {
// 커스텀 테스트 실행
},
verify: (data) async {
// 커스텀 테스트 검증
},
),
],
));
return features;
}
```
## 자동 에러 처리
### 1. 에러 진단
- API 에러 자동 분석
- 에러 타입 식별 (필수 필드 누락, 잘못된 참조, 권한 오류 등)
- 신뢰도 기반 자동 수정 시도
### 2. 자동 수정 액션
- `updateField`: 필드 값 자동 수정
- `createMissingResource`: 누락된 참조 데이터 자동 생성
- `retryWithDelay`: 지연 후 재시도
### 3. 재시도 로직
- 백오프를 포함한 자동 재시도
- 최대 3회 시도 (설정 가능)
- 점진적 지연 시간 증가
## 병렬 테스트 실행
### 1. 세션 격리
- 각 테스트는 고유한 세션 ID를 가짐
- 리소스 충돌 방지
### 2. 리소스 잠금
```dart
// 필요시 리소스 잠금 사용
await _acquireLock('critical_resource');
try {
// 중요한 작업 수행
} finally {
_releaseLock('critical_resource');
}
```
## 테스트 데이터 관리
### 1. 자동 생성
- TestDataGenerator를 통한 현실적인 테스트 데이터 생성
- 관계 데이터 자동 생성
### 2. 자동 정리
- 테스트 종료 시 생성된 모든 데이터 자동 삭제
- 역순 삭제로 참조 무결성 보장
## 리포트 생성
### 1. 자동 로깅
- 모든 작업이 자동으로 로깅됨
- 성공/실패 상태 추적
### 2. 상세 리포트
- 각 기능별 테스트 결과
- 에러 진단 및 수정 내역
- 성능 메트릭
## 예제
전체 구현 예제는 `example_screen_test.dart` 파일을 참조하세요.
## 주의사항
1. **서비스 메서드 규약**: 서비스는 다음 메서드를 구현해야 합니다:
- `create(model)`
- `getList(page, perPage)`
- `getById(id)`
- `update(id, data)`
- `delete(id)`
- `search(keyword)` (검색 기능 사용 시)
- `getListWithFilters(filters)` (필터 기능 사용 시)
2. **데이터 모델**: TestData의 data 필드는 Map<String, dynamic> 형식입니다.
3. **병렬 실행**: 병렬 테스트 시 리소스 경쟁을 피하기 위해 고유한 데이터를 사용하세요.
4. **에러 처리**: 예상되는 에러는 적절히 처리하고, 예상치 못한 에러만 throw하세요.

View File

@@ -0,0 +1,836 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:get_it/get_it.dart';
import 'package:superport/data/datasources/remote/api_client.dart';
import 'package:superport/models/company_model.dart';
import 'package:superport/models/warehouse_location_model.dart';
import 'package:superport/services/auth_service.dart';
import 'package:superport/data/models/auth/login_request.dart';
import 'package:superport/services/company_service.dart';
import 'package:superport/services/warehouse_service.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/services/license_service.dart';
import 'package:superport/services/user_service.dart';
import '../../framework/core/screen_test_framework.dart';
import '../../framework/models/test_models.dart';
import '../../framework/models/report_models.dart' as report_models;
import '../../framework/models/error_models.dart';
import 'package:dio/dio.dart';
/// 모든 화면 테스트의 기본 클래스
///
/// 이 클래스는 다음과 같은 기능을 제공합니다:
/// - 공통 CRUD 테스트 패턴의 표준화된 구현
/// - 에러 자동 진단 및 수정 플로우
/// - 테스트 데이터 자동 생성/정리
/// - 병렬 테스트 실행을 위한 격리 보장
/// - 화면별 특수 기능 테스트를 위한 확장 포인트
abstract class BaseScreenTest extends ScreenTestFramework {
final ApiClient apiClient;
final GetIt getIt;
// 테스트 격리를 위한 고유 식별자
late final String testSessionId;
// 병렬 실행을 위한 잠금 메커니즘
static final Map<String, Completer<void>> _resourceLocks = {};
// 자동 재시도 설정
static const int maxRetryAttempts = 3;
static const Duration retryDelay = Duration(seconds: 1);
BaseScreenTest({
required this.apiClient,
required this.getIt,
required super.testContext,
required super.errorDiagnostics,
required super.autoFixer,
required super.dataGenerator,
required super.reportCollector,
}) {
// 테스트 세션 ID 생성 (병렬 실행 시 격리 보장)
testSessionId = '${getScreenMetadata().screenName}_${DateTime.now().millisecondsSinceEpoch}';
}
/// 화면 메타데이터 가져오기
ScreenMetadata getScreenMetadata();
/// 서비스 초기화
Future<void> initializeServices();
/// 테스트 환경 설정
Future<void> setupTestEnvironment() async {
_log('테스트 환경 설정 시작 (세션: $testSessionId)');
try {
// 서비스 초기화
await initializeServices();
// 인증 확인 (재시도 로직 포함)
await _retryWithBackoff(
() => _ensureAuthenticated(),
'인증 확인',
);
// 기본 데이터 설정
await _setupBaseData();
// 화면별 추가 설정
await performAdditionalSetup();
_log('테스트 환경 설정 완료');
} catch (e) {
_log('테스트 환경 설정 실패: $e');
throw TestSetupError(
message: '테스트 환경 설정 실패',
details: {'error': e.toString(), 'sessionId': testSessionId},
);
}
}
/// 테스트 환경 정리
Future<void> teardownTestEnvironment() async {
_log('테스트 환경 정리 시작');
try {
// 화면별 추가 정리
await performAdditionalCleanup();
// 생성된 데이터 정리 (역순으로 삭제)
await _cleanupTestData();
// 서비스 정리
await _cleanupServices();
// 잠금 해제
_releaseAllLocks();
_log('테스트 환경 정리 완료');
} catch (e) {
_log('테스트 환경 정리 중 오류 (무시): $e');
}
}
/// 테스트 실행
Future<report_models.TestResult> runTests() async {
final metadata = getScreenMetadata();
testContext.currentScreen = metadata.screenName;
final startTime = DateTime.now();
_log('\n${'=' * 60}');
_log('${metadata.screenName} 테스트 시작');
_log('${'=' * 60}\n');
try {
// 환경 설정
await setupTestEnvironment();
// 기능 감지
final features = await detectFeatures(metadata);
_log('감지된 기능: ${features.map((f) => f.featureName).join(', ')}');
// 테스트 실행
final result = await executeTests(features);
final duration = DateTime.now().difference(startTime);
_log('\n테스트 완료 (소요시간: ${duration.inSeconds}초)');
_log('결과: 총 ${result.totalTests}개, 성공 ${result.passedTests}개, 실패 ${result.failedTests}\n');
return result;
} catch (e, stackTrace) {
_log('테스트 실행 중 치명적 오류: $e');
_log('스택 트레이스: $stackTrace');
// 오류 리포트 생성
return report_models.TestResult(
totalTests: 0,
passedTests: 0,
failedTests: 1,
skippedTests: 0,
failures: [
report_models.TestFailure(
feature: metadata.screenName,
message: '테스트 실행 중 치명적 오류: $e',
stackTrace: stackTrace.toString(),
),
],
);
} finally {
// 환경 정리
await teardownTestEnvironment();
}
}
/// 인증 확인
Future<void> _ensureAuthenticated() async {
try {
final authService = getIt.get<AuthService>();
final isAuthenticated = await authService.isLoggedIn();
if (!isAuthenticated) {
// 로그인 시도
final loginRequest = LoginRequest(
email: testContext.getConfig('testEmail') ?? 'admin@superport.kr',
password: testContext.getConfig('testPassword') ?? 'admin123!',
);
await authService.login(loginRequest);
}
} catch (e) {
throw TestError(
message: '인증 실패: $e',
timestamp: DateTime.now(),
feature: 'Authentication',
);
}
}
/// 기본 데이터 설정
Future<void> _setupBaseData() async {
// 회사 데이터 확인/생성
await _ensureCompanyExists();
// 창고 데이터 확인/생성
await _ensureWarehouseExists();
}
/// 회사 데이터 확인/생성
Future<void> _ensureCompanyExists() async {
try {
final companyService = getIt.get<CompanyService>();
final companies = await companyService.getCompanies(page: 1, perPage: 1);
if (companies.isEmpty) {
// 테스트용 회사 생성
final companyData = await dataGenerator.generate(
GenerationStrategy(
dataType: Company,
fields: [],
relationships: [],
constraints: {},
),
);
final company = await companyService.createCompany(companyData.data);
testContext.setData('testCompanyId', company.id);
} else {
testContext.setData('testCompanyId', companies.first.id);
}
} catch (e) {
// 회사 생성은 선택사항이므로 에러 무시
print('회사 데이터 설정 실패: $e');
}
}
/// 창고 데이터 확인/생성
Future<void> _ensureWarehouseExists() async {
try {
final warehouseService = getIt.get<WarehouseService>();
final companyId = testContext.getData('testCompanyId');
if (companyId != null) {
final warehouses = await warehouseService.getWarehouseLocations(
page: 1,
perPage: 1,
);
if (warehouses.isEmpty) {
// 테스트용 창고 생성
final warehouseData = await dataGenerator.generate(
GenerationStrategy(
dataType: WarehouseLocation,
fields: [],
relationships: [],
constraints: {},
),
);
warehouseData.data['company_id'] = companyId;
final warehouse = await warehouseService.createWarehouseLocation(warehouseData.data);
testContext.setData('testWarehouseId', warehouse.id);
} else {
testContext.setData('testWarehouseId', warehouses.first.id);
}
}
} catch (e) {
// 창고 생성은 선택사항이므로 에러 무시
print('창고 데이터 설정 실패: $e');
}
}
/// 테스트 데이터 정리
Future<void> _cleanupTestData() async {
final createdIds = testContext.getAllCreatedResourceIds();
final resourcesByType = <String, List<String>>{};
// createdIds를 resourceType별로 분류
for (final id in createdIds) {
final parts = id.split(':');
if (parts.length == 2) {
final resourceType = parts[0];
final resourceId = parts[1];
resourcesByType.putIfAbsent(resourceType, () => []).add(resourceId);
}
}
for (final entry in resourcesByType.entries) {
final resourceType = entry.key;
final ids = entry.value;
for (final id in ids) {
try {
await _deleteResource(resourceType, id);
} catch (e) {
// 삭제 실패는 무시
print('리소스 삭제 실패: $resourceType/$id - $e');
}
}
}
}
/// 리소스 삭제
Future<void> _deleteResource(String resourceType, String id) async {
switch (resourceType) {
case 'equipment':
final service = getIt.get<EquipmentService>();
await service.deleteEquipment(int.parse(id));
break;
case 'license':
final service = getIt.get<LicenseService>();
await service.deleteLicense(int.parse(id));
break;
case 'user':
final service = getIt.get<UserService>();
await service.deleteUser(int.parse(id));
break;
case 'warehouse':
final service = getIt.get<WarehouseService>();
await service.deleteWarehouseLocation(int.parse(id));
break;
case 'company':
final service = getIt.get<CompanyService>();
await service.deleteCompany(int.parse(id));
break;
}
}
/// 서비스 정리
Future<void> _cleanupServices() async {
// 필요시 서비스 정리 로직 추가
}
/// 공통 CRUD 작업 구현 - Create
@override
Future<void> performCreate(TestData data) async {
_log('[CREATE] 시작: ${getResourceType()}');
try {
// 생성 전 데이터 검증
await validateDataBeforeCreate(data);
// 서비스 호출 (재시도 로직 포함)
final result = await _retryWithBackoff(
() => performCreateOperation(data),
'CREATE 작업',
);
// 생성된 리소스 ID 저장
final resourceId = extractResourceId(result);
testContext.addCreatedResourceId(getResourceType(), resourceId.toString());
testContext.setData('lastCreatedId', resourceId);
testContext.setData('lastCreatedResource', result);
_log('[CREATE] 성공: ID=$resourceId');
} catch (e) {
_log('[CREATE] 실패: $e');
// 에러 자동 진단 및 수정 시도
final fixed = await _handleCrudError(e, 'CREATE', data);
if (!fixed) {
rethrow;
}
}
}
@override
Future<void> verifyCreate(TestData data) async {
final lastCreatedId = testContext.getData('lastCreatedId');
expect(lastCreatedId, isNotNull, reason: '리소스 생성 실패');
// 생성된 리소스 조회하여 검증
final service = getService();
final result = await service.getById(lastCreatedId);
expect(result, isNotNull, reason: '생성된 리소스를 찾을 수 없음');
}
@override
Future<void> performRead(TestData data) async {
_log('[READ] 시작: ${getResourceType()}');
try {
// 읽기 작업 수행 (재시도 로직 포함)
final results = await _retryWithBackoff(
() => performReadOperation(data),
'READ 작업',
);
testContext.setData('readResults', results);
testContext.setData('readCount', results is List ? results.length : 1);
_log('[READ] 성공: ${results is List ? results.length : 1}개 항목');
} catch (e) {
_log('[READ] 실패: $e');
// 에러 자동 진단 및 수정 시도
final fixed = await _handleCrudError(e, 'READ', data);
if (!fixed) {
rethrow;
}
}
}
@override
Future<void> verifyRead(TestData data) async {
final readResults = testContext.getData('readResults');
expect(readResults, isNotNull, reason: '목록 조회 실패');
expect(readResults, isA<List>(), reason: '올바른 목록 형식이 아님');
}
@override
Future<void> performUpdate(TestData data) async {
_log('[UPDATE] 시작: ${getResourceType()}');
try {
// 업데이트할 리소스 확보
final resourceId = await _ensureResourceForUpdate(data);
// 업데이트 데이터 준비
final updateData = await prepareUpdateData(data, resourceId);
// 업데이트 수행 (재시도 로직 포함)
final result = await _retryWithBackoff(
() => performUpdateOperation(resourceId, updateData),
'UPDATE 작업',
);
testContext.setData('updateResult', result);
testContext.setData('lastUpdatedId', resourceId);
_log('[UPDATE] 성공: ID=$resourceId');
} catch (e) {
_log('[UPDATE] 실패: $e');
// 에러 자동 진단 및 수정 시도
final fixed = await _handleCrudError(e, 'UPDATE', data);
if (!fixed) {
rethrow;
}
}
}
@override
Future<void> verifyUpdate(TestData data) async {
final updateResult = testContext.getData('updateResult');
expect(updateResult, isNotNull, reason: '업데이트 실패');
// 업데이트된 내용 확인
final lastCreatedId = testContext.getData('lastCreatedId');
final service = getService();
final result = await service.getById(lastCreatedId);
expect(result.name, contains('Updated'), reason: '업데이트가 반영되지 않음');
}
@override
Future<void> performDelete(TestData data) async {
_log('[DELETE] 시작: ${getResourceType()}');
try {
// 삭제할 리소스 확보
final resourceId = await _ensureResourceForDelete(data);
// 삭제 수행 (재시도 로직 포함)
await _retryWithBackoff(
() => performDeleteOperation(resourceId),
'DELETE 작업',
);
testContext.setData('deleteCompleted', true);
testContext.setData('lastDeletedId', resourceId);
// 생성된 리소스 목록에서 제거
testContext.removeCreatedResourceId(getResourceType(), resourceId.toString());
_log('[DELETE] 성공: ID=$resourceId');
} catch (e) {
_log('[DELETE] 실패: $e');
// 에러 자동 진단 및 수정 시도
final fixed = await _handleCrudError(e, 'DELETE', data);
if (!fixed) {
rethrow;
}
}
}
@override
Future<void> verifyDelete(TestData data) async {
final deleteCompleted = testContext.getData('deleteCompleted');
expect(deleteCompleted, isTrue, reason: '삭제 작업이 완료되지 않음');
// 삭제된 리소스 조회 시도
final lastCreatedId = testContext.getData('lastCreatedId');
final service = getService();
try {
await service.getById(lastCreatedId);
fail('삭제된 리소스가 여전히 존재함');
} catch (e) {
// 예상된 에러 - 리소스를 찾을 수 없음
}
}
@override
Future<void> performSearch(TestData data) async {
// 검색할 데이터 먼저 생성
await performCreate(data);
final service = getService();
final searchKeyword = data.data['name']?.toString().split(' ').first ?? 'test';
final results = await service.search(searchKeyword);
testContext.setData('searchResults', results);
testContext.setData('searchKeyword', searchKeyword);
}
@override
Future<void> verifySearch(TestData data) async {
final searchResults = testContext.getData('searchResults');
final searchKeyword = testContext.getData('searchKeyword');
expect(searchResults, isNotNull, reason: '검색 결과가 없음');
expect(searchResults, isA<List>(), reason: '올바른 검색 결과 형식이 아님');
if (searchResults.isNotEmpty) {
// 검색 결과가 키워드를 포함하는지 확인
final firstResult = searchResults.first;
expect(
firstResult.toString().toLowerCase(),
contains(searchKeyword.toLowerCase()),
reason: '검색 결과가 키워드를 포함하지 않음',
);
}
}
@override
Future<void> performFilter(TestData data) async {
final service = getService();
// 필터 조건 설정
final filters = getDefaultFilters();
final results = await service.getListWithFilters(filters);
testContext.setData('filterResults', results);
testContext.setData('appliedFilters', filters);
}
@override
Future<void> verifyFilter(TestData data) async {
final filterResults = testContext.getData('filterResults');
expect(filterResults, isNotNull, reason: '필터 결과가 없음');
expect(filterResults, isA<List>(), reason: '올바른 필터 결과 형식이 아님');
}
@override
Future<void> performPagination(TestData data) async {
final service = getService();
// 첫 페이지 조회
final page1 = await service.getList(page: 1, perPage: 5);
testContext.setData('page1Results', page1);
// 두 번째 페이지 조회
final page2 = await service.getList(page: 2, perPage: 5);
testContext.setData('page2Results', page2);
}
@override
Future<void> verifyPagination(TestData data) async {
final page1Results = testContext.getData('page1Results');
final page2Results = testContext.getData('page2Results');
expect(page1Results, isNotNull, reason: '첫 페이지 결과가 없음');
expect(page2Results, isNotNull, reason: '두 번째 페이지 결과가 없음');
// 페이지별 결과가 다른지 확인 (데이터가 충분한 경우)
if (page1Results.isNotEmpty && page2Results.isNotEmpty) {
expect(
page1Results.first.id != page2Results.first.id,
isTrue,
reason: '페이지네이션이 올바르게 작동하지 않음',
);
}
}
// ===== 하위 클래스에서 구현해야 할 추상 메서드들 =====
/// 서비스 인스턴스 가져오기
dynamic getService();
/// 리소스 타입 가져오기
String getResourceType();
/// 기본 필터 설정 가져오기
Map<String, dynamic> getDefaultFilters();
// ===== CRUD 작업 구현을 위한 추상 메서드들 =====
/// 실제 생성 작업 수행
Future<dynamic> performCreateOperation(TestData data);
/// 실제 읽기 작업 수행
Future<dynamic> performReadOperation(TestData data);
/// 실제 업데이트 작업 수행
Future<dynamic> performUpdateOperation(dynamic resourceId, Map<String, dynamic> updateData);
/// 실제 삭제 작업 수행
Future<void> performDeleteOperation(dynamic resourceId);
/// 생성된 객체에서 ID 추출
dynamic extractResourceId(dynamic resource);
// ===== 선택적 구현 메서드들 (기본 구현 제공) =====
/// 생성 전 데이터 검증
Future<void> validateDataBeforeCreate(TestData data) async {
// 기본적으로 검증 없음, 필요시 오버라이드
}
/// 업데이트 데이터 준비
Future<Map<String, dynamic>> prepareUpdateData(TestData data, dynamic resourceId) async {
// 기본 구현: 이름에 'Updated' 추가
final updateData = Map<String, dynamic>.from(data.data);
if (updateData.containsKey('name')) {
updateData['name'] = '${updateData['name']} - Updated';
}
return updateData;
}
/// 추가 설정 수행 (setupTestEnvironment에서 호출)
Future<void> performAdditionalSetup() async {
// 기본적으로 추가 설정 없음, 필요시 오버라이드
}
/// 추가 정리 수행 (teardownTestEnvironment에서 호출)
Future<void> performAdditionalCleanup() async {
// 기본적으로 추가 정리 없음, 필요시 오버라이드
}
// ===== 에러 처리 및 자동 수정 메서드들 =====
/// CRUD 작업 중 발생한 에러 처리
Future<bool> _handleCrudError(dynamic error, String operation, TestData data) async {
_log('에러 자동 처리 시작: $operation');
try {
// DioException으로 변환
final dioError = _convertToDioException(error);
// API 에러로 변환
final apiError = ApiError(
originalError: dioError,
requestUrl: dioError.requestOptions.path,
requestMethod: dioError.requestOptions.method,
statusCode: dioError.response?.statusCode,
message: error.toString(),
requestBody: data.data,
timestamp: DateTime.now(),
);
// 에러 진단
final diagnosis = await errorDiagnostics.diagnose(apiError);
_log('진단 결과: ${diagnosis.errorType} - ${diagnosis.description}');
// 자동 수정 시도
if (diagnosis.confidence > 0.7) {
final fixResult = await autoFixer.attemptAutoFix(diagnosis);
if (fixResult.success) {
_log('자동 수정 성공: ${fixResult.executedActions.length}개 액션 적용');
// 수정 액션 적용
for (final action in fixResult.executedActions) {
await _applyFixAction(action, data);
}
return true;
} else {
_log('자동 수정 실패: $fixResult.error');
}
}
} catch (e) {
_log('에러 처리 중 예외 발생: $e');
}
return false;
}
/// 수정 액션 적용
Future<void> _applyFixAction(FixAction action, TestData data) async {
switch (action.type) {
case FixActionType.updateField:
final field = action.parameters['field'] as String?;
final value = action.parameters['value'];
if (field != null && value != null) {
data.data[field] = value;
_log('필드 업데이트: $field = $value');
}
break;
case FixActionType.createMissingResource:
final resourceType = action.parameters['resourceType'] as String?;
if (resourceType != null) {
await _createMissingResource(resourceType, action.parameters);
}
break;
case FixActionType.retryWithDelay:
final delay = action.parameters['delay'] as int? ?? 1000;
await Future.delayed(Duration(milliseconds: delay));
_log('${delay}ms 대기 후 재시도');
break;
default:
_log('알 수 없는 수정 액션: $action.type');
}
}
/// 누락된 리소스 생성
Future<void> _createMissingResource(String resourceType, Map<String, dynamic> metadata) async {
_log('누락된 리소스 자동 생성: $resourceType');
switch (resourceType.toLowerCase()) {
case 'company':
await _ensureCompanyExists();
break;
case 'warehouse':
await _ensureCompanyExists();
await _ensureWarehouseExists();
break;
default:
_log('자동 생성을 지원하지 않는 리소스 타입: $resourceType');
}
}
/// 일반 에러를 DioException으로 변환
DioException _convertToDioException(dynamic error) {
if (error is DioException) {
return error;
}
return DioException(
requestOptions: RequestOptions(
path: '/api/v1/${getResourceType()}',
method: 'POST',
),
message: error.toString(),
type: DioExceptionType.unknown,
);
}
// ===== 재시도 및 병렬 실행 지원 메서드들 =====
/// 백오프를 포함한 재시도 로직
Future<T> _retryWithBackoff<T>(
Future<T> Function() operation,
String operationName,
) async {
int attempt = 0;
dynamic lastError;
while (attempt < maxRetryAttempts) {
try {
return await operation();
} catch (e) {
lastError = e;
attempt++;
if (attempt < maxRetryAttempts) {
final delay = retryDelay * attempt;
_log('$operationName 실패 (시도 $attempt/$maxRetryAttempts), ${delay.inSeconds}초 후 재시도...');
await Future.delayed(delay);
}
}
}
_log('$operationName 최종 실패 ($maxRetryAttempts회 시도)');
throw lastError;
}
/// 모든 잠금 해제
void _releaseAllLocks() {
for (final entry in _resourceLocks.entries) {
if (entry.key.contains(testSessionId)) {
entry.value.complete();
}
}
_resourceLocks.removeWhere((key, _) => key.contains(testSessionId));
}
// ===== 헬퍼 메서드들 =====
/// 업데이트를 위한 리소스 확보
Future<dynamic> _ensureResourceForUpdate(TestData data) async {
var resourceId = testContext.getData('lastCreatedId');
if (resourceId == null) {
_log('업데이트할 리소스가 없어 새로 생성');
await performCreate(data);
resourceId = testContext.getData('lastCreatedId');
}
return resourceId;
}
/// 삭제를 위한 리소스 확보
Future<dynamic> _ensureResourceForDelete(TestData data) async {
var resourceId = testContext.getData('lastCreatedId');
if (resourceId == null) {
_log('삭제할 리소스가 없어 새로 생성');
await performCreate(data);
resourceId = testContext.getData('lastCreatedId');
}
return resourceId;
}
/// 로깅 메서드
void _log(String message) {
final screenName = getScreenMetadata().screenName;
// 리포트 수집기에 로그 추가 (print 대신 사용)
reportCollector.addStep(
report_models.StepReport(
stepName: screenName,
timestamp: DateTime.now(),
success: !message.contains('실패') && !message.contains('에러'),
message: message,
details: {'sessionId': testSessionId},
),
);
}
}
/// 테스트 설정 오류
class TestSetupError implements Exception {
final String message;
final Map<String, dynamic> details;
TestSetupError({
required this.message,
required this.details,
});
@override
String toString() => 'TestSetupError: $message ($details)';
}

View File

@@ -0,0 +1,366 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:superport/services/equipment_service.dart';
import 'package:superport/models/equipment_unified_model.dart';
import 'base_screen_test.dart';
import '../../framework/models/test_models.dart';
/// BaseScreenTest를 상속받아 구현하는 예제
///
/// 이 예제는 Equipment 화면 테스트를 구현하는 방법을 보여줍니다.
/// 다른 화면들도 이와 유사한 방식으로 구현할 수 있습니다.
class ExampleEquipmentScreenTest extends BaseScreenTest {
late EquipmentService equipmentService;
ExampleEquipmentScreenTest({
required super.apiClient,
required super.getIt,
required super.testContext,
required super.errorDiagnostics,
required super.autoFixer,
required super.dataGenerator,
required super.reportCollector,
});
// ===== 필수 구현 메서드들 =====
@override
ScreenMetadata getScreenMetadata() {
return ScreenMetadata(
screenName: 'EquipmentListScreen',
controllerType: EquipmentService,
relatedEndpoints: [
ApiEndpoint(
path: '/api/v1/equipment',
method: 'GET',
description: '장비 목록 조회',
),
ApiEndpoint(
path: '/api/v1/equipment',
method: 'POST',
description: '장비 생성',
),
ApiEndpoint(
path: '/api/v1/equipment/{id}',
method: 'PUT',
description: '장비 수정',
),
ApiEndpoint(
path: '/api/v1/equipment/{id}',
method: 'DELETE',
description: '장비 삭제',
),
],
screenCapabilities: {
'crud': {
'create': true,
'read': true,
'update': true,
'delete': true,
},
'search': {
'enabled': true,
'fields': ['name', 'serialNumber', 'manufacturer'],
},
'filter': {
'enabled': true,
'fields': ['status', 'category', 'location'],
},
'pagination': {
'enabled': true,
'defaultPerPage': 20,
},
},
);
}
@override
Future<void> initializeServices() async {
equipmentService = getIt<EquipmentService>();
}
@override
dynamic getService() => equipmentService;
@override
String getResourceType() => 'equipment';
@override
Map<String, dynamic> getDefaultFilters() {
return {
'status': 'active',
'category': 'all',
};
}
// ===== CRUD 작업 구현 =====
@override
Future<dynamic> performCreateOperation(TestData data) async {
// TestData에서 Equipment 객체로 변환
final equipmentData = data.data;
final equipment = Equipment(
manufacturer: equipmentData['manufacturer'] ?? 'Unknown',
name: equipmentData['name'] ?? 'Test Equipment',
category: equipmentData['category'] ?? '미분류',
subCategory: equipmentData['subCategory'] ?? '',
subSubCategory: equipmentData['subSubCategory'] ?? '',
serialNumber: equipmentData['serialNumber'] ?? 'SN-${DateTime.now().millisecondsSinceEpoch}',
quantity: equipmentData['quantity'] ?? 1,
inDate: equipmentData['inDate'] ?? DateTime.now().toIso8601String(),
remark: equipmentData['remark'],
);
return await equipmentService.createEquipment(equipment);
}
@override
Future<dynamic> performReadOperation(TestData data) async {
// 페이지네이션 파라미터 사용
final page = data.data['page'] ?? 1;
final perPage = data.data['perPage'] ?? 20;
return await equipmentService.getEquipments(
page: page,
perPage: perPage,
);
}
@override
Future<dynamic> performUpdateOperation(dynamic resourceId, Map<String, dynamic> updateData) async {
// 기존 장비 조회
final existing = await equipmentService.getEquipment(resourceId);
// 업데이트할 Equipment 객체 생성
final updated = Equipment(
id: existing.id,
manufacturer: updateData['manufacturer'] ?? existing.manufacturer,
name: updateData['name'] ?? existing.name,
category: updateData['category'] ?? existing.category,
subCategory: updateData['subCategory'] ?? existing.subCategory,
subSubCategory: updateData['subSubCategory'] ?? existing.subSubCategory,
serialNumber: updateData['serialNumber'] ?? existing.serialNumber,
quantity: updateData['quantity'] ?? existing.quantity,
inDate: updateData['inDate'] ?? existing.inDate,
remark: updateData['remark'] ?? existing.remark,
);
return await equipmentService.updateEquipment(resourceId, updated);
}
@override
Future<void> performDeleteOperation(dynamic resourceId) async {
await equipmentService.deleteEquipment(resourceId);
}
@override
dynamic extractResourceId(dynamic resource) {
if (resource is Equipment) {
return resource.id;
}
return resource['id'] ?? resource.id;
}
// ===== 선택적 구현 메서드들 (필요시 오버라이드) =====
@override
Future<void> validateDataBeforeCreate(TestData data) async {
// 장비 생성 전 데이터 검증
final equipmentData = data.data;
// 필수 필드 검증
if (equipmentData['manufacturer'] == null || equipmentData['manufacturer'].isEmpty) {
throw ValidationError('제조사는 필수 입력 항목입니다');
}
if (equipmentData['name'] == null || equipmentData['name'].isEmpty) {
throw ValidationError('장비명은 필수 입력 항목입니다');
}
// 시리얼 번호 형식 검증
final serialNumber = equipmentData['serialNumber'];
if (serialNumber != null && !RegExp(r'^[A-Z0-9\-]+$').hasMatch(serialNumber)) {
throw ValidationError('시리얼 번호는 영문 대문자, 숫자, 하이픈만 사용 가능합니다');
}
}
@override
Future<Map<String, dynamic>> prepareUpdateData(TestData data, dynamic resourceId) async {
// 기본 구현에 추가로 장비별 특수 로직 적용
final updateData = await super.prepareUpdateData(data, resourceId);
// 장비 상태 업데이트 시 이력 추가
if (updateData.containsKey('status')) {
updateData['statusChangeReason'] = '테스트 상태 변경';
updateData['statusChangedAt'] = DateTime.now().toIso8601String();
}
return updateData;
}
@override
Future<void> performAdditionalSetup() async {
// 장비 테스트를 위한 추가 설정
_log('장비 테스트용 카테고리 마스터 데이터 확인');
// 필요한 경우 카테고리 마스터 데이터 생성
// await _ensureCategoryMasterData();
}
@override
Future<void> performAdditionalCleanup() async {
// 장비 테스트 후 추가 정리
_log('장비 관련 임시 파일 정리');
// 테스트 중 생성된 임시 파일이나 캐시 정리
// await _cleanupTempFiles();
}
// ===== 커스텀 기능 테스트 =====
@override
Future<List<TestableFeature>> detectCustomFeatures(ScreenMetadata metadata) async {
final features = <TestableFeature>[];
// 장비 입출고 기능 테스트
features.add(TestableFeature(
featureName: 'Equipment In/Out',
type: FeatureType.custom,
testCases: [
TestCase(
name: 'Equipment check-in',
execute: (data) async {
await performEquipmentCheckIn(data);
},
verify: (data) async {
await verifyEquipmentCheckIn(data);
},
),
TestCase(
name: 'Equipment check-out',
execute: (data) async {
await performEquipmentCheckOut(data);
},
verify: (data) async {
await verifyEquipmentCheckOut(data);
},
),
],
metadata: {
'description': '장비 입출고 프로세스 테스트',
},
));
// 장비 이력 조회 기능 테스트
features.add(TestableFeature(
featureName: 'Equipment History',
type: FeatureType.custom,
testCases: [
TestCase(
name: 'View equipment history',
execute: (data) async {
await performViewHistory(data);
},
verify: (data) async {
await verifyViewHistory(data);
},
),
],
metadata: {
'description': '장비 이력 조회 테스트',
},
));
return features;
}
// 장비 입고 테스트
Future<void> performEquipmentCheckIn(TestData data) async {
// 먼저 장비 생성
await performCreate(data);
final equipmentId = testContext.getData('lastCreatedId');
// 입고 처리
final checkInResult = await equipmentService.equipmentIn(
equipmentId: equipmentId,
quantity: 1,
warehouseLocationId: testContext.getData('testWarehouseId') ?? 1,
notes: '테스트 입고',
);
testContext.setData('checkInResult', checkInResult);
}
Future<void> verifyEquipmentCheckIn(TestData data) async {
final checkInResult = testContext.getData('checkInResult');
expect(checkInResult, isNotNull, reason: '장비 입고 실패');
expect(checkInResult.success, isTrue, reason: '입고 처리가 성공하지 못했습니다');
}
// 장비 출고 테스트
Future<void> performEquipmentCheckOut(TestData data) async {
// 입고된 장비가 있는지 확인
final equipmentId = testContext.getData('lastCreatedId');
if (equipmentId == null) {
await performEquipmentCheckIn(data);
}
// 출고 처리
final checkOutResult = await equipmentService.equipmentOut(
equipmentId: equipmentId,
quantity: 1,
companyId: testContext.getData('testCompanyId') ?? 1,
notes: '테스트 출고',
);
testContext.setData('checkOutResult', checkOutResult);
}
Future<void> verifyEquipmentCheckOut(TestData data) async {
final checkOutResult = testContext.getData('checkOutResult');
expect(checkOutResult, isNotNull, reason: '장비 출고 실패');
expect(checkOutResult.success, isTrue, reason: '출고 처리가 성공하지 못했습니다');
}
// 장비 이력 조회 테스트
Future<void> performViewHistory(TestData data) async {
final equipmentId = testContext.getData('lastCreatedId');
if (equipmentId == null) {
await performCreate(data);
}
final history = await equipmentService.getEquipmentHistory(equipmentId);
testContext.setData('equipmentHistory', history);
}
Future<void> verifyViewHistory(TestData data) async {
final history = testContext.getData('equipmentHistory');
expect(history, isNotNull, reason: '장비 이력 조회 실패');
expect(history, isA<List>(), reason: '이력이 리스트 형식이 아닙니다');
}
// 로깅을 위한 헬퍼 메서드
void _log(String message) {
print('[ExampleEquipmentScreenTest] $message');
}
}
/// 검증 오류
class ValidationError implements Exception {
final String message;
ValidationError(this.message);
@override
String toString() => 'ValidationError: $message';
}
// 테스트 실행 예제
void main() {
group('Example Equipment Screen Test', () {
test('BaseScreenTest를 상속받아 구현하는 방법 예제', () {
// 이것은 예제 구현입니다.
// 실제 테스트는 프레임워크를 통해 실행됩니다.
expect(true, isTrue);
});
});
}