## 주요 변경사항 ### 🏗️ 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. 성능 최적화
531 lines
13 KiB
Dart
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,
|
|
);
|
|
}
|
|
} |