- 모든 *_redesign.dart 파일을 기본 화면 파일로 통합 - 백업용 컨트롤러 파일들 제거 (*_controller.backup.dart) - 사용하지 않는 예제 및 테스트 파일 제거 - Clean Architecture 적용 후 남은 정리 작업 완료 - 테스트 코드 정리 및 구조 개선 준비 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
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.execute(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.execute(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!.execute(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.execute(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,
|
|
);
|
|
}
|
|
} |