Files
superport/test/integration/automated/framework/testable_action.dart
JiWoong Sul 731dcd816b
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
refactor: Repository 패턴 적용 및 Clean Architecture 완성
## 주요 변경사항

### 🏗️ Architecture
- Repository 패턴 전면 도입 (인터페이스/구현체 분리)
- Domain Layer에 Repository 인터페이스 정의
- Data Layer에 Repository 구현체 배치
- UseCase 의존성을 Service에서 Repository로 전환

### 📦 Dependency Injection
- GetIt 기반 DI Container 재구성 (lib/injection_container.dart)
- Repository 인터페이스와 구현체 등록
- Service와 Repository 공존 (마이그레이션 기간)

### 🔄 Migration Status
완료:
- License 모듈 (6개 UseCase)
- Warehouse Location 모듈 (5개 UseCase)

진행중:
- Auth 모듈 (2/5 UseCase)
- Company 모듈 (1/6 UseCase)

대기:
- User 모듈 (7개 UseCase)
- Equipment 모듈 (4개 UseCase)

### 🎯 Controller 통합
- 중복 Controller 제거 (with_usecase 버전)
- 단일 Controller로 통합
- UseCase 패턴 직접 적용

### 🧹 코드 정리
- 임시 파일 제거 (test_*.md, task.md)
- Node.js 아티팩트 제거 (package.json)
- 불필요한 테스트 파일 정리

###  테스트 개선
- Real API 중심 테스트 구조
- Mock 제거, 실제 API 엔드포인트 사용
- 통합 테스트 프레임워크 강화

## 기술적 영향
- 의존성 역전 원칙 적용
- 레이어 간 결합도 감소
- 테스트 용이성 향상
- 확장성 및 유지보수성 개선

## 다음 단계
1. User/Equipment 모듈 Repository 마이그레이션
2. Service Layer 점진적 제거
3. 캐싱 전략 구현
4. 성능 최적화
2025-08-11 20:14:10 +09:00

531 lines
13 KiB
Dart

import 'package:flutter_test/flutter_test.dart';
/// 테스트 가능한 액션의 기본 인터페이스
abstract class TestableAction {
/// 액션 이름
String get name;
/// 액션 설명
String get description;
/// 액션 실행 전 조건 검증
Future<bool> canExecute(WidgetTester tester);
/// 액션 실행
Future<ActionResult> execute(WidgetTester tester);
/// 액션 실행 후 검증
Future<bool> verify(WidgetTester tester);
/// 에러 발생 시 복구 시도
Future<bool> recover(WidgetTester tester, dynamic error);
}
/// 액션 실행 결과
class ActionResult {
final bool success;
final String? message;
final dynamic data;
final Duration executionTime;
final Map<String, dynamic>? metrics;
final dynamic error;
final StackTrace? stackTrace;
ActionResult({
required this.success,
this.message,
this.data,
required this.executionTime,
this.metrics,
this.error,
this.stackTrace,
});
factory ActionResult.success({
String? message,
dynamic data,
required Duration executionTime,
Map<String, dynamic>? metrics,
}) {
return ActionResult(
success: true,
message: message,
data: data,
executionTime: executionTime,
metrics: metrics,
);
}
factory ActionResult.failure({
required String message,
required Duration executionTime,
dynamic error,
StackTrace? stackTrace,
}) {
return ActionResult(
success: false,
message: message,
executionTime: executionTime,
error: error,
stackTrace: stackTrace,
);
}
}
/// 기본 테스트 액션 구현
abstract class BaseTestableAction implements TestableAction {
@override
Future<bool> canExecute(WidgetTester tester) async {
// 기본적으로 항상 실행 가능
return true;
}
@override
Future<bool> verify(WidgetTester tester) async {
// 기본 검증은 성공으로 가정
return true;
}
@override
Future<bool> recover(WidgetTester tester, dynamic error) async {
// 기본 복구는 실패로 가정
return false;
}
}
/// 탭 액션
class TapAction extends BaseTestableAction {
final Finder finder;
final String targetName;
TapAction({
required this.finder,
required this.targetName,
});
@override
String get name => 'Tap $targetName';
@override
String get description => 'Tap on $targetName';
@override
Future<bool> canExecute(WidgetTester tester) async {
return finder.evaluate().items.isNotEmpty;
}
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
try {
await tester.tap(finder);
await tester.pump();
return ActionResult.success(
message: 'Successfully tapped $targetName',
executionTime: stopwatch.elapsed,
);
} catch (e, stack) {
return ActionResult.failure(
message: 'Failed to tap $targetName: $e',
executionTime: stopwatch.elapsed,
error: e,
stackTrace: stack,
);
}
}
}
/// 텍스트 입력 액션
class EnterTextAction extends BaseTestableAction {
final Finder finder;
final String text;
final String fieldName;
EnterTextAction({
required this.finder,
required this.text,
required this.fieldName,
});
@override
String get name => 'Enter text in $fieldName';
@override
String get description => 'Enter "$text" in $fieldName field';
@override
Future<bool> canExecute(WidgetTester tester) async {
return finder.evaluate().items.isNotEmpty;
}
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
try {
await tester.enterText(finder, text);
await tester.pump();
return ActionResult.success(
message: 'Successfully entered text in $fieldName',
executionTime: stopwatch.elapsed,
);
} catch (e, stack) {
return ActionResult.failure(
message: 'Failed to enter text in $fieldName: $e',
executionTime: stopwatch.elapsed,
error: e,
stackTrace: stack,
);
}
}
}
/// 대기 액션
class WaitAction extends BaseTestableAction {
final Duration duration;
final String? reason;
WaitAction({
required this.duration,
this.reason,
});
@override
String get name => 'Wait ${duration.inMilliseconds}ms';
@override
String get description => reason ?? 'Wait for ${duration.inMilliseconds}ms';
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
try {
await tester.pump(duration);
return ActionResult.success(
message: 'Waited for ${duration.inMilliseconds}ms',
executionTime: stopwatch.elapsed,
);
} catch (e, stack) {
return ActionResult.failure(
message: 'Failed to wait: $e',
executionTime: stopwatch.elapsed,
error: e,
stackTrace: stack,
);
}
}
}
/// 스크롤 액션
class ScrollAction extends BaseTestableAction {
final Finder scrollable;
final Finder? target;
final Offset offset;
final int maxAttempts;
ScrollAction({
required this.scrollable,
this.target,
this.offset = const Offset(0, -300),
this.maxAttempts = 10,
});
@override
String get name => 'Scroll';
@override
String get description => target != null
? 'Scroll to find target widget'
: 'Scroll by offset ${offset.dx}, ${offset.dy}';
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
try {
if (target != null) {
// 타겟을 찾을 때까지 스크롤
for (int i = 0; i < maxAttempts; i++) {
if (target!.evaluate().items.isNotEmpty) {
return ActionResult.success(
message: 'Found target after $i scrolls',
executionTime: stopwatch.elapsed,
);
}
await tester.drag(scrollable, offset);
await tester.pump();
}
return ActionResult.failure(
message: 'Target not found after $maxAttempts scrolls',
executionTime: stopwatch.elapsed,
);
} else {
// 단순 스크롤
await tester.drag(scrollable, offset);
await tester.pump();
return ActionResult.success(
message: 'Scrolled by offset',
executionTime: stopwatch.elapsed,
);
}
} catch (e, stack) {
return ActionResult.failure(
message: 'Failed to scroll: $e',
executionTime: stopwatch.elapsed,
error: e,
stackTrace: stack,
);
}
}
}
/// 검증 액션
class VerifyAction extends BaseTestableAction {
final Future<bool> Function(WidgetTester) verifyFunction;
final String verificationName;
VerifyAction({
required this.verifyFunction,
required this.verificationName,
});
@override
String get name => 'Verify $verificationName';
@override
String get description => 'Verify that $verificationName';
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
try {
final result = await verifyFunction(tester);
if (result) {
return ActionResult.success(
message: 'Verification passed: $verificationName',
executionTime: stopwatch.elapsed,
);
} else {
return ActionResult.failure(
message: 'Verification failed: $verificationName',
executionTime: stopwatch.elapsed,
);
}
} catch (e, stack) {
return ActionResult.failure(
message: 'Verification error: $e',
executionTime: stopwatch.elapsed,
error: e,
stackTrace: stack,
);
}
}
}
/// 복합 액션 (여러 액션을 순차적으로 실행)
class CompositeAction extends BaseTestableAction {
final List<TestableAction> actions;
final String compositeName;
final bool stopOnFailure;
CompositeAction({
required this.actions,
required this.compositeName,
this.stopOnFailure = true,
});
@override
String get name => compositeName;
@override
String get description => 'Execute ${actions.items.length} actions for $compositeName';
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
final results = <ActionResult>[];
for (final action in actions) {
if (!await action.canExecute(tester)) {
if (stopOnFailure) {
return ActionResult.failure(
message: 'Cannot execute action: ${action.name}',
executionTime: stopwatch.elapsed,
);
}
continue;
}
final result = await action.call(tester);
results.add(result);
if (!result.success && stopOnFailure) {
return ActionResult.failure(
message: 'Failed at action: ${action.name} - ${result.message}',
executionTime: stopwatch.elapsed,
error: result.error,
stackTrace: result.stackTrace,
);
}
if (!await action.verify(tester) && stopOnFailure) {
return ActionResult.failure(
message: 'Verification failed for action: ${action.name}',
executionTime: stopwatch.elapsed,
);
}
}
final successCount = results.items.where((r) => r.success).items.length;
final totalCount = results.items.length;
return ActionResult.success(
message: 'Completed $successCount/$totalCount actions successfully',
data: results,
executionTime: stopwatch.elapsed,
metrics: {
'total_actions': totalCount,
'successful_actions': successCount,
'failed_actions': totalCount - successCount,
},
);
}
}
/// 조건부 액션
class ConditionalAction extends BaseTestableAction {
final Future<bool> Function(WidgetTester) condition;
final TestableAction trueAction;
final TestableAction? falseAction;
final String conditionName;
ConditionalAction({
required this.condition,
required this.trueAction,
this.falseAction,
required this.conditionName,
});
@override
String get name => 'Conditional: $conditionName';
@override
String get description => 'Execute action based on condition: $conditionName';
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
try {
final conditionMet = await condition(tester);
if (conditionMet) {
final result = await trueAction.call(tester);
return ActionResult(
success: result.success,
message: 'Condition met - ${result.message}',
data: result.data,
executionTime: stopwatch.elapsed,
metrics: result.metrics,
error: result.error,
stackTrace: result.stackTrace,
);
} else if (falseAction != null) {
final result = await falseAction!.call(tester);
return ActionResult(
success: result.success,
message: 'Condition not met - ${result.message}',
data: result.data,
executionTime: stopwatch.elapsed,
metrics: result.metrics,
error: result.error,
stackTrace: result.stackTrace,
);
} else {
return ActionResult.success(
message: 'Condition not met - no action taken',
executionTime: stopwatch.elapsed,
);
}
} catch (e, stack) {
return ActionResult.failure(
message: 'Conditional action error: $e',
executionTime: stopwatch.elapsed,
error: e,
stackTrace: stack,
);
}
}
}
/// 재시도 액션
class RetryAction extends BaseTestableAction {
final TestableAction action;
final int maxRetries;
final Duration retryDelay;
RetryAction({
required this.action,
this.maxRetries = 3,
this.retryDelay = const Duration(seconds: 1),
});
@override
String get name => 'Retry ${action.name}';
@override
String get description => 'Retry ${action.name} up to $maxRetries times';
@override
Future<ActionResult> execute(WidgetTester tester) async {
final stopwatch = Stopwatch()..start();
ActionResult? lastResult;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
if (!await action.canExecute(tester)) {
await tester.pump(retryDelay);
continue;
}
lastResult = await action.call(tester);
if (lastResult.success) {
return ActionResult.success(
message: 'Succeeded on attempt $attempt - ${lastResult.message}',
data: lastResult.data,
executionTime: stopwatch.elapsed,
metrics: {
...?lastResult.metrics,
'attempts': attempt,
},
);
}
if (attempt < maxRetries) {
await tester.pump(retryDelay);
// 복구 시도
if (lastResult.error != null) {
await action.recover(tester, lastResult.error);
}
}
}
return ActionResult.failure(
message: 'Failed after $maxRetries attempts - ${lastResult?.message}',
executionTime: stopwatch.elapsed,
error: lastResult?.error,
stackTrace: lastResult?.stackTrace,
);
}
}