完成自动化测试套件实施(W1-W11)
W1-W3: 基线修复与测试基础设施搭建 - 修复 Jenkins JDK 21 兼容性,统一 E2E 目录,修复 storageState 冲突 - 搭建后端测试基类 BaseContractTest + Testcontainers PostgreSQL - 创建 TestDataFactory 链式构造,完善 Vitest 基座与 Playwright fixtures - 建立 docker-compose.test.yml 与测试数据隔离方案 W4-W5: 单元测试补齐(阶段 2) - 补齐 gym-member/gym-groupCourse/gym-checkIn/gym-payment 核心模块单元测试 - 补齐 gym-coach/manage-sys 模块单元测试 - 前端 utils/composables/stores 单元测试,37 文件 502 项测试 - JaCoCo 覆盖率门禁从 30% 调整至 55%,21 模块全部通过 W6-W7: 集成与契约测试(阶段 3) - Repository 集成测试:会员/团课/签到/支付关键表,Testcontainers 100% 通过 - Handler 集成测试:WebTestClient 覆盖正向/异常/权限路径 - 网关集成测试:JWT/RBAC/签名/限流/重试 - Flyway 迁移测试:验证迁移脚本可重复执行 - OpenAPI 契约测试:覆盖 ≥80% P0 接口,202 项契约测试 0 失败 - 跨模块契约测试:会员-支付-团课数据一致性 W8-W9: E2E 与用户旅程测试(阶段 4) - 管理员 Web 核心流程 E2E:用户/角色/菜单/字典/配置 - 小程序会员端核心页面 E2E:购卡/预约/签到 - 5 条 P0 用户旅程全链路自动化,60 条 journey 测试 0 失败 W10: 变异测试与质量门禁(阶段 5) - 后端 PIT 配置:pitest-maven 1.19.1 + JUnit 5,覆盖率阈值 55%/变异阈值 45% - P0 模块基线:manage-sys 48%,gym-member 30%,gym-payment 36% - 前端 StrykerJS 配置:utils/stores 变异测试,dateFormat.ts 70.83% - Jenkins 质量门禁:JaCoCo/PIT/E2E 统一检查,不达标阻断构建 W11: 持续运行与改进(阶段 6) - 测试指标收集脚本 scripts/collect-test-metrics.py + HTML 看板生成器 - Flaky Test 治理 SOP:检测→隔离→根因分析→修复→验证闭环 - 测试资产定期评审流程:月度/季度/事件驱动三级机制 - 快速参考指南 docs/testing/quick-reference.md - 累计 10 份测试文档,7 个里程碑全部达成
This commit was merged in pull request #54.
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
#!/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 <path>] [--history-dir <path>]
|
||||
|
||||
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())
|
||||
@@ -0,0 +1,501 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
测试质量看板生成器
|
||||
=====================
|
||||
从 collect-test-metrics.py 收集的历史指标数据生成可视化 HTML 看板。
|
||||
|
||||
看板包含:
|
||||
- 概览区:最新构建通过率、覆盖率、变异分数、E2E 通过率
|
||||
- 趋势图:覆盖率随时间变化曲线(内联 SVG)
|
||||
- 模块健康度:各模块测试通过情况
|
||||
- 构建历史:最近 10 次构建摘要
|
||||
|
||||
用法:
|
||||
python3 scripts/generate-dashboard.py [--history-dir <path>] [--output <path>]
|
||||
|
||||
依赖:Python 3.8+(标准库,无外部依赖)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="生成测试质量看板")
|
||||
parser.add_argument(
|
||||
"--history-dir",
|
||||
default="target/test-metrics/history",
|
||||
help="历史数据目录(默认: target/test-metrics/history)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="target/test-metrics/dashboard.html",
|
||||
help="输出 HTML 文件路径(默认: target/test-metrics/dashboard.html)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
default=".",
|
||||
help="项目根目录(默认: 当前目录)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_history(history_dir: str) -> List[Dict[str, Any]]:
|
||||
"""加载历史指标数据"""
|
||||
history_file = os.path.join(history_dir, "metrics-history.json")
|
||||
if not os.path.exists(history_file):
|
||||
print(f"⚠️ 历史数据文件不存在: {history_file}")
|
||||
return []
|
||||
try:
|
||||
with open(history_file, "r") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
print(f"❌ 无法解析历史数据: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def safe_get(d: Dict[str, Any], *keys: str, default: Any = "N/A") -> Any:
|
||||
"""安全地从嵌套字典取值"""
|
||||
current = d
|
||||
for key in keys:
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key, {})
|
||||
else:
|
||||
return default
|
||||
if isinstance(current, dict) and not current:
|
||||
return default
|
||||
return current if current is not None else default
|
||||
|
||||
|
||||
def generate_svg_trend(
|
||||
history: List[Dict[str, Any]],
|
||||
data_keys: List[str],
|
||||
labels: List[str],
|
||||
colors: List[str],
|
||||
width: int = 800,
|
||||
height: int = 300,
|
||||
) -> str:
|
||||
"""
|
||||
生成内联 SVG 趋势图。
|
||||
|
||||
使用纯 SVG 折线图,无需外部依赖。
|
||||
"""
|
||||
if len(history) < 2:
|
||||
return '<div class="no-data">数据不足,无法生成趋势图(需要至少 2 个数据点)</div>'
|
||||
|
||||
# 提取数据序列
|
||||
series: List[List[Optional[float]]] = [[] for _ in data_keys]
|
||||
for entry in history:
|
||||
for i, keys in enumerate(data_keys):
|
||||
val = safe_get(entry, *keys.split("."), default=None)
|
||||
try:
|
||||
series[i].append(float(val) if val is not None else None)
|
||||
except (ValueError, TypeError):
|
||||
series[i].append(None)
|
||||
|
||||
# 只保留至少有一个非空值的索引
|
||||
valid_indices = set(range(len(history)))
|
||||
for s in series:
|
||||
valid_indices = {i for i in valid_indices if i < len(s) and s[i] is not None}
|
||||
|
||||
if len(valid_indices) < 2:
|
||||
return '<div class="no-data">有效数据点不足 2 个</div>'
|
||||
|
||||
valid_indices = sorted(valid_indices)
|
||||
n = len(valid_indices)
|
||||
|
||||
# 计算全局范围
|
||||
all_values = []
|
||||
for s in series:
|
||||
for i in valid_indices:
|
||||
if i < len(s) and s[i] is not None:
|
||||
all_values.append(s[i])
|
||||
if not all_values:
|
||||
return '<div class="no-data">无有效数据</div>'
|
||||
|
||||
min_val = min(all_values)
|
||||
max_val = max(all_values)
|
||||
val_range = max_val - min_val if max_val != min_val else 10
|
||||
padding = val_range * 0.1
|
||||
min_val -= padding
|
||||
max_val += padding
|
||||
|
||||
# 图表区域
|
||||
margin = {"top": 20, "right": 20, "bottom": 40, "left": 60}
|
||||
plot_w = width - margin["left"] - margin["right"]
|
||||
plot_h = height - margin["top"] - margin["bottom"]
|
||||
|
||||
def x_pos(idx: int) -> float:
|
||||
return margin["left"] + (idx / (n - 1)) * plot_w if n > 1 else margin["left"] + plot_w / 2
|
||||
|
||||
def y_pos(val: float) -> float:
|
||||
return margin["top"] + plot_h - ((val - min_val) / val_range) * plot_h
|
||||
|
||||
# Y 轴刻度
|
||||
y_ticks = 5
|
||||
svg_parts = [
|
||||
f'<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg" '
|
||||
f'style="font-family: \'SF Mono\', Monaco, Consolas, monospace; font-size: 11px;">',
|
||||
f'<rect width="{width}" height="{height}" fill="var(--bg-card)" rx="8"/>',
|
||||
]
|
||||
|
||||
# 网格线 & Y 轴标签
|
||||
for i in range(y_ticks + 1):
|
||||
y = margin["top"] + (plot_h / y_ticks) * i
|
||||
val = max_val - (val_range / y_ticks) * i
|
||||
svg_parts.append(
|
||||
f'<line x1="{margin["left"]}" y1="{y:.1f}" x2="{width - margin["right"]}" y2="{y:.1f}" '
|
||||
f'stroke="var(--border)" stroke-width="1" stroke-dasharray="4,4"/>'
|
||||
)
|
||||
svg_parts.append(
|
||||
f'<text x="{margin["left"] - 8}" y="{y:.1f + 4}" text-anchor="end" fill="var(--text-muted)">'
|
||||
f"{val:.1f}%</text>"
|
||||
)
|
||||
|
||||
# 绘制折线
|
||||
for s_idx, s in enumerate(series):
|
||||
points = []
|
||||
for i_idx, vi in enumerate(valid_indices):
|
||||
if vi < len(s) and s[vi] is not None:
|
||||
points.append(f"{x_pos(i_idx):.1f},{y_pos(s[vi]):.1f}")
|
||||
if len(points) >= 2:
|
||||
svg_parts.append(
|
||||
f'<polyline points="{" ".join(points)}" fill="none" '
|
||||
f'stroke="{colors[s_idx % len(colors)]}" stroke-width="2" '
|
||||
f'stroke-linejoin="round" stroke-linecap="round"/>'
|
||||
)
|
||||
# 数据点
|
||||
for p in points:
|
||||
cx, cy = p.split(",")
|
||||
svg_parts.append(
|
||||
f'<circle cx="{cx}" cy="{cy}" r="3" fill="{colors[s_idx % len(colors)]}" '
|
||||
f'stroke="var(--bg-card)" stroke-width="1.5"/>'
|
||||
)
|
||||
|
||||
# X 轴标签(构建号或时间)
|
||||
x_step = max(1, n // 10)
|
||||
for i, vi in enumerate(valid_indices):
|
||||
if i % x_step == 0 or i == n - 1:
|
||||
label = safe_get(history[vi], "build_number", default=str(vi))
|
||||
if len(str(label)) > 8:
|
||||
label = str(label)[-8:]
|
||||
svg_parts.append(
|
||||
f'<text x="{x_pos(i):.1f}" y="{height - 10}" text-anchor="end" '
|
||||
f'transform="rotate(-30, {x_pos(i):.1f}, {height - 10})" fill="var(--text-muted)">{label}</text>'
|
||||
)
|
||||
|
||||
# 图例
|
||||
legend_x = margin["left"]
|
||||
legend_y = height - margin["bottom"] + 18
|
||||
for i, (label, color) in enumerate(zip(labels, colors)):
|
||||
lx = legend_x + i * 160
|
||||
svg_parts.append(
|
||||
f'<line x1="{lx}" y1="{legend_y}" x2="{lx + 12}" y2="{legend_y}" '
|
||||
f'stroke="{color}" stroke-width="2"/>'
|
||||
)
|
||||
svg_parts.append(
|
||||
f'<text x="{lx + 16}" y="{legend_y + 4}" fill="var(--text-muted)">{label}</text>'
|
||||
)
|
||||
|
||||
svg_parts.append("</svg>")
|
||||
return "\n".join(svg_parts)
|
||||
|
||||
|
||||
def generate_dashboard(history: List[Dict[str, Any]], output_path: str):
|
||||
"""生成 HTML 看板"""
|
||||
latest = history[-1] if history else {}
|
||||
|
||||
# 提取最新指标
|
||||
jacoco_instr = safe_get(latest, "backend", "jacoco", "instruction_coverage", default="N/A")
|
||||
branch_cov = safe_get(latest, "backend", "jacoco", "branch_coverage", default="N/A")
|
||||
unit_pass = safe_get(latest, "backend", "unit_tests", "pass_rate", default="N/A")
|
||||
unit_total = safe_get(latest, "backend", "unit_tests", "total", default="N/A")
|
||||
pit_score = safe_get(latest, "pit", "overall", "mutation_coverage", default="N/A")
|
||||
fe_ut_pass = safe_get(latest, "frontend", "unit_tests", "pass_rate", default="N/A")
|
||||
fe_ut_total = safe_get(latest, "frontend", "unit_tests", "total", default="N/A")
|
||||
fe_cov = safe_get(latest, "frontend", "coverage", "statements_coverage", default="N/A")
|
||||
e2e_pass = safe_get(latest, "e2e", "pass_rate", default="N/A")
|
||||
e2e_total = safe_get(latest, "e2e", "total", default="N/A")
|
||||
build_number = safe_get(latest, "build_number", default="N/A")
|
||||
timestamp = safe_get(latest, "timestamp", default="N/A")
|
||||
|
||||
# 生成趋势图
|
||||
cov_trend_svg = generate_svg_trend(
|
||||
history,
|
||||
["backend.jacoco.instruction_coverage", "backend.jacoco.branch_coverage", "frontend.coverage.statements_coverage"],
|
||||
["指令覆盖率", "分支覆盖率", "前端语句覆盖率"],
|
||||
["#4CAF50", "#2196F3", "#FF9800"],
|
||||
)
|
||||
pit_trend_svg = generate_svg_trend(
|
||||
history,
|
||||
["pit.overall.mutation_coverage", "backend.unit_tests.pass_rate", "e2e.pass_rate"],
|
||||
["PIT 变异覆盖率", "后端通过率", "E2E 通过率"],
|
||||
["#9C27B0", "#4CAF50", "#F44336"],
|
||||
)
|
||||
|
||||
# 构建历史表
|
||||
build_rows = ""
|
||||
for entry in reversed(history[-20:]):
|
||||
bn = safe_get(entry, "build_number", default="N/A")
|
||||
ts = safe_get(entry, "timestamp", default="")
|
||||
if ts != "N/A":
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts.replace("Z", "+00:00")).strftime("%m-%d %H:%M")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
be = safe_get(entry, "backend", "unit_tests", "pass_rate", default="-")
|
||||
fe = safe_get(entry, "frontend", "unit_tests", "pass_rate", default="-")
|
||||
e2e = safe_get(entry, "e2e", "pass_rate", default="-")
|
||||
jc = safe_get(entry, "backend", "jacoco", "instruction_coverage", default="-")
|
||||
pt = safe_get(entry, "pit", "overall", "mutation_coverage", default="-")
|
||||
|
||||
def cell(v, good_threshold=100):
|
||||
try:
|
||||
fv = float(v)
|
||||
cls = "cell-good" if fv >= good_threshold else ("cell-warn" if fv >= 50 else "cell-bad")
|
||||
return f'<td class="{cls}">{fv:.1f}%</td>'
|
||||
except (ValueError, TypeError):
|
||||
return f"<td>{v}</td>"
|
||||
|
||||
build_rows += (
|
||||
f"<tr>"
|
||||
f"<td>{bn}</td>"
|
||||
f"<td>{ts}</td>"
|
||||
f"{cell(be, 100)}"
|
||||
f"{cell(fe, 100)}"
|
||||
f"{cell(e2e, 100)}"
|
||||
f"{cell(jc, 55)}"
|
||||
f"{cell(pt, 45)}"
|
||||
f"</tr>\n"
|
||||
)
|
||||
|
||||
# PIT 模块详情
|
||||
pit_modules = safe_get(latest, "pit", "modules", default={})
|
||||
if isinstance(pit_modules, dict):
|
||||
pit_rows = ""
|
||||
for mod_name, mod_data in pit_modules.items():
|
||||
mc = safe_get(mod_data, "mutation_coverage", default="N/A")
|
||||
total = safe_get(mod_data, "total_mutations", default="N/A")
|
||||
killed = safe_get(mod_data, "killed_mutations", default="N/A")
|
||||
pit_rows += (
|
||||
f"<tr>"
|
||||
f"<td>{mod_name}</td>"
|
||||
f"<td>{total}</td>"
|
||||
f"<td>{killed}</td>"
|
||||
f"{cell(mc, 45)}"
|
||||
f"</tr>\n"
|
||||
)
|
||||
else:
|
||||
pit_rows = "<tr><td colspan='4'>无数据</td></tr>"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gym Manage 测试质量看板</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--primary: #4f46e5;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
}}
|
||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 24px;
|
||||
}}
|
||||
.header {{
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 24px; padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px; color: white;
|
||||
}}
|
||||
.header h1 {{ font-size: 24px; font-weight: 600; }}
|
||||
.header .meta {{ font-size: 13px; opacity: 0.9; text-align: right; }}
|
||||
.grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }}
|
||||
.card {{
|
||||
background: var(--bg-card); border-radius: 10px; padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08); border: 1px solid var(--border);
|
||||
}}
|
||||
.card .label {{ font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }}
|
||||
.card .value {{ font-size: 28px; font-weight: 700; }}
|
||||
.card .value.good {{ color: var(--success); }}
|
||||
.card .value.warn {{ color: var(--warning); }}
|
||||
.card .value.bad {{ color: var(--danger); }}
|
||||
.card .sub {{ font-size: 12px; color: var(--text-muted); margin-top: 4px; }}
|
||||
.section {{ margin-bottom: 24px; }}
|
||||
.section h2 {{ font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--text); }}
|
||||
table {{
|
||||
width: 100%; border-collapse: collapse; background: var(--bg-card);
|
||||
border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
border: 1px solid var(--border);
|
||||
}}
|
||||
th, td {{ padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; }}
|
||||
th {{ background: #f9fafb; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.3px; }}
|
||||
tr:last-child td {{ border-bottom: none; }}
|
||||
.cell-good {{ color: var(--success); font-weight: 600; }}
|
||||
.cell-warn {{ color: var(--warning); font-weight: 600; }}
|
||||
.cell-bad {{ color: var(--danger); font-weight: 600; }}
|
||||
.no-data {{ padding: 40px; text-align: center; color: var(--text-muted); }}
|
||||
.trend-container {{ overflow-x: auto; }}
|
||||
@media (max-width: 768px) {{
|
||||
body {{ padding: 12px; }}
|
||||
.grid {{ grid-template-columns: repeat(2, 1fr); }}
|
||||
.header {{ flex-direction: column; text-align: center; }}
|
||||
.header .meta {{ text-align: center; margin-top: 8px; }}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1>🧪 Gym Manage 测试质量看板</h1>
|
||||
<div style="font-size: 13px; opacity: 0.85; margin-top: 4px;">构建 #{build_number}</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div>更新时间: {timestamp}</div>
|
||||
<div>历史数据点: {len(history)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<div class="label">后端指令覆盖率</div>
|
||||
<div class="value {'good' if isinstance(jacoco_instr, (int, float)) and jacoco_instr >= 55 else 'warn' if isinstance(jacoco_instr, (int, float)) else ''}">{jacoco_instr if isinstance(jacoco_instr, str) else f'{jacoco_instr:.1f}%'}</div>
|
||||
<div class="sub">阈值: 55%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">后端分支覆盖率</div>
|
||||
<div class="value {'good' if isinstance(branch_cov, (int, float)) and branch_cov >= 40 else 'warn' if isinstance(branch_cov, (int, float)) else ''}">{branch_cov if isinstance(branch_cov, str) else f'{branch_cov:.1f}%'}</div>
|
||||
<div class="sub">阈值: 40%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">后端单元测试通过率</div>
|
||||
<div class="value {'good' if isinstance(unit_pass, (int, float)) and unit_pass >= 100 else 'warn' if isinstance(unit_pass, (int, float)) else ''}">{unit_pass if isinstance(unit_pass, str) else f'{unit_pass:.1f}%'}</div>
|
||||
<div class="sub">共 {unit_total} 项</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">PIT 变异覆盖率</div>
|
||||
<div class="value {'good' if isinstance(pit_score, (int, float)) and pit_score >= 45 else 'warn' if isinstance(pit_score, (int, float)) else ''}">{pit_score if isinstance(pit_score, str) else f'{pit_score:.1f}%'}</div>
|
||||
<div class="sub">阈值: 45%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">前端语句覆盖率</div>
|
||||
<div class="value">{fe_cov if isinstance(fe_cov, str) else f'{fe_cov:.1f}%'}</div>
|
||||
<div class="sub">阈值: 40%</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">前端单元测试通过率</div>
|
||||
<div class="value {'good' if isinstance(fe_ut_pass, (int, float)) and fe_ut_pass >= 100 else 'warn' if isinstance(fe_ut_pass, (int, float)) else ''}">{fe_ut_pass if isinstance(fe_ut_pass, str) else f'{fe_ut_pass:.1f}%'}</div>
|
||||
<div class="sub">共 {fe_ut_total} 项</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">E2E 测试通过率</div>
|
||||
<div class="value {'good' if isinstance(e2e_pass, (int, float)) and e2e_pass >= 100 else 'bad' if isinstance(e2e_pass, (int, float)) else ''}">{e2e_pass if isinstance(e2e_pass, str) else f'{e2e_pass:.1f}%'}</div>
|
||||
<div class="sub">共 {e2e_total} 项</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 覆盖率趋势</h2>
|
||||
<div class="card trend-container">
|
||||
{cov_trend_svg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 通过率与变异分数趋势</h2>
|
||||
<div class="card trend-container">
|
||||
{pit_trend_svg}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🧬 PIT 变异测试模块详情</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>模块</th>
|
||||
<th>总变异数</th>
|
||||
<th>已杀死</th>
|
||||
<th>变异覆盖率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pit_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>🏗️ 构建历史(最近 20 次)</h2>
|
||||
<div style="overflow-x: auto;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>构建号</th>
|
||||
<th>时间</th>
|
||||
<th>后端通过率</th>
|
||||
<th>前端通过率</th>
|
||||
<th>E2E 通过率</th>
|
||||
<th>指令覆盖率</th>
|
||||
<th>PIT 分数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{build_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"✅ 看板已生成: {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
project_dir = os.path.abspath(args.project_dir)
|
||||
history_dir = os.path.join(project_dir, args.history_dir)
|
||||
output_path = os.path.join(project_dir, args.output)
|
||||
|
||||
print("📊 生成测试质量看板...")
|
||||
history = load_history(history_dir)
|
||||
if not history:
|
||||
print("⚠️ 无历史数据,生成空看板")
|
||||
# 生成一个初始数据点,避免空看板
|
||||
history = [{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"build_number": "initial",
|
||||
"backend": {},
|
||||
"frontend": {},
|
||||
"e2e": {},
|
||||
"pit": {},
|
||||
}]
|
||||
|
||||
generate_dashboard(history, output_path)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user