f14002559e
refactor(components): 调整头部和页脚布局样式 style(hero-section): 更新徽章动画效果 docs: 添加测试框架README文档 test: 实现首页、导航和联系表单的测试用例 ci: 添加CI测试脚本和配置
607 lines
20 KiB
Python
607 lines
20 KiB
Python
"""
|
|
测试报告生成模块
|
|
提供HTML、JSON、Markdown等多种格式的测试报告生成功能
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime
|
|
from enum import Enum
|
|
from html import escape
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
from collections import defaultdict
|
|
import base64
|
|
from io import BytesIO
|
|
|
|
from jinja2 import Environment, FileSystemLoader
|
|
import matplotlib
|
|
matplotlib.use('Agg') # 使用非GUI后端
|
|
import matplotlib.pyplot as plt
|
|
import numpy as np
|
|
|
|
from config.settings import get_settings
|
|
|
|
|
|
class TestStatus(Enum):
|
|
"""测试状态枚举"""
|
|
PASSED = "passed"
|
|
FAILED = "failed"
|
|
SKIPPED = "skipped"
|
|
ERROR = "error"
|
|
XFAIL = "xfail"
|
|
XPASS = "xpass"
|
|
|
|
|
|
@dataclass
|
|
class TestResult:
|
|
"""单个测试结果"""
|
|
test_id: str
|
|
test_name: str
|
|
test_file: str
|
|
test_class: str
|
|
status: TestStatus
|
|
start_time: datetime
|
|
end_time: datetime
|
|
duration: float
|
|
error_message: Optional[str] = None
|
|
error_traceback: Optional[str] = None
|
|
screenshot_path: Optional[str] = None
|
|
logs: List[str] = field(default_factory=list)
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
parameters: Dict[str, Any] = field(default_factory=dict)
|
|
browser: Optional[str] = None
|
|
viewport: Optional[Tuple[int, int]] = None
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""转换为字典"""
|
|
data = asdict(self)
|
|
data["status"] = self.status.value
|
|
data["start_time"] = self.start_time.isoformat()
|
|
data["end_time"] = self.end_time.isoformat()
|
|
return data
|
|
|
|
@property
|
|
def passed(self) -> bool:
|
|
"""是否通过"""
|
|
return self.status == TestStatus.PASSED
|
|
|
|
@property
|
|
def failed(self) -> bool:
|
|
"""是否失败"""
|
|
return self.status in [TestStatus.FAILED, TestStatus.ERROR]
|
|
|
|
|
|
@dataclass
|
|
class TestSuiteResult:
|
|
"""测试套件结果"""
|
|
suite_name: str
|
|
test_count: int = 0
|
|
passed_count: int = 0
|
|
failed_count: int = 0
|
|
skipped_count: int = 0
|
|
error_count: int = 0
|
|
duration: float = 0.0
|
|
test_results: List[TestResult] = field(default_factory=list)
|
|
start_time: Optional[datetime] = None
|
|
end_time: Optional[datetime] = None
|
|
|
|
@property
|
|
def pass_rate(self) -> float:
|
|
"""通过率"""
|
|
if self.test_count == 0:
|
|
return 0.0
|
|
return (self.passed_count / self.test_count) * 100
|
|
|
|
@property
|
|
def success(self) -> bool:
|
|
"""是否全部通过"""
|
|
return self.failed_count == 0 and self.error_count == 0
|
|
|
|
|
|
class ReportGenerator(ABC):
|
|
"""报告生成器抽象基类"""
|
|
|
|
@abstractmethod
|
|
def generate(self, suite_results: List[TestSuiteResult], output_path: str) -> str:
|
|
"""生成报告"""
|
|
pass
|
|
|
|
@abstractmethod
|
|
def get_format(self) -> str:
|
|
"""获取报告格式"""
|
|
pass
|
|
|
|
|
|
class HTMLReportGenerator(ReportGenerator):
|
|
"""HTML报告生成器"""
|
|
|
|
def __init__(self):
|
|
self.settings = get_settings()
|
|
self.env = Environment(
|
|
loader=FileSystemLoader(Path(__file__).parent / "templates"),
|
|
autoescape=True
|
|
)
|
|
self.template = self.env.get_template("html_report.html")
|
|
|
|
def get_format(self) -> str:
|
|
return "html"
|
|
|
|
def generate(
|
|
self,
|
|
suite_results: List[TestSuiteResult],
|
|
output_path: str
|
|
) -> str:
|
|
"""生成HTML报告"""
|
|
# 汇总所有测试结果
|
|
all_results = []
|
|
for suite in suite_results:
|
|
all_results.extend(suite.test_results)
|
|
|
|
# 计算统计信息
|
|
stats = self._calculate_stats(suite_results, all_results)
|
|
|
|
# 生成图表
|
|
charts = self._generate_charts(suite_results, all_results)
|
|
|
|
# 准备模板数据
|
|
context = {
|
|
"title": self.settings.report_title,
|
|
"description": self.settings.report_description,
|
|
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
"stats": stats,
|
|
"charts": charts,
|
|
"suites": suite_results,
|
|
"all_results": all_results,
|
|
"git_info": self._get_git_info(),
|
|
"settings": self.settings
|
|
}
|
|
|
|
# 渲染模板
|
|
html_content = self.template.render(**context)
|
|
|
|
# 确保输出目录存在
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 写入文件
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(html_content)
|
|
|
|
return output_path
|
|
|
|
def _calculate_stats(
|
|
self,
|
|
suite_results: List[TestSuiteResult],
|
|
all_results: List[TestResult]
|
|
) -> Dict[str, Any]:
|
|
"""计算统计信息"""
|
|
total = len(all_results)
|
|
passed = sum(1 for r in all_results if r.passed)
|
|
failed = sum(1 for r in all_results if r.failed)
|
|
skipped = sum(1 for r in all_results if r.status == TestStatus.SKIPPED)
|
|
|
|
total_duration = sum(r.duration for r in all_results)
|
|
|
|
# 按状态分组
|
|
by_status = defaultdict(list)
|
|
for result in all_results:
|
|
by_status[result.status.value].append(result)
|
|
|
|
# 按浏览器分组
|
|
by_browser = defaultdict(list)
|
|
for result in all_results:
|
|
if result.browser:
|
|
by_browser[result.browser].append(result)
|
|
|
|
# 按文件分组
|
|
by_file = defaultdict(list)
|
|
for result in all_results:
|
|
by_file[result.test_file].append(result)
|
|
|
|
# 失败和错误的测试
|
|
failed_tests = [r for r in all_results if r.failed]
|
|
|
|
return {
|
|
"total": total,
|
|
"passed": passed,
|
|
"failed": failed,
|
|
"skipped": skipped,
|
|
"pass_rate": round((passed / total * 100) if total > 0 else 0, 2),
|
|
"total_duration": round(total_duration, 2),
|
|
"average_duration": round(total_duration / total, 2) if total > 0 else 0,
|
|
"by_status": dict(by_status),
|
|
"by_browser": dict(by_browser),
|
|
"by_file": dict(by_file),
|
|
"failed_tests": failed_tests,
|
|
"suite_count": len(suite_results),
|
|
"success": failed == 0
|
|
}
|
|
|
|
def _generate_charts(
|
|
self,
|
|
suite_results: List[TestSuiteResult],
|
|
all_results: List[TestResult]
|
|
) -> Dict[str, str]:
|
|
"""生成图表"""
|
|
charts = {}
|
|
|
|
# 1. 测试状态饼图
|
|
charts["status_pie"] = self._create_status_pie_chart(all_results)
|
|
|
|
# 2. 套件结果条形图
|
|
charts["suite_results"] = self._create_suite_results_chart(suite_results)
|
|
|
|
# 3. 执行时间图表
|
|
charts["duration"] = self._create_duration_chart(all_results)
|
|
|
|
# 4. 浏览器分布图
|
|
browsers = defaultdict(int)
|
|
for result in all_results:
|
|
if result.browser:
|
|
browsers[result.browser] += 1
|
|
if browsers:
|
|
charts["browser_distribution"] = self._create_browser_chart(dict(browsers))
|
|
|
|
return charts
|
|
|
|
def _create_status_pie_chart(self, results: List[TestResult]) -> str:
|
|
"""创建状态饼图"""
|
|
counts = defaultdict(int)
|
|
for r in results:
|
|
counts[r.status.value] += 1
|
|
|
|
labels = []
|
|
sizes = []
|
|
colors = []
|
|
color_map = {
|
|
"passed": "#22c55e",
|
|
"failed": "#ef4444",
|
|
"skipped": "#94a3b8",
|
|
"error": "#f97316",
|
|
"xfail": "#eab308",
|
|
"xpass": "#3b82f6"
|
|
}
|
|
|
|
for status, count in counts.items():
|
|
labels.append(f"{status} ({count})")
|
|
sizes.append(count)
|
|
colors.append(color_map.get(status, "#6b7280"))
|
|
|
|
if not sizes:
|
|
return ""
|
|
|
|
fig, ax = plt.subplots(figsize=(8, 8))
|
|
ax.pie(sizes, labels=labels, colors=colors, autopct="%1.1f%%",
|
|
startangle=90, textprops={"fontsize": 12})
|
|
ax.axis("equal")
|
|
|
|
return self._fig_to_base64(fig)
|
|
|
|
def _create_suite_results_chart(self, suites: List[TestSuiteResult]) -> str:
|
|
"""创建套件结果图表"""
|
|
names = [s.suite_name for s in suites]
|
|
passed = [s.passed_count for s in suites]
|
|
failed = [s.failed_count for s in suites]
|
|
|
|
fig, ax = plt.subplots(figsize=(12, 6))
|
|
x = np.arange(len(names))
|
|
width = 0.35
|
|
|
|
bars1 = ax.bar(x - width/2, passed, width, label="Passed", color="#22c55e")
|
|
bars2 = ax.bar(x + width/2, failed, width, label="Failed", color="#ef4444")
|
|
|
|
ax.set_xlabel("Test Suite")
|
|
ax.set_ylabel("Test Count")
|
|
ax.set_title("Test Results by Suite")
|
|
ax.set_xticks(x)
|
|
ax.set_xticklabels(names, rotation=45, ha="right")
|
|
ax.legend()
|
|
|
|
# 添加数值标签
|
|
for bar in bars1:
|
|
height = bar.get_height()
|
|
ax.annotate(f"{int(height)}",
|
|
xy=(bar.get_x() + bar.get_width() / 2, height),
|
|
xytext=(0, 3), textcoords="offset points",
|
|
ha="center", va="bottom", fontsize=8)
|
|
|
|
for bar in bars2:
|
|
height = bar.get_height()
|
|
if height > 0:
|
|
ax.annotate(f"{int(height)}",
|
|
xy=(bar.get_x() + bar.get_width() / 2, height),
|
|
xytext=(0, 3), textcoords="offset points",
|
|
ha="center", va="bottom", fontsize=8)
|
|
|
|
return self._fig_to_base64(fig)
|
|
|
|
def _create_duration_chart(self, results: List[TestResult]) -> str:
|
|
"""创建执行时间图表"""
|
|
# 获取前20个最耗时的测试
|
|
sorted_results = sorted(results, key=lambda x: x.duration, reverse=True)[:20]
|
|
|
|
names = [r.test_name[:30] + "..." if len(r.test_name) > 30 else r.test_name
|
|
for r in sorted_results]
|
|
durations = [r.duration for r in sorted_results]
|
|
|
|
fig, ax = plt.subplots(figsize=(12, 8))
|
|
bars = ax.barh(names, durations, color="#3b82f6")
|
|
|
|
ax.set_xlabel("Duration (seconds)")
|
|
ax.set_title("Top 20 Slowest Tests")
|
|
ax.invert_yaxis()
|
|
|
|
# 添加数值标签
|
|
for bar, duration in zip(bars, durations):
|
|
ax.annotate(f"{duration:.2f}s",
|
|
xy=(duration, bar.get_y() + bar.get_height() / 2),
|
|
xytext=(3, 0), textcoords="offset points",
|
|
ha="left", va="center", fontsize=8)
|
|
|
|
return self._fig_to_base64(fig)
|
|
|
|
def _create_browser_chart(self, browsers: Dict[str, int]) -> str:
|
|
"""创建浏览器分布图"""
|
|
labels = list(browsers.keys())
|
|
sizes = list(browsers.values())
|
|
colors = ["#4285f4", "#ea4335", "#fbbc05", "#34a853"]
|
|
|
|
fig, ax = plt.subplots(figsize=(8, 8))
|
|
ax.pie(sizes, labels=labels, colors=colors[:len(labels)],
|
|
autopct="%1.1f%%", startangle=90, textprops={"fontsize": 12})
|
|
ax.axis("equal")
|
|
|
|
return self._fig_to_base64(fig)
|
|
|
|
def _fig_to_base64(self, fig) -> str:
|
|
"""将matplotlib图表转换为base64字符串"""
|
|
buffer = BytesIO()
|
|
fig.savefig(buffer, format="png", dpi=100, bbox_inches="tight")
|
|
buffer.seek(0)
|
|
img_str = base64.b64encode(buffer.read()).decode("utf-8")
|
|
plt.close(fig)
|
|
return f"data:image/png;base64,{img_str}"
|
|
|
|
def _get_git_info(self) -> Dict[str, str]:
|
|
"""获取Git信息"""
|
|
git_info = {
|
|
"branch": self.settings.git_branch,
|
|
"commit": self.settings.git_commit,
|
|
"repository": self.settings.git_repository
|
|
}
|
|
|
|
# 尝试从环境变量获取
|
|
if not git_info["branch"]:
|
|
git_info["branch"] = os.environ.get("GIT_BRANCH", "")
|
|
if not git_info["commit"]:
|
|
git_info["commit"] = os.environ.get("GIT_COMMIT", os.environ.get("GITHUB_SHA", ""))
|
|
|
|
return git_info
|
|
|
|
|
|
class JSONReportGenerator(ReportGenerator):
|
|
"""JSON报告生成器"""
|
|
|
|
def get_format(self) -> str:
|
|
return "json"
|
|
|
|
def generate(
|
|
self,
|
|
suite_results: List[TestSuiteResult],
|
|
output_path: str
|
|
) -> str:
|
|
"""生成JSON报告"""
|
|
report = {
|
|
"report_info": {
|
|
"title": get_settings().report_title,
|
|
"description": get_settings().report_description,
|
|
"generated_at": datetime.now().isoformat(),
|
|
"version": "1.0.0"
|
|
},
|
|
"summary": self._calculate_summary(suite_results),
|
|
"suites": []
|
|
}
|
|
|
|
for suite in suite_results:
|
|
suite_data = {
|
|
"name": suite.suite_name,
|
|
"test_count": suite.test_count,
|
|
"passed": suite.passed_count,
|
|
"failed": suite.failed_count,
|
|
"skipped": suite.skipped_count,
|
|
"duration": suite.duration,
|
|
"pass_rate": suite.pass_rate,
|
|
"success": suite.success,
|
|
"tests": [r.to_dict() for r in suite.test_results]
|
|
}
|
|
report["suites"].append(suite_data)
|
|
|
|
# 确保输出目录存在
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 写入文件
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
json.dump(report, f, ensure_ascii=False, indent=2)
|
|
|
|
return output_path
|
|
|
|
def _calculate_summary(self, suites: List[TestSuiteResult]) -> Dict[str, Any]:
|
|
"""计算汇总信息"""
|
|
all_results = []
|
|
for suite in suites:
|
|
all_results.extend(suite.test_results)
|
|
|
|
return {
|
|
"total": len(all_results),
|
|
"passed": sum(1 for r in all_results if r.passed),
|
|
"failed": sum(1 for r in all_results if r.failed),
|
|
"skipped": sum(1 for r in all_results if r.status == TestStatus.SKIPPED),
|
|
"duration": sum(r.duration for r in all_results)
|
|
}
|
|
|
|
|
|
class MarkdownReportGenerator(ReportGenerator):
|
|
"""Markdown报告生成器"""
|
|
|
|
def get_format(self) -> str:
|
|
return "markdown"
|
|
|
|
def generate(
|
|
self,
|
|
suite_results: List[TestSuiteResult],
|
|
output_path: str
|
|
) -> str:
|
|
"""生成Markdown报告"""
|
|
lines = [
|
|
f"# {get_settings().report_title}",
|
|
"",
|
|
f"**生成时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
"",
|
|
"## 📊 测试汇总",
|
|
""
|
|
]
|
|
|
|
# 汇总信息
|
|
all_results = []
|
|
for suite in suite_results:
|
|
all_results.extend(suite.test_results)
|
|
|
|
total = len(all_results)
|
|
passed = sum(1 for r in all_results if r.passed)
|
|
failed = sum(1 for r in all_results if r.failed)
|
|
|
|
lines.extend([
|
|
f"- **总计**: {total} 个测试",
|
|
f"- **通过**: {passed} 个 ✅",
|
|
f"- **失败**: {failed} 个 ❌",
|
|
f"- **通过率**: {round(passed / total * 100, 2) if total > 0 else 0}%",
|
|
""
|
|
])
|
|
|
|
# 套件详情
|
|
lines.append("## 📁 套件详情")
|
|
lines.append("")
|
|
|
|
for suite in suite_results:
|
|
lines.append(f"### {suite.suite_name}")
|
|
lines.append("")
|
|
lines.append(f"- 测试数: {suite.test_count}")
|
|
lines.append(f"- 通过: {suite.passed_count}")
|
|
lines.append(f"- 失败: {suite.failed_count}")
|
|
lines.append(f"- 耗时: {suite.duration:.2f}s")
|
|
lines.append(f"- 通过率: {suite.pass_rate:.2f}%")
|
|
lines.append("")
|
|
|
|
# 失败测试详情
|
|
failed_tests = [r for r in all_results if r.failed]
|
|
if failed_tests:
|
|
lines.append("## ❌ 失败测试")
|
|
lines.append("")
|
|
|
|
for result in failed_tests:
|
|
lines.append(f"### {result.test_name}")
|
|
lines.append("")
|
|
lines.append(f"- **文件**: {result.test_file}")
|
|
lines.append(f"- **状态**: {result.status.value}")
|
|
lines.append(f"- **耗时**: {result.duration:.2f}s")
|
|
if result.error_message:
|
|
lines.append(f"- **错误**: {result.error_message}")
|
|
lines.append("")
|
|
|
|
content = "\n".join(lines)
|
|
|
|
# 确保输出目录存在
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 写入文件
|
|
with open(output_path, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
return output_path
|
|
|
|
|
|
class ReportManager:
|
|
"""报告管理器"""
|
|
|
|
def __init__(self):
|
|
self.settings = get_settings()
|
|
self.generators = {
|
|
"html": HTMLReportGenerator(),
|
|
"json": JSONReportGenerator(),
|
|
"markdown": MarkdownReportGenerator()
|
|
}
|
|
self.current_results: List[TestSuiteResult] = []
|
|
|
|
def add_result(self, result: TestResult) -> None:
|
|
"""添加测试结果"""
|
|
# 查找或创建对应的套件
|
|
suite_name = result.test_class or "default"
|
|
suite = next(
|
|
(s for s in self.current_results if s.suite_name == suite_name),
|
|
None
|
|
)
|
|
|
|
if suite is None:
|
|
suite = TestSuiteResult(suite_name=suite_name)
|
|
self.current_results.append(suite)
|
|
|
|
suite.test_results.append(result)
|
|
suite.test_count += 1
|
|
|
|
if result.passed:
|
|
suite.passed_count += 1
|
|
elif result.status == TestStatus.FAILED:
|
|
suite.failed_count += 1
|
|
elif result.status == TestStatus.SKIPPED:
|
|
suite.skipped_count += 1
|
|
else:
|
|
suite.error_count += 1
|
|
|
|
suite.duration += result.duration
|
|
|
|
def generate_reports(self, output_dir: Optional[str] = None) -> Dict[str, str]:
|
|
"""生成所有格式的报告"""
|
|
output_dir = output_dir or str(self.settings.get_reports_path())
|
|
output_dir = Path(output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
generated = {}
|
|
|
|
for format_name, generator in self.generators.items():
|
|
output_path = output_dir / f"test_report.{format_name}"
|
|
generator.generate(self.current_results, str(output_path))
|
|
generated[format_name] = str(output_path)
|
|
|
|
return generated
|
|
|
|
def get_summary(self) -> Dict[str, Any]:
|
|
"""获取汇总信息"""
|
|
all_results = []
|
|
for suite in self.current_results:
|
|
all_results.extend(suite.test_results)
|
|
|
|
total = len(all_results)
|
|
passed = sum(1 for r in all_results if r.passed)
|
|
|
|
return {
|
|
"total": total,
|
|
"passed": passed,
|
|
"failed": total - passed,
|
|
"pass_rate": round(passed / total * 100, 2) if total > 0 else 0,
|
|
"suites": len(self.current_results),
|
|
"duration": sum(s.duration for s in self.current_results)
|
|
}
|
|
|
|
def clear_results(self) -> None:
|
|
"""清空当前结果"""
|
|
self.current_results.clear()
|
|
|
|
|
|
def get_report_manager() -> ReportManager:
|
|
"""获取报告管理器"""
|
|
return ReportManager()
|