Files
gym-manage/scripts/generate-dashboard.py
zhangxiang 015cb0dc78 完成自动化测试套件实施(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 个里程碑全部达成
2026-08-02 08:28:37 +08:00

501 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())