"""
测试报告生成模块
提供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()