完成自动化测试套件实施(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:
2026-08-02 08:28:37 +08:00
parent dc68581c5e
commit 015cb0dc78
119 changed files with 21873 additions and 553 deletions
+456
View File
@@ -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())
+501
View File
@@ -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())