Files
superport/test/AUTOMATED_TEST_PLAN.md
JiWoong Sul 198aac6525
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
test: 통합 테스트 오류 및 경고 수정
- 모든 서비스 메서드 시그니처를 실제 구현에 맞게 수정
- 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
2025-08-05 20:24:05 +09:00

20 KiB

SuperPort Real API 자동화 테스트 계획서

📋 개요

본 문서는 SuperPort 애플리케이션의 모든 화면과 기능에 대한 Real API 기반 자동화 테스트 계획을 정의합니다.

핵심 원칙

  • Real API 사용: Mock 데이터 사용 금지, 실제 서버와 통신
  • 자동 오류 복구: 에러 발생시 자동 진단 및 수정
  • 전체 기능 커버리지: 모든 화면의 모든 기능 테스트
  • 재사용 가능한 프레임워크: 확장 가능한 테스트 구조

🏗️ 테스트 프레임워크 아키텍처

1. 기본 구조

test/
├── integration/
│   ├── automated/
│   │   ├── framework/
│   │   │   ├── screen_test_framework.dart    # 핵심 프레임워크
│   │   │   ├── api_error_diagnostics.dart    # 에러 진단 시스템
│   │   │   ├── auto_fixer.dart               # 자동 수정 시스템
│   │   │   └── test_data_generator.dart      # 데이터 생성기
│   │   ├── screens/
│   │   │   ├── login_automated_test.dart
│   │   │   ├── dashboard_automated_test.dart
│   │   │   ├── equipment_automated_test.dart
│   │   │   ├── company_automated_test.dart
│   │   │   ├── user_automated_test.dart
│   │   │   ├── warehouse_automated_test.dart
│   │   │   └── license_automated_test.dart
│   │   └── test_runner.dart                  # 통합 실행기

2. 핵심 컴포넌트

ScreenTestFramework

class ScreenTestFramework {
  // 화면 분석 및 테스트 가능한 액션 추출
  static Future<List<TestableAction>> analyzeScreen(Widget screen);
  
  // 필수 필드 자동 감지 및 입력
  static Future<Map<String, dynamic>> generateRequiredData(String endpoint);
  
  // API 호출 및 응답 검증
  static Future<TestResult> executeApiCall(ApiRequest request);
  
  // 에러 처리 및 재시도
  static Future<TestResult> executeWithRetry(
    Future<dynamic> Function() action,
    {int maxRetries = 3}
  );
}

ApiErrorDiagnostics

class ApiErrorDiagnostics {
  static ErrorDiagnosis diagnose(DioException error) {
    // 에러 타입 분류
    // - 400: 검증 오류 (필수 필드, 타입 불일치)
    // - 401: 인증 오류
    // - 403: 권한 오류
    // - 404: 리소스 없음
    // - 409: 중복 오류
    // - 422: 비즈니스 로직 오류
    // - 500: 서버 오류
  }
}

AutoFixer

class AutoFixer {
  static Future<Map<String, dynamic>> fix(
    ErrorDiagnosis diagnosis,
    Map<String, dynamic> originalData,
  ) {
    // 에러 타입별 자동 수정 로직
    // - 필수 필드 누락: 기본값 추가
    // - 타입 불일치: 타입 변환
    // - 참조 무결성: 관련 데이터 생성
    // - 중복 오류: 고유값 재생성
  }
}

📱 화면별 테스트 계획

1. 🔐 로그인 화면 (Login Screen)

테스트 시나리오

  1. 정상 로그인

    • 유효한 자격증명으로 로그인
    • 액세스 토큰 저장 확인
    • 대시보드 이동 확인
  2. 로그인 실패 처리

    • 잘못된 이메일/비밀번호
    • 비활성 계정
    • 네트워크 오류
  3. 토큰 관리

    • 토큰 만료시 자동 갱신
    • 로그아웃 후 토큰 삭제

자동화 테스트 코드

test('로그인 전체 프로세스 자동화 테스트', () async {
  // 1. 테스트 데이터 준비
  final testCredentials = {
    'email': 'admin@superport.kr',
    'password': 'admin123!',
  };
  
  // 2. 로그인 시도
  try {
    final response = await authService.login(testCredentials);
    expect(response.accessToken, isNotEmpty);
  } catch (error) {
    // 3. 에러 진단
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    
    // 4. 자동 수정 (예: 비밀번호 재설정 필요)
    if (diagnosis.errorType == ErrorType.invalidCredentials) {
      // 관리자 계정으로 비밀번호 재설정 API 호출
      await resetTestUserPassword();
      
      // 5. 재시도
      final response = await authService.login(testCredentials);
      expect(response.accessToken, isNotEmpty);
    }
  }
});

2. 📊 대시보드 화면 (Dashboard Screen)

테스트 시나리오

  1. 통계 데이터 조회

    • 전체 통계 로딩
    • 각 카드별 데이터 검증
    • 실시간 업데이트 확인
  2. 최근 활동 표시

    • 최근 활동 목록 조회
    • 페이지네이션
    • 필터링
  3. 차트 및 그래프

    • 장비 상태 분포
    • 월별 입출고 현황
    • 라이선스 만료 예정

자동화 테스트 코드

test('대시보드 데이터 로딩 자동화 테스트', () async {
  // 1. 로그인
  await RealApiTestHelper.loginAndGetToken();
  
  // 2. 대시보드 데이터 조회
  try {
    final stats = await dashboardService.getOverviewStats();
    expect(stats.totalEquipment, greaterThanOrEqualTo(0));
    expect(stats.totalUsers, greaterThanOrEqualTo(0));
  } catch (error) {
    // 3. 에러 진단
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    
    // 4. 자동 수정 (예: 초기 데이터 생성)
    if (diagnosis.errorType == ErrorType.noData) {
      await createInitialTestData();
      
      // 5. 재시도
      final stats = await dashboardService.getOverviewStats();
      expect(stats, isNotNull);
    }
  }
});

3. 🛠 장비 관리 화면 (Equipment Management)

테스트 시나리오

3.1 장비 입고
test('장비 입고 전체 프로세스 자동화 테스트', () async {
  // 1. 사전 데이터 준비
  final company = await prepareTestCompany();
  final warehouse = await prepareTestWarehouse(company.id);
  
  // 2. 장비 입고 데이터 생성
  final equipmentData = TestDataGenerator.generateEquipmentInData(
    companyId: company.id,
    warehouseId: warehouse.id,
  );
  
  // 3. 입고 실행
  try {
    final response = await equipmentService.createEquipment(equipmentData);
    expect(response.id, isNotNull);
    expect(response.status, equals('I'));
  } catch (error) {
    // 4. 에러 진단 및 수정
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    final fixedData = await AutoFixer.fix(diagnosis, equipmentData);
    
    // 5. 재시도
    final response = await equipmentService.createEquipment(fixedData);
    expect(response.id, isNotNull);
  }
});
3.2 장비 출고
test('장비 출고 전체 프로세스 자동화 테스트', () async {
  // 1. 입고된 장비 준비
  final equipment = await prepareInStockEquipment();
  
  // 2. 출고 데이터 생성
  final outData = {
    'equipment_id': equipment.id,
    'transaction_type': 'O',
    'quantity': 1,
    'out_company_id': await getOrCreateTestCompany().id,
    'out_date': DateTime.now().toIso8601String(),
  };
  
  // 3. 출고 실행
  try {
    final response = await equipmentService.createEquipmentHistory(outData);
    expect(response.transactionType, equals('O'));
  } catch (error) {
    // 4. 에러 처리
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    
    if (diagnosis.errorType == ErrorType.insufficientStock) {
      // 재고 부족시 입고 먼저 실행
      await createAdditionalStock(equipment.id);
      
      // 재시도
      final response = await equipmentService.createEquipmentHistory(outData);
      expect(response, isNotNull);
    }
  }
});
3.3 장비 목록 조회
test('장비 목록 필터링 및 검색 테스트', () async {
  // 1. 다양한 상태의 테스트 장비 생성
  await createEquipmentsWithVariousStatuses();
  
  // 2. 필터별 조회
  final filters = [
    {'status': 'I'},     // 입고
    {'status': 'O'},     // 출고
    {'status': 'R'},     // 대여
    {'company_id': 1},   // 회사별
    {'search': '노트북'}, // 검색어
  ];
  
  for (final filter in filters) {
    try {
      final equipments = await equipmentService.getEquipments(filter);
      expect(equipments, isNotEmpty);
      
      // 필터 조건 검증
      if (filter['status'] != null) {
        expect(equipments.every((e) => e.status == filter['status']), isTrue);
      }
    } catch (error) {
      // 에러시 테스트 데이터 생성 후 재시도
      await createTestDataForFilter(filter);
      final equipments = await equipmentService.getEquipments(filter);
      expect(equipments, isNotEmpty);
    }
  }
});

4. 🏢 회사 관리 화면 (Company Management)

테스트 시나리오

test('회사 및 지점 관리 전체 프로세스', () async {
  // 1. 회사 생성
  final companyData = TestDataGenerator.generateCompanyData();
  
  try {
    final company = await companyService.createCompany(companyData);
    expect(company.id, isNotNull);
    
    // 2. 지점 추가
    final branchData = TestDataGenerator.generateBranchData(company.id);
    final branch = await companyService.createBranch(branchData);
    expect(branch.companyId, equals(company.id));
    
    // 3. 연락처 정보 추가
    final contactData = {
      'company_id': company.id,
      'name': '담당자',
      'phone': '010-1234-5678',
      'email': 'contact@test.com',
    };
    await companyService.addContact(contactData);
    
    // 4. 회사 정보 수정
    company.name = '${company.name} (수정됨)';
    final updated = await companyService.updateCompany(company);
    expect(updated.name, contains('수정됨'));
    
  } catch (error) {
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    
    // 중복 회사명 오류시 처리
    if (diagnosis.errorType == ErrorType.duplicate) {
      companyData['name'] = '${companyData['name']}_${DateTime.now().millisecondsSinceEpoch}';
      final company = await companyService.createCompany(companyData);
      expect(company.id, isNotNull);
    }
  }
});

5. 👥 사용자 관리 화면 (User Management)

테스트 시나리오

test('사용자 CRUD 및 권한 관리 테스트', () async {
  // 1. 사용자 생성
  final company = await prepareTestCompany();
  final userData = TestDataGenerator.generateUserData(
    companyId: company.id,
    role: 'M', // Member
  );
  
  try {
    final user = await userService.createUser(userData);
    expect(user.id, isNotNull);
    
    // 2. 권한 변경 (Member -> Admin)
    user.role = 'A';
    await userService.updateUser(user);
    
    // 3. 비밀번호 변경
    await userService.changePassword(user.id, {
      'current_password': userData['password'],
      'new_password': 'NewPassword123!',
    });
    
    // 4. 계정 비활성화
    await userService.changeUserStatus(user.id, false);
    
    // 5. 삭제
    await userService.deleteUser(user.id);
    
  } catch (error) {
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    
    // 이메일 중복 오류시
    if (diagnosis.errorType == ErrorType.duplicateEmail) {
      userData['email'] = TestDataGenerator.generateUniqueEmail();
      final user = await userService.createUser(userData);
      expect(user.id, isNotNull);
    }
  }
});

6. 📍 창고 위치 관리 (Warehouse Location)

테스트 시나리오

test('창고 위치 계층 구조 관리 테스트', () async {
  // 1. 메인 창고 생성
  final mainWarehouse = await warehouseService.createLocation({
    'name': '메인 창고',
    'code': 'MAIN',
    'level': 1,
  });
  
  // 2. 하위 구역 생성
  final zones = ['A', 'B', 'C'];
  for (final zone in zones) {
    try {
      final subLocation = await warehouseService.createLocation({
        'name': '구역 $zone',
        'code': 'MAIN-$zone',
        'parent_id': mainWarehouse.id,
        'level': 2,
      });
      
      // 3. 선반 생성
      for (int shelf = 1; shelf <= 5; shelf++) {
        await warehouseService.createLocation({
          'name': '선반 $shelf',
          'code': 'MAIN-$zone-$shelf',
          'parent_id': subLocation.id,
          'level': 3,
        });
      }
    } catch (error) {
      // 코드 중복시 자동 수정
      final diagnosis = ApiErrorDiagnostics.diagnose(error);
      if (diagnosis.errorType == ErrorType.duplicateCode) {
        // 타임스탬프 추가하여 유니크하게
        const newCode = 'MAIN-$zone-${DateTime.now().millisecondsSinceEpoch}';
        await warehouseService.createLocation({
          'name': '구역 $zone',
          'code': newCode,
          'parent_id': mainWarehouse.id,
          'level': 2,
        });
      }
    }
  }
});

7. 📜 라이선스 관리 (License Management)

테스트 시나리오

test('라이선스 생명주기 관리 테스트', () async {
  // 1. 라이선스 생성
  final company = await prepareTestCompany();
  final licenseData = TestDataGenerator.generateLicenseData(
    companyId: company.id,
    expiryDays: 30, // 30일 후 만료
  );
  
  try {
    final license = await licenseService.createLicense(licenseData);
    expect(license.id, isNotNull);
    
    // 2. 사용자에게 할당
    final user = await prepareTestUser(company.id);
    await licenseService.assignLicense(license.id, user.id);
    
    // 3. 만료 예정 라이선스 조회
    final expiringLicenses = await licenseService.getExpiringLicenses(days: 30);
    expect(expiringLicenses.any((l) => l.id == license.id), isTrue);
    
    // 4. 라이선스 갱신
    license.expiryDate = DateTime.now().add(Duration(days: 365));
    await licenseService.updateLicense(license);
    
    // 5. 할당 해제
    await licenseService.unassignLicense(license.id);
    
  } catch (error) {
    final diagnosis = ApiErrorDiagnostics.diagnose(error);
    
    // 라이선스 키 중복시
    if (diagnosis.errorType == ErrorType.duplicateLicenseKey) {
      licenseData['license_key'] = TestDataGenerator.generateUniqueLicenseKey();
      final license = await licenseService.createLicense(licenseData);
      expect(license.id, isNotNull);
    }
  }
});

🔧 에러 자동 진단 및 수정 시스템

에러 타입별 자동 수정 전략

1. 필수 필드 누락 (400 Bad Request)

if (error.response?.data['missing_fields'] != null) {
  final missingFields = error.response.data['missing_fields'] as List;
  for (final field in missingFields) {
    switch (field) {
      case 'equipment_number':
        data['equipment_number'] = 'EQ-${DateTime.now().millisecondsSinceEpoch}';
        break;
      case 'manufacturer':
        data['manufacturer'] = await getOrCreateManufacturer('기본제조사');
        break;
      case 'warehouse_location_id':
        data['warehouse_location_id'] = await getOrCreateWarehouse();
        break;
    }
  }
}

2. 타입 불일치 (422 Unprocessable Entity)

if (error.response?.data['type_errors'] != null) {
  final typeErrors = error.response.data['type_errors'] as Map;
  typeErrors.forEach((field, expectedType) {
    switch (expectedType) {
      case 'integer':
        data[field] = int.tryParse(data[field].toString()) ?? 0;
        break;
      case 'datetime':
        data[field] = DateTime.parse(data[field].toString()).toIso8601String();
        break;
      case 'boolean':
        data[field] = data[field].toString().toLowerCase() == 'true';
        break;
    }
  });
}

3. 참조 무결성 오류 (409 Conflict)

if (error.response?.data['foreign_key_error'] != null) {
  final fkError = error.response.data['foreign_key_error'];
  switch (fkError['table']) {
    case 'companies':
      data[fkError['field']] = await createTestCompany().id;
      break;
    case 'warehouse_locations':
      data[fkError['field']] = await createTestWarehouse().id;
      break;
    case 'users':
      data[fkError['field']] = await createTestUser().id;
      break;
  }
}

4. 중복 데이터 (409 Conflict)

if (error.response?.data['duplicate_field'] != null) {
  final duplicateField = error.response.data['duplicate_field'];
  switch (duplicateField) {
    case 'email':
      data['email'] = '${data['email'].split('@')[0]}_${DateTime.now().millisecondsSinceEpoch}@test.com';
      break;
    case 'serial_number':
      data['serial_number'] = 'SN-${DateTime.now().millisecondsSinceEpoch}-${Random().nextInt(9999)}';
      break;
    case 'license_key':
      data['license_key'] = UUID.v4();
      break;
  }
}

📊 테스트 실행 및 리포팅

통합 테스트 실행기

// test/integration/automated/test_runner.dart
class AutomatedTestRunner {
  static Future<TestReport> runAllTests() async {
    final results = <ScreenTestResult>[];
    
    // 모든 화면 테스트 실행
    results.add(await LoginAutomatedTest.run());
    results.add(await DashboardAutomatedTest.run());
    results.add(await EquipmentAutomatedTest.run());
    results.add(await CompanyAutomatedTest.run());
    results.add(await UserAutomatedTest.run());
    results.add(await WarehouseAutomatedTest.run());
    results.add(await LicenseAutomatedTest.run());
    
    // 리포트 생성
    return TestReport(
      totalTests: results.length,
      passed: results.where((r) => r.passed).length,
      failed: results.where((r) => !r.passed).length,
      autoFixed: results.expand((r) => r.autoFixedErrors).length,
      duration: results.fold(Duration.zero, (sum, r) => sum + r.duration),
      details: results,
    );
  }
}

테스트 리포트 형식

{
  "timestamp": "2025-01-21T10:30:00Z",
  "environment": "test",
  "summary": {
    "totalScreens": 7,
    "totalTests": 156,
    "passed": 148,
    "failed": 8,
    "autoFixed": 23,
    "duration": "5m 32s"
  },
  "screens": [
    {
      "name": "Equipment Management",
      "tests": 32,
      "passed": 29,
      "failed": 3,
      "autoFixed": 7,
      "errors": [
        {
          "test": "장비 입고 - 필수 필드 누락",
          "error": "Missing required field: manufacturer",
          "fixed": true,
          "fixApplied": "Added default manufacturer"
        }
      ]
    }
  ]
}

🚀 CI/CD 통합

GitHub Actions 설정

name: Automated API Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * *' # 매일 자정 실행

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Flutter
      uses: subosito/flutter-action@v2
      with:
        flutter-version: '3.x'
    
    - name: Install dependencies
      run: flutter pub get
    
    - name: Run automated tests
      run: flutter test test/integration/automated/test_runner.dart
      env:
        API_BASE_URL: ${{ secrets.TEST_API_URL }}
        TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
        TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
    
    - name: Upload test report
      uses: actions/upload-artifact@v3
      with:
        name: test-report
        path: test-report.json
    
    - name: Notify on failure
      if: failure()
      uses: 8398a7/action-slack@v3
      with:
        status: ${{ job.status }}
        text: 'Automated API tests failed!'

📈 성공 지표

  1. 테스트 커버리지: 모든 화면의 95% 이상 기능 커버
  2. 자동 복구율: 발생한 에러의 80% 이상 자동 수정
  3. 실행 시간: 전체 테스트 10분 이내 완료
  4. 안정성: 연속 100회 실행시 95% 이상 성공률

🔄 향후 확장 계획

  1. 성능 테스트 추가

    • 부하 테스트
    • 응답 시간 측정
    • 동시성 테스트
  2. 보안 테스트 통합

    • SQL Injection 테스트
    • XSS 방어 테스트
    • 권한 우회 시도
  3. UI 자동화 연동

    • Widget 테스트와 통합
    • 스크린샷 비교
    • 시각적 회귀 테스트
  4. AI 기반 테스트 생성

    • 사용 패턴 학습
    • 엣지 케이스 자동 발견
    • 테스트 시나리오 추천

본 계획서는 SuperPort 애플리케이션의 품질 보증을 위한 포괄적인 자동화 테스트 전략을 제공합니다. 모든 테스트는 실제 API를 사용하며, 발생하는 오류를 자동으로 진단하고 수정하여 안정적인 테스트 환경을 보장합니다.