feat(e2e-tests): 添加端到端测试框架及测试用例
refactor(components): 调整头部和页脚布局样式 style(hero-section): 更新徽章动画效果 docs: 添加测试框架README文档 test: 实现首页、导航和联系表单的测试用例 ci: 添加CI测试脚本和配置
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
"""
|
||||
测试报告生成模块
|
||||
提供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()
|
||||
Reference in New Issue
Block a user