""" 테스트 커버리지 분석 코드 커버리지를 측정하고 분석합니다. """ import unittest import coverage import os import sys import logging from typing import Dict, List, Any # 프로젝트 루트를 Python 경로에 추가 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) logger = logging.getLogger(__name__) class CoverageAnalyzer: """코드 커버리지 분석기""" def __init__(self, source_dir: str = 'src', exclude_patterns: List[str] = None): """ 커버리지 분석기 초기화 Args: source_dir (str): 소스 코드 디렉토리 exclude_patterns (List[str]): 제외할 패턴 리스트 """ self.source_dir = source_dir self.exclude_patterns = exclude_patterns or [ '*/tests/*', '*/test_*', '*/__pycache__/*', '*/migrations/*', '*/venv/*', '*/env/*', '*/site-packages/*' ] # 커버리지 설정 self.cov = coverage.Coverage( source=[source_dir], omit=self.exclude_patterns, branch=True, include=f'{source_dir}/*' ) def start_coverage(self): """커버리지 측정 시작""" self.cov.start() logger.info("커버리지 측정을 시작했습니다.") def stop_coverage(self): """커버리지 측정 중지""" self.cov.stop() logger.info("커버리지 측정을 중지했습니다.") def generate_report(self, output_dir: str = 'coverage_report') -> Dict[str, Any]: """ 커버리지 리포트 생성 Args: output_dir (str): 리포트 출력 디렉토리 Returns: Dict[str, Any]: 커버리지 리포트 데이터 """ try: # 출력 디렉토리 생성 os.makedirs(output_dir, exist_ok=True) # HTML 리포트 생성 html_report_path = os.path.join(output_dir, 'htmlcov') self.cov.html_report(directory=html_report_path) # XML 리포트 생성 xml_report_path = os.path.join(output_dir, 'coverage.xml') self.cov.xml_report(outfile=xml_report_path) # JSON 리포트 생성 json_report_path = os.path.join(output_dir, 'coverage.json') self.cov.json_report(outfile=json_report_path) # 텍스트 리포트 생성 text_report_path = os.path.join(output_dir, 'coverage.txt') with open(text_report_path, 'w') as f: self.cov.report(file=f) # 커버리지 데이터 수집 coverage_data = self._collect_coverage_data() logger.info(f"커버리지 리포트가 생성되었습니다: {output_dir}") return coverage_data except Exception as e: logger.error(f"커버리지 리포트 생성 중 오류: {str(e)}") return {} def _collect_coverage_data(self) -> Dict[str, Any]: """커버리지 데이터 수집""" try: # 전체 커버리지 통계 total_coverage = self.cov.report() # 파일별 커버리지 데이터 file_coverage = {} for filename in self.cov.get_data().measured_files(): if filename.startswith(os.path.abspath(self.source_dir)): relative_path = os.path.relpath(filename, os.path.abspath(self.source_dir)) file_coverage[relative_path] = self._get_file_coverage(filename) # 모듈별 커버리지 통계 module_stats = self._get_module_stats() return { 'total_coverage': total_coverage, 'file_coverage': file_coverage, 'module_stats': module_stats, 'summary': self._generate_summary(total_coverage, file_coverage) } except Exception as e: logger.error(f"커버리지 데이터 수집 중 오류: {str(e)}") return {} def _get_file_coverage(self, filename: str) -> Dict[str, Any]: """파일별 커버리지 정보""" try: analysis = self.cov.analysis2(filename) executed_lines = analysis[1] missing_lines = analysis[2] excluded_lines = analysis[3] total_lines = len(executed_lines) + len(missing_lines) + len(excluded_lines) covered_lines = len(executed_lines) if total_lines > 0: coverage_percent = (covered_lines / total_lines) * 100 else: coverage_percent = 100.0 return { 'total_lines': total_lines, 'covered_lines': covered_lines, 'missing_lines': len(missing_lines), 'excluded_lines': len(excluded_lines), 'coverage_percent': coverage_percent, 'missing_line_numbers': list(missing_lines) } except Exception as e: logger.error(f"파일 커버리지 분석 중 오류: {str(e)}") return {} def _get_module_stats(self) -> Dict[str, Any]: """모듈별 통계""" try: module_stats = {} for filename in self.cov.get_data().measured_files(): if filename.startswith(os.path.abspath(self.source_dir)): relative_path = os.path.relpath(filename, os.path.abspath(self.source_dir)) module_name = relative_path.replace(os.sep, '.').replace('.py', '') file_coverage = self._get_file_coverage(filename) module_stats[module_name] = file_coverage return module_stats except Exception as e: logger.error(f"모듈 통계 수집 중 오류: {str(e)}") return {} def _generate_summary(self, total_coverage: float, file_coverage: Dict[str, Any]) -> Dict[str, Any]: """커버리지 요약 생성""" try: # 파일별 커버리지 통계 coverage_percentages = [data['coverage_percent'] for data in file_coverage.values()] if coverage_percentages: avg_coverage = sum(coverage_percentages) / len(coverage_percentages) min_coverage = min(coverage_percentages) max_coverage = max(coverage_percentages) else: avg_coverage = 0.0 min_coverage = 0.0 max_coverage = 0.0 # 커버리지 등급 if total_coverage >= 90: grade = 'A' elif total_coverage >= 80: grade = 'B' elif total_coverage >= 70: grade = 'C' elif total_coverage >= 60: grade = 'D' else: grade = 'F' # 낮은 커버리지 파일 식별 low_coverage_files = [ filename for filename, data in file_coverage.items() if data['coverage_percent'] < 70 ] return { 'total_coverage': total_coverage, 'average_coverage': avg_coverage, 'min_coverage': min_coverage, 'max_coverage': max_coverage, 'grade': grade, 'total_files': len(file_coverage), 'low_coverage_files': low_coverage_files, 'low_coverage_count': len(low_coverage_files) } except Exception as e: logger.error(f"요약 생성 중 오류: {str(e)}") return {} def check_coverage_threshold(self, threshold: float = 80.0) -> bool: """ 커버리지 임계값 확인 Args: threshold (float): 임계값 (기본값: 80%) Returns: bool: 임계값 달성 여부 """ try: total_coverage = self.cov.report() return total_coverage >= threshold except Exception as e: logger.error(f"커버리지 임계값 확인 중 오류: {str(e)}") return False class CoverageTestRunner: """커버리지 테스트 실행기""" def __init__(self, source_dir: str = 'src', test_dir: str = 'tests'): """ 커버리지 테스트 실행기 초기화 Args: source_dir (str): 소스 코드 디렉토리 test_dir (str): 테스트 디렉토리 """ self.source_dir = source_dir self.test_dir = test_dir self.analyzer = CoverageAnalyzer(source_dir) def run_coverage_tests(self, output_dir: str = 'coverage_report') -> Dict[str, Any]: """ 커버리지 테스트 실행 Args: output_dir (str): 리포트 출력 디렉토리 Returns: Dict[str, Any]: 테스트 결과 및 커버리지 데이터 """ try: logger.info("커버리지 테스트를 시작합니다...") # 커버리지 측정 시작 self.analyzer.start_coverage() # 테스트 실행 test_result = self._run_tests() # 커버리지 측정 중지 self.analyzer.stop_coverage() # 커버리지 리포트 생성 coverage_data = self.analyzer.generate_report(output_dir) # 결과 통합 result = { 'test_result': test_result, 'coverage_data': coverage_data, 'success': test_result['success'] and self.analyzer.check_coverage_threshold() } logger.info("커버리지 테스트가 완료되었습니다.") return result except Exception as e: logger.error(f"커버리지 테스트 실행 중 오류: {str(e)}") return {'success': False, 'error': str(e)} def _run_tests(self) -> Dict[str, Any]: """테스트 실행""" try: import subprocess # pytest 실행 cmd = [ sys.executable, '-m', 'pytest', self.test_dir, '-v', '--tb=short', '--maxfail=10' ] result = subprocess.run(cmd, capture_output=True, text=True) return { 'success': result.returncode == 0, 'returncode': result.returncode, 'stdout': result.stdout, 'stderr': result.stderr } except Exception as e: logger.error(f"테스트 실행 중 오류: {str(e)}") return {'success': False, 'error': str(e)} class CoverageReportGenerator: """커버리지 리포트 생성기""" def __init__(self, coverage_data: Dict[str, Any]): """ 커버리지 리포트 생성기 초기화 Args: coverage_data (Dict[str, Any]): 커버리지 데이터 """ self.coverage_data = coverage_data def generate_markdown_report(self, output_file: str = 'coverage_report.md'): """마크다운 리포트 생성""" try: with open(output_file, 'w', encoding='utf-8') as f: f.write("# 코드 커버리지 리포트\n\n") # 요약 정보 summary = self.coverage_data.get('summary', {}) f.write("## 요약\n\n") f.write(f"- **전체 커버리지**: {summary.get('total_coverage', 0):.1f}%\n") f.write(f"- **평균 커버리지**: {summary.get('average_coverage', 0):.1f}%\n") f.write(f"- **최소 커버리지**: {summary.get('min_coverage', 0):.1f}%\n") f.write(f"- **최대 커버리지**: {summary.get('max_coverage', 0):.1f}%\n") f.write(f"- **등급**: {summary.get('grade', 'F')}\n") f.write(f"- **총 파일 수**: {summary.get('total_files', 0)}\n") f.write(f"- **낮은 커버리지 파일 수**: {summary.get('low_coverage_count', 0)}\n\n") # 파일별 커버리지 f.write("## 파일별 커버리지\n\n") f.write("| 파일 | 커버리지 | 총 라인 | 커버된 라인 | 누락된 라인 |\n") f.write("|------|----------|---------|-------------|-------------|\n") file_coverage = self.coverage_data.get('file_coverage', {}) for filename, data in sorted(file_coverage.items()): f.write(f"| {filename} | {data['coverage_percent']:.1f}% | " f"{data['total_lines']} | {data['covered_lines']} | " f"{data['missing_lines']} |\n") # 낮은 커버리지 파일 low_coverage_files = summary.get('low_coverage_files', []) if low_coverage_files: f.write("\n## 낮은 커버리지 파일 (< 70%)\n\n") for filename in low_coverage_files: data = file_coverage.get(filename, {}) f.write(f"- **{filename}**: {data.get('coverage_percent', 0):.1f}%\n") # 권장사항 f.write("\n## 권장사항\n\n") if summary.get('total_coverage', 0) < 80: f.write("- 전체 커버리지를 80% 이상으로 향상시키세요.\n") if low_coverage_files: f.write("- 낮은 커버리지 파일에 대한 테스트를 추가하세요.\n") if summary.get('grade', 'F') in ['D', 'F']: f.write("- 코드 품질을 개선하기 위해 더 많은 테스트를 작성하세요.\n") logger.info(f"마크다운 리포트가 생성되었습니다: {output_file}") except Exception as e: logger.error(f"마크다운 리포트 생성 중 오류: {str(e)}") def generate_json_report(self, output_file: str = 'coverage_report.json'): """JSON 리포트 생성""" try: import json with open(output_file, 'w', encoding='utf-8') as f: json.dump(self.coverage_data, f, indent=2, ensure_ascii=False) logger.info(f"JSON 리포트가 생성되었습니다: {output_file}") except Exception as e: logger.error(f"JSON 리포트 생성 중 오류: {str(e)}") def main(): """메인 함수""" # 로깅 설정 logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) # 커버리지 테스트 실행 runner = CoverageTestRunner() result = runner.run_coverage_tests() if result['success']: print("✅ 커버리지 테스트가 성공적으로 완료되었습니다!") # 리포트 생성 coverage_data = result['coverage_data'] report_generator = CoverageReportGenerator(coverage_data) report_generator.generate_markdown_report() report_generator.generate_json_report() # 요약 출력 summary = coverage_data.get('summary', {}) print(f"\n📊 커버리지 요약:") print(f" 전체 커버리지: {summary.get('total_coverage', 0):.1f}%") print(f" 등급: {summary.get('grade', 'F')}") print(f" 총 파일 수: {summary.get('total_files', 0)}") print(f" 낮은 커버리지 파일 수: {summary.get('low_coverage_count', 0)}") else: print("❌ 커버리지 테스트가 실패했습니다.") if 'error' in result: print(f"오류: {result['error']}") return result['success'] if __name__ == '__main__': success = main() sys.exit(0 if success else 1)