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