#!/usr/bin/env python3 """ 测试执行指标收集脚本 ========================== 从 JaCoCo、JUnit、PIT 等报告中提取测试质量指标,输出为 JSON 格式, 支持历史趋势数据的积累与可视化。 数据源: - JaCoCo: manage-test-report/target/site/jacoco-aggregate/jacoco.csv - JUnit: 各模块 target/surefire-reports/*.xml - PIT: 各模块 target/pit-reports/mutations.xml - E2E: gym-manage-web/test-results/junit.xml 用法: python3 scripts/collect-test-metrics.py [--output-dir ] [--history-dir ] Cross-Validation: - JaCoCo CSV 格式参考: https://www.jacoco.org/jacoco/trunk/doc/csv.html - JUnit XML 格式参考: https://llg.cubic.org/docs/junit/ - PIT mutations.xml 格式参考: https://pitest.org/quickstart/mutators/ """ import argparse import csv import json import os import sys import xml.etree.ElementTree as ET from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="收集测试质量指标") parser.add_argument( "--output-dir", default="target/test-metrics", help="输出目录(默认: target/test-metrics)", ) parser.add_argument( "--history-dir", default="target/test-metrics/history", help="历史数据存储目录(默认: target/test-metrics/history)", ) parser.add_argument( "--project-dir", default=".", help="项目根目录(默认: 当前目录)", ) parser.add_argument( "--build-number", default=None, help="构建编号(CI 环境传入)", ) return parser.parse_args() def find_surefire_reports(project_dir: str) -> List[str]: """查找所有 surefire 报告 XML 文件""" reports = [] for root, dirs, files in os.walk(project_dir): if "surefire-reports" in root: for f in files: if f.endswith(".xml") and not f.startswith("TEST-"): continue if f.endswith(".xml"): reports.append(os.path.join(root, f)) return reports def parse_surefire_report(filepath: str) -> Dict[str, Any]: """解析单个 JUnit XML 报告""" try: tree = ET.parse(filepath) root = tree.getroot() testsuite = root if root.tag == "testsuite" else root.find(".//testsuite") if testsuite is None: return {"file": filepath, "tests": 0, "failures": 0, "errors": 0, "skipped": 0} return { "file": os.path.relpath(filepath), "tests": int(testsuite.get("tests", 0)), "failures": int(testsuite.get("failures", 0)), "errors": int(testsuite.get("errors", 0)), "skipped": int(testsuite.get("skipped", 0)), "time": float(testsuite.get("time", 0)), } except (ET.ParseError, FileNotFoundError) as e: return {"file": filepath, "error": str(e)} def parse_jacoco_csv(filepath: str) -> Dict[str, Any]: """ 解析 JaCoCo CSV 聚合报告,提取指令覆盖率和分支覆盖率。 信源参考: JaCoCo CSV 格式 — INSTRUCTION_MISSED, INSTRUCTION_COVERED, BRANCH_MISSED, BRANCH_COVERED, 等列。 """ if not os.path.exists(filepath): return {"error": f"JaCoCo report not found: {filepath}"} metrics = { "instruction_missed": 0, "instruction_covered": 0, "branch_missed": 0, "branch_covered": 0, "line_missed": 0, "line_covered": 0, "method_missed": 0, "method_covered": 0, "class_missed": 0, "class_covered": 0, } try: with open(filepath, "r") as f: reader = csv.DictReader(f) for row in reader: # 跳过头部汇总行 group = row.get("GROUP", "") if not group: continue metrics["instruction_missed"] += int(row.get("INSTRUCTION_MISSED", 0)) metrics["instruction_covered"] += int(row.get("INSTRUCTION_COVERED", 0)) metrics["branch_missed"] += int(row.get("BRANCH_MISSED", 0)) metrics["branch_covered"] += int(row.get("BRANCH_COVERED", 0)) metrics["line_missed"] += int(row.get("LINE_MISSED", 0)) metrics["line_covered"] += int(row.get("LINE_COVERED", 0)) metrics["method_missed"] += int(row.get("METHOD_MISSED", 0)) metrics["method_covered"] += int(row.get("METHOD_COVERED", 0)) metrics["class_missed"] += int(row.get("CLASS_MISSED", 0)) metrics["class_covered"] += int(row.get("CLASS_COVERED", 0)) except (csv.Error, IOError) as e: return {"error": f"Failed to parse JaCoCo CSV: {e}"} total_instruction = metrics["instruction_missed"] + metrics["instruction_covered"] total_branch = metrics["branch_missed"] + metrics["branch_covered"] return { "instruction_coverage": round( (metrics["instruction_covered"] / total_instruction * 100) if total_instruction > 0 else 0, 2, ), "branch_coverage": round( (metrics["branch_covered"] / total_branch * 100) if total_branch > 0 else 0, 2, ), "line_coverage": round( (metrics["line_covered"] / (metrics["line_missed"] + metrics["line_covered"]) * 100) if (metrics["line_missed"] + metrics["line_covered"]) > 0 else 0, 2, ), **metrics, } def parse_pit_report(filepath: str) -> Dict[str, Any]: """ 解析 PIT mutations.xml,提取变异分数。 信源参考: PIT mutations.xml — mutationCoverage 属性包含总体变异覆盖率。 """ if not os.path.exists(filepath): return {"error": f"PIT report not found: {filepath}"} try: tree = ET.parse(filepath) root = tree.getroot() mutation_coverage = float(root.get("mutationCoverage", 0)) total_mutations = int(root.get("totalMutations", 0)) killed_mutations = int(root.get("killedMutations", 0)) return { "mutation_coverage": mutation_coverage, "total_mutations": total_mutations, "killed_mutations": killed_mutations, "test_strength": round( (killed_mutations / total_mutations * 100) if total_mutations > 0 else 0, 2, ), } except (ET.ParseError, FileNotFoundError) as e: return {"error": f"Failed to parse PIT report: {e}"} def parse_e2e_report(filepath: str) -> Dict[str, Any]: """ 解析 Playwright 输出的 JUnit XML 报告。 """ if not os.path.exists(filepath): return {"error": f"E2E report not found: {filepath}"} try: tree = ET.parse(filepath) root = tree.getroot() total = int(root.get("tests", 0)) failures = int(root.get("failures", 0)) errors = int(root.get("errors", 0)) skipped = int(root.get("skipped", 0)) pass_rate = round(((total - failures - errors) / total * 100) if total > 0 else 0, 2) return { "total": total, "failures": failures, "errors": errors, "skipped": skipped, "pass_rate": pass_rate, } except (ET.ParseError, FileNotFoundError) as e: return {"error": f"Failed to parse E2E report: {e}"} def parse_frontend_coverage(filepath: str) -> Dict[str, Any]: """ 解析前端 Vitest 覆盖率 JSON 报告。 信源参考: @vitest/coverage-v8 JSON 输出格式。 """ if not os.path.exists(filepath): return {"error": f"Frontend coverage report not found: {filepath}"} try: with open(filepath, "r") as f: data = json.load(f) total = data.get("total", {}) lines = total.get("lines", {}) branches = total.get("branches", {}) functions = total.get("functions", {}) statements = total.get("statements", {}) def pct(v: Dict[str, int]) -> float: total_count = v.get("total", 0) covered = v.get("covered", 0) return round((covered / total_count * 100) if total_count > 0 else 0, 2) return { "lines_coverage": pct(lines), "branches_coverage": pct(branches), "functions_coverage": pct(functions), "statements_coverage": pct(statements), "total_lines": lines.get("total", 0), "covered_lines": lines.get("covered", 0), } except (json.JSONDecodeError, IOError) as e: return {"error": f"Failed to parse frontend coverage: {e}"} def collect_all_metrics(project_dir: str, build_number: Optional[str] = None) -> Dict[str, Any]: """收集所有测试指标""" project_path = Path(project_dir).resolve() timestamp = datetime.now(timezone.utc).isoformat() metrics: Dict[str, Any] = { "timestamp": timestamp, "build_number": build_number or "local", "backend": {}, "frontend": {}, "e2e": {}, "pit": {}, } # ---- 后端 JaCoCo 覆盖率 ---- jacoco_csv = ( project_path / "gym-manage-api" / "manage-test-report" / "target" / "site" / "jacoco-aggregate" / "jacoco.csv" ) metrics["backend"]["jacoco"] = parse_jacoco_csv(str(jacoco_csv)) # ---- 后端 JUnit 测试结果 ---- surefire_reports = find_surefire_reports(str(project_path / "gym-manage-api")) total_tests = 0 total_failures = 0 total_errors = 0 total_skipped = 0 total_time = 0.0 for report_path in surefire_reports: result = parse_surefire_report(report_path) if "error" not in result: total_tests += result["tests"] total_failures += result["failures"] total_errors += result["errors"] total_skipped += result["skipped"] total_time += result["time"] metrics["backend"]["unit_tests"] = { "total": total_tests, "failures": total_failures, "errors": total_errors, "skipped": total_skipped, "time_seconds": round(total_time, 2), "pass_rate": round( ((total_tests - total_failures - total_errors) / total_tests * 100) if total_tests > 0 else 0, 2, ), } # ---- 后端 PIT 变异测试 ---- pit_modules = ["manage-sys", "gym-member", "gym-payment", "manage-common"] pit_results = {} # 信源交叉验证: 各模块 PIT mutations.xml for module in pit_modules: pit_path = project_path / "gym-manage-api" / module / "target" / "pit-reports" / "mutations.xml" pit_results[module] = parse_pit_report(str(pit_path)) metrics["pit"]["modules"] = pit_results # 计算 PIT 加权平均 pit_total_mutations = sum( m.get("total_mutations", 0) for m in pit_results.values() if "error" not in m ) pit_killed_mutations = sum( m.get("killed_mutations", 0) for m in pit_results.values() if "error" not in m ) metrics["pit"]["overall"] = { "total_mutations": pit_total_mutations, "killed_mutations": pit_killed_mutations, "mutation_coverage": round( (pit_killed_mutations / pit_total_mutations * 100) if pit_total_mutations > 0 else 0, 2, ), } # ---- 前端 Vitest 覆盖率 ---- frontend_coverage_json = ( project_path / "gym-manage-web" / "coverage" / "coverage-final.json" ) metrics["frontend"]["coverage"] = parse_frontend_coverage(str(frontend_coverage_json)) # ---- 前端单元测试 ---- frontend_surefire = find_surefire_reports(str(project_path / "gym-manage-web")) fe_total = 0 fe_failures = 0 fe_errors = 0 fe_skipped = 0 for report_path in frontend_surefire: result = parse_surefire_report(report_path) if "error" not in result: fe_total += result["tests"] fe_failures += result["failures"] fe_errors += result["errors"] fe_skipped += result["skipped"] metrics["frontend"]["unit_tests"] = { "total": fe_total, "failures": fe_failures, "errors": fe_errors, "skipped": fe_skipped, "pass_rate": round( ((fe_total - fe_failures - fe_errors) / fe_total * 100) if fe_total > 0 else 0, 2, ), } # ---- E2E 测试 ---- e2e_junit = project_path / "gym-manage-web" / "test-results" / "junit.xml" metrics["e2e"] = parse_e2e_report(str(e2e_junit)) # ---- 前端 StrykerJS 变异测试 ---- stryker_report = project_path / "gym-manage-web" / "reports" / "mutation" / "mutation.json" if stryker_report.exists(): try: with open(stryker_report, "r") as f: stryker_data = json.load(f) metrics["frontend"]["mutation"] = { "score": round(stryker_data.get("metrics", {}).get("mutationScore", 0), 2), "killed": stryker_data.get("metrics", {}).get("killed", 0), "survived": stryker_data.get("metrics", {}).get("survived", 0), "total": stryker_data.get("metrics", {}).get("totalDetected", 0), } except (json.JSONDecodeError, IOError) as e: metrics["frontend"]["mutation"] = {"error": str(e)} else: metrics["frontend"]["mutation"] = {"error": "StrykerJS report not found"} return metrics def load_history(history_dir: str) -> List[Dict[str, Any]]: """加载历史指标数据""" history_file = os.path.join(history_dir, "metrics-history.json") if os.path.exists(history_file): try: with open(history_file, "r") as f: return json.load(f) except (json.JSONDecodeError, IOError): return [] return [] def save_history(history_dir: str, metrics: Dict[str, Any], max_entries: int = 100): """保存指标到历史记录""" os.makedirs(history_dir, exist_ok=True) history = load_history(history_dir) history.append(metrics) # 保留最近 N 条记录 if len(history) > max_entries: history = history[-max_entries:] history_file = os.path.join(history_dir, "metrics-history.json") with open(history_file, "w") as f: json.dump(history, f, indent=2, ensure_ascii=False) def main(): args = parse_args() project_dir = os.path.abspath(args.project_dir) output_dir = os.path.join(project_dir, args.output_dir) history_dir = os.path.join(project_dir, args.history_dir) print(f"📊 收集测试指标...") print(f" 项目目录: {project_dir}") print(f" 输出目录: {output_dir}") metrics = collect_all_metrics(project_dir, args.build_number) # 输出当前指标 os.makedirs(output_dir, exist_ok=True) output_file = os.path.join(output_dir, "current-metrics.json") with open(output_file, "w") as f: json.dump(metrics, f, indent=2, ensure_ascii=False) print(f" ✅ 当前指标已保存: {output_file}") # 保存历史记录 save_history(history_dir, metrics) print(f" ✅ 历史记录已更新: {history_dir}/metrics-history.json") # 打印摘要 print("\n=== 测试指标摘要 ===") jacoco = metrics["backend"].get("jacoco", {}) if "error" not in jacoco: print(f" 后端 JaCoCo 指令覆盖率: {jacoco.get('instruction_coverage', 'N/A')}%") print(f" 后端 JaCoCo 分支覆盖率: {jacoco.get('branch_coverage', 'N/A')}%") ut = metrics["backend"].get("unit_tests", {}) if "error" not in ut: print(f" 后端单元测试: {ut.get('total', 0)} 项, 通过率 {ut.get('pass_rate', 'N/A')}%") pit = metrics["pit"].get("overall", {}) if "error" not in pit: print(f" PIT 变异覆盖率: {pit.get('mutation_coverage', 'N/A')}%") fe_ut = metrics["frontend"].get("unit_tests", {}) if "error" not in fe_ut: print(f" 前端单元测试: {fe_ut.get('total', 0)} 项, 通过率 {fe_ut.get('pass_rate', 'N/A')}%") fe_cov = metrics["frontend"].get("coverage", {}) if "error" not in fe_cov: print(f" 前端语句覆盖率: {fe_cov.get('statements_coverage', 'N/A')}%") e2e = metrics.get("e2e", {}) if "error" not in e2e: print(f" E2E 测试: {e2e.get('total', 0)} 项, 通过率 {e2e.get('pass_rate', 'N/A')}%") return 0 if __name__ == "__main__": sys.exit(main())