完成自动化测试套件实施(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
+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())