feat(admin): 添加用户管理相关文件
添加用户管理视图、API和状态管理文件
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""工具模块"""
|
||||
@@ -0,0 +1,507 @@
|
||||
"""
|
||||
增强版报告生成器模块
|
||||
支持趋势分析、历史报告对比、性能基准对比等功能
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
import statistics
|
||||
|
||||
from .reporter import TestResult, TestSummary, ReportGenerator
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrendData:
|
||||
"""趋势数据"""
|
||||
date: str
|
||||
total: int
|
||||
passed: int
|
||||
failed: int
|
||||
pass_rate: float
|
||||
avg_response_time: float
|
||||
total_time: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class PerformanceBenchmark:
|
||||
"""性能基准数据"""
|
||||
endpoint: str
|
||||
method: str
|
||||
avg_response_time: float
|
||||
min_response_time: float
|
||||
max_response_time: float
|
||||
p95_response_time: float
|
||||
p99_response_time: float
|
||||
|
||||
|
||||
class EnhancedReportGenerator(ReportGenerator):
|
||||
"""增强版报告生成器"""
|
||||
|
||||
def __init__(self, output_dir: str = "reports", history_dir: str = "reports/history"):
|
||||
"""
|
||||
初始化增强版报告生成器
|
||||
|
||||
Args:
|
||||
output_dir: 报告输出目录
|
||||
history_dir: 历史报告目录
|
||||
"""
|
||||
super().__init__(output_dir)
|
||||
self.history_dir = Path(history_dir)
|
||||
self.history_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def generate_trend_report(self, days: int = 7) -> str:
|
||||
"""
|
||||
生成趋势分析报告
|
||||
|
||||
Args:
|
||||
days: 分析最近几天的数据
|
||||
|
||||
Returns:
|
||||
报告文件路径
|
||||
"""
|
||||
trend_data = self._load_trend_data(days)
|
||||
|
||||
if not trend_data:
|
||||
return ""
|
||||
|
||||
report_path = self.output_dir / f"trend_report_{datetime.now().strftime('%Y%m%d')}.html"
|
||||
|
||||
html_content = self._generate_trend_html(trend_data)
|
||||
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
return str(report_path)
|
||||
|
||||
def generate_comparison_report(self, current_results: List[TestResult],
|
||||
previous_results: List[TestResult] = None) -> str:
|
||||
"""
|
||||
生成对比报告
|
||||
|
||||
Args:
|
||||
current_results: 当前测试结果
|
||||
previous_results: 之前的测试结果
|
||||
|
||||
Returns:
|
||||
报告文件路径
|
||||
"""
|
||||
if previous_results is None:
|
||||
previous_results = self._load_latest_results()
|
||||
|
||||
if not previous_results:
|
||||
return ""
|
||||
|
||||
comparison_data = self._compare_results(current_results, previous_results)
|
||||
|
||||
report_path = self.output_dir / f"comparison_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
||||
|
||||
html_content = self._generate_comparison_html(comparison_data)
|
||||
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
return str(report_path)
|
||||
|
||||
def generate_performance_report(self, results: List[TestResult],
|
||||
benchmarks: Dict[str, PerformanceBenchmark] = None) -> str:
|
||||
"""
|
||||
生成性能报告
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
benchmarks: 性能基准数据
|
||||
|
||||
Returns:
|
||||
报告文件路径
|
||||
"""
|
||||
performance_data = self._analyze_performance(results)
|
||||
|
||||
if benchmarks:
|
||||
performance_data = self._compare_with_benchmarks(performance_data, benchmarks)
|
||||
|
||||
report_path = self.output_dir / f"performance_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.html"
|
||||
|
||||
html_content = self._generate_performance_html(performance_data)
|
||||
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
return str(report_path)
|
||||
|
||||
def _load_trend_data(self, days: int) -> List[TrendData]:
|
||||
"""
|
||||
加载趋势数据
|
||||
|
||||
Args:
|
||||
days: 加载最近几天的数据
|
||||
|
||||
Returns:
|
||||
趋势数据列表
|
||||
"""
|
||||
trend_data = []
|
||||
|
||||
for i in range(days):
|
||||
date = datetime.now() - timedelta(days=i)
|
||||
date_str = date.strftime('%Y%m%d')
|
||||
|
||||
report_file = self.history_dir / f"test_report_{date_str}.json"
|
||||
|
||||
if report_file.exists():
|
||||
with open(report_file, 'r', encoding='utf-8') as f:
|
||||
report_data = json.load(f)
|
||||
|
||||
summary = report_data.get('summary', {})
|
||||
results = report_data.get('results', [])
|
||||
|
||||
avg_response_time = statistics.mean([r['response_time'] for r in results]) if results else 0
|
||||
|
||||
trend_data.append(TrendData(
|
||||
date=date.strftime('%Y-%m-%d'),
|
||||
total=summary.get('total', 0),
|
||||
passed=summary.get('passed', 0),
|
||||
failed=summary.get('failed', 0),
|
||||
pass_rate=summary.get('pass_rate', 0),
|
||||
avg_response_time=avg_response_time,
|
||||
total_time=summary.get('total_time', 0)
|
||||
))
|
||||
|
||||
return sorted(trend_data, key=lambda x: x.date)
|
||||
|
||||
def _load_latest_results(self) -> List[TestResult]:
|
||||
"""
|
||||
加载最新的测试结果
|
||||
|
||||
Returns:
|
||||
测试结果列表
|
||||
"""
|
||||
report_files = list(self.history_dir.glob("test_report_*.json"))
|
||||
|
||||
if not report_files:
|
||||
return []
|
||||
|
||||
latest_file = max(report_files, key=lambda f: f.stat().st_mtime)
|
||||
|
||||
with open(latest_file, 'r', encoding='utf-8') as f:
|
||||
report_data = json.load(f)
|
||||
|
||||
return [TestResult(**r) for r in report_data.get('results', [])]
|
||||
|
||||
def _compare_results(self, current: List[TestResult], previous: List[TestResult]) -> Dict[str, Any]:
|
||||
"""
|
||||
对比测试结果
|
||||
|
||||
Args:
|
||||
current: 当前测试结果
|
||||
previous: 之前的测试结果
|
||||
|
||||
Returns:
|
||||
对比数据
|
||||
"""
|
||||
current_summary = self._calculate_summary(current)
|
||||
previous_summary = self._calculate_summary(previous)
|
||||
|
||||
return {
|
||||
"current": current_summary,
|
||||
"previous": previous_summary,
|
||||
"changes": {
|
||||
"total": current_summary["total"] - previous_summary["total"],
|
||||
"passed": current_summary["passed"] - previous_summary["passed"],
|
||||
"failed": current_summary["failed"] - previous_summary["failed"],
|
||||
"pass_rate": current_summary["pass_rate"] - previous_summary["pass_rate"],
|
||||
"avg_response_time": current_summary["avg_response_time"] - previous_summary["avg_response_time"]
|
||||
}
|
||||
}
|
||||
|
||||
def _analyze_performance(self, results: List[TestResult]) -> Dict[str, Any]:
|
||||
"""
|
||||
分析性能数据
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
|
||||
Returns:
|
||||
性能分析数据
|
||||
"""
|
||||
response_times = [r.response_time for r in results if r.response_time > 0]
|
||||
|
||||
if not response_times:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"avg_response_time": statistics.mean(response_times),
|
||||
"min_response_time": min(response_times),
|
||||
"max_response_time": max(response_times),
|
||||
"median_response_time": statistics.median(response_times),
|
||||
"std_response_time": statistics.stdev(response_times) if len(response_times) > 1 else 0,
|
||||
"p95_response_time": self._calculate_percentile(response_times, 95),
|
||||
"p99_response_time": self._calculate_percentile(response_times, 99),
|
||||
"total_tests": len(results),
|
||||
"failed_tests": sum(1 for r in results if not r.success)
|
||||
}
|
||||
|
||||
def _compare_with_benchmarks(self, performance_data: Dict[str, Any],
|
||||
benchmarks: Dict[str, PerformanceBenchmark]) -> Dict[str, Any]:
|
||||
"""
|
||||
与性能基准对比
|
||||
|
||||
Args:
|
||||
performance_data: 性能数据
|
||||
benchmarks: 性能基准
|
||||
|
||||
Returns:
|
||||
对比后的性能数据
|
||||
"""
|
||||
comparisons = []
|
||||
|
||||
for endpoint, benchmark in benchmarks.items():
|
||||
current_avg = performance_data.get("avg_response_time", 0)
|
||||
|
||||
comparison = {
|
||||
"endpoint": endpoint,
|
||||
"method": benchmark.method,
|
||||
"benchmark_avg": benchmark.avg_response_time,
|
||||
"current_avg": current_avg,
|
||||
"diff": current_avg - benchmark.avg_response_time,
|
||||
"diff_percent": ((current_avg - benchmark.avg_response_time) / benchmark.avg_response_time * 100) if benchmark.avg_response_time > 0 else 0,
|
||||
"status": "good" if current_avg <= benchmark.avg_response_time * 1.2 else "warning" if current_avg <= benchmark.avg_response_time * 1.5 else "critical"
|
||||
}
|
||||
|
||||
comparisons.append(comparison)
|
||||
|
||||
performance_data["benchmarks"] = comparisons
|
||||
|
||||
return performance_data
|
||||
|
||||
def _calculate_percentile(self, data: List[float], percentile: int) -> float:
|
||||
"""
|
||||
计算百分位数
|
||||
|
||||
Args:
|
||||
data: 数据列表
|
||||
percentile: 百分位数
|
||||
|
||||
Returns:
|
||||
百分位数值
|
||||
"""
|
||||
sorted_data = sorted(data)
|
||||
index = (percentile / 100) * (len(sorted_data) - 1)
|
||||
|
||||
if index.is_integer():
|
||||
return sorted_data[int(index)]
|
||||
else:
|
||||
lower = sorted_data[int(index)]
|
||||
upper = sorted_data[int(index) + 1]
|
||||
return lower + (upper - lower) * (index - int(index))
|
||||
|
||||
def _calculate_summary(self, results: List[TestResult]) -> Dict[str, Any]:
|
||||
"""
|
||||
计算测试摘要
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
|
||||
Returns:
|
||||
测试摘要
|
||||
"""
|
||||
total = len(results)
|
||||
passed = sum(1 for r in results if r.success)
|
||||
failed = total - passed
|
||||
response_times = [r.response_time for r in results if r.response_time > 0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": failed,
|
||||
"pass_rate": (passed / total * 100) if total > 0 else 0,
|
||||
"avg_response_time": statistics.mean(response_times) if response_times else 0
|
||||
}
|
||||
|
||||
def _generate_trend_html(self, trend_data: List[TrendData]) -> str:
|
||||
"""生成趋势分析HTML"""
|
||||
# 这里简化实现,实际应该使用图表库(如Chart.js)
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>趋势分析报告</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; }}
|
||||
.trend-table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
|
||||
.trend-table th, .trend-table td {{ border: 1px solid #ddd; padding: 8px; text-align: center; }}
|
||||
.trend-table th {{ background-color: #4CAF50; color: white; }}
|
||||
.trend-table tr:nth-child(even) {{ background-color: #f2f2f2; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>测试趋势分析报告</h1>
|
||||
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
|
||||
<table class="trend-table">
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>总数</th>
|
||||
<th>通过</th>
|
||||
<th>失败</th>
|
||||
<th>通过率</th>
|
||||
<th>平均响应时间</th>
|
||||
</tr>
|
||||
{"".join([f"""
|
||||
<tr>
|
||||
<td>{d.date}</td>
|
||||
<td>{d.total}</td>
|
||||
<td>{d.passed}</td>
|
||||
<td>{d.failed}</td>
|
||||
<td>{d.pass_rate:.2f}%</td>
|
||||
<td>{d.avg_response_time:.2f}ms</td>
|
||||
</tr>
|
||||
""" for d in trend_data])}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def _generate_comparison_html(self, comparison_data: Dict[str, Any]) -> str:
|
||||
"""生成对比分析HTML"""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试对比报告</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; }}
|
||||
.comparison-table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
|
||||
.comparison-table th, .comparison-table td {{ border: 1px solid #ddd; padding: 8px; text-align: center; }}
|
||||
.comparison-table th {{ background-color: #4CAF50; color: white; }}
|
||||
.positive {{ color: green; }}
|
||||
.negative {{ color: red; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>测试对比报告</h1>
|
||||
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
|
||||
<table class="trend-table">
|
||||
<tr>
|
||||
<th>指标</th>
|
||||
<th>当前</th>
|
||||
<th>之前</th>
|
||||
<th>变化</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>总数</td>
|
||||
<td>{comparison_data['current']['total']}</td>
|
||||
<td>{comparison_data['previous']['total']}</td>
|
||||
<td class="{'positive' if comparison_data['changes']['total'] >= 0 else 'negative'}">
|
||||
{comparison_data['changes']['total']:+d}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>通过</td>
|
||||
<td>{comparison_data['current']['passed']}</td>
|
||||
<td>{comparison_data['previous']['passed']}</td>
|
||||
<td class="{'positive' if comparison_data['changes']['passed'] >= 0 else 'negative'}">
|
||||
{comparison_data['changes']['passed']:+d}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>失败</td>
|
||||
<td>{comparison_data['current']['failed']}</td>
|
||||
<td>{comparison_data['previous']['failed']}</td>
|
||||
<td class="{'negative' if comparison_data['changes']['failed'] > 0 else 'positive'}">
|
||||
{comparison_data['changes']['failed']:+d}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>通过率</td>
|
||||
<td>{comparison_data['current']['pass_rate']:.2f}%</td>
|
||||
<td>{comparison_data['previous']['pass_rate']:.2f}%</td>
|
||||
<td class="{'positive' if comparison_data['changes']['pass_rate'] >= 0 else 'negative'}">
|
||||
{comparison_data['changes']['pass_rate']:+.2f}%
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def _generate_performance_html(self, performance_data: Dict[str, Any]) -> str:
|
||||
"""生成性能分析HTML"""
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>性能分析报告</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; padding: 20px; }}
|
||||
.performance-table {{ width: 100%; border-collapse: collapse; margin-top: 20px; }}
|
||||
.performance-table th, .performance-table td {{ border: 1px solid #ddd; padding: 8px; text-align: center; }}
|
||||
.performance-table th {{ background-color: #4CAF50; color: white; }}
|
||||
.good {{ color: green; }}
|
||||
.warning {{ color: orange; }}
|
||||
.critical {{ color: red; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>性能分析报告</h1>
|
||||
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
|
||||
|
||||
<h2>性能指标</h2>
|
||||
<table class="performance-table">
|
||||
<tr>
|
||||
<th>指标</th>
|
||||
<th>值</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>平均响应时间</td>
|
||||
<td>{performance_data.get('avg_response_time', 0):.2f}ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>最小响应时间</td>
|
||||
<td>{performance_data.get('min_response_time', 0):.2f}ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>最大响应时间</td>
|
||||
<td>{performance_data.get('max_response_time', 0):.2f}ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>中位数响应时间</td>
|
||||
<td>{performance_data.get('median_response_time', 0):.2f}ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>P95响应时间</td>
|
||||
<td>{performance_data.get('p95_response_time', 0):.2f}ms</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>P99响应时间</td>
|
||||
<td>{performance_data.get('p99_response_time', 0):.2f}ms</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
def save_to_history(self, results: List[TestResult], summary: TestSummary) -> None:
|
||||
"""
|
||||
保存测试结果到历史记录
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
summary: 测试摘要
|
||||
"""
|
||||
date_str = datetime.now().strftime('%Y%m%d')
|
||||
history_file = self.history_dir / f"test_report_{date_str}.json"
|
||||
|
||||
report_data = {
|
||||
"summary": summary.to_dict(),
|
||||
"results": [r.to_dict() for r in results],
|
||||
"generated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(history_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
日志工具模块
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
class TestLogger:
|
||||
"""测试日志记录器"""
|
||||
|
||||
def __init__(self, name: str = "test", log_file: str = None, level: str = "INFO", console: bool = True):
|
||||
"""
|
||||
初始化日志记录器
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
log_file: 日志文件路径
|
||||
level: 日志级别
|
||||
console: 是否输出到控制台
|
||||
"""
|
||||
self.logger = logging.getLogger(name)
|
||||
self.logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
# 清除现有的处理器
|
||||
self.logger.handlers.clear()
|
||||
|
||||
# 创建格式化器
|
||||
formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
# 添加文件处理器
|
||||
if log_file:
|
||||
log_path = Path(log_file)
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setFormatter(formatter)
|
||||
self.logger.addHandler(file_handler)
|
||||
|
||||
# 添加控制台处理器
|
||||
if console:
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
"""记录调试信息"""
|
||||
self.logger.debug(message)
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
"""记录信息"""
|
||||
self.logger.info(message)
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
"""记录警告"""
|
||||
self.logger.warning(message)
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
"""记录错误"""
|
||||
self.logger.error(message)
|
||||
|
||||
def critical(self, message: str) -> None:
|
||||
"""记录严重错误"""
|
||||
self.logger.critical(message)
|
||||
|
||||
def exception(self, message: str) -> None:
|
||||
"""记录异常信息"""
|
||||
self.logger.exception(message)
|
||||
|
||||
def log_test_start(self, test_name: str) -> None:
|
||||
"""记录测试开始"""
|
||||
self.info(f"{'='*60}")
|
||||
self.info(f"开始测试: {test_name}")
|
||||
self.info(f"{'='*60}")
|
||||
|
||||
def log_test_end(self, test_name: str, passed: bool, duration: float = None) -> None:
|
||||
"""记录测试结束"""
|
||||
status = "✅ 通过" if passed else "❌ 失败"
|
||||
duration_str = f" (耗时: {duration:.2f}ms)" if duration else ""
|
||||
self.info(f"测试结束: {test_name} - {status}{duration_str}")
|
||||
|
||||
def log_request(self, method: str, url: str, data: dict = None, headers: dict = None) -> None:
|
||||
"""记录请求信息"""
|
||||
self.info(f"发送请求: {method} {url}")
|
||||
if data:
|
||||
self.info(f"请求数据: {data}")
|
||||
if headers:
|
||||
self.info(f"请求头: {headers}")
|
||||
|
||||
def log_response(self, status_code: int, response_time: float, data: dict = None) -> None:
|
||||
"""记录响应信息"""
|
||||
self.info(f"响应状态码: {status_code}")
|
||||
self.info(f"响应时间: {response_time:.2f}ms")
|
||||
if data:
|
||||
self.info(f"响应数据: {data}")
|
||||
|
||||
def log_validation(self, rule_type: str, passed: bool, message: str = "") -> None:
|
||||
"""记录验证结果"""
|
||||
status = "✅" if passed else "❌"
|
||||
self.info(f"{status} 验证规则 [{rule_type}]: {message or '通过'}")
|
||||
|
||||
def log_error(self, error: Exception) -> None:
|
||||
"""记录错误详情"""
|
||||
self.error(f"错误类型: {type(error).__name__}")
|
||||
self.error(f"错误信息: {str(error)}")
|
||||
self.exception("错误堆栈:")
|
||||
|
||||
def log_summary(self, total: int, passed: int, failed: int, skipped: int = 0, duration: float = None) -> None:
|
||||
"""记录测试摘要"""
|
||||
self.info(f"{'='*60}")
|
||||
self.info(f"测试摘要:")
|
||||
self.info(f" 总数: {total}")
|
||||
self.info(f" 通过: {passed}")
|
||||
self.info(f" 失败: {failed}")
|
||||
self.info(f" 跳过: {skipped}")
|
||||
if duration:
|
||||
self.info(f" 总耗时: {duration:.2f}ms")
|
||||
self.info(f" 通过率: {(passed/total*100):.2f}%")
|
||||
self.info(f"{'='*60}")
|
||||
|
||||
|
||||
class LoggerFactory:
|
||||
"""日志记录器工厂"""
|
||||
|
||||
_loggers = {}
|
||||
|
||||
@classmethod
|
||||
def get_logger(cls, name: str = "test", log_file: str = None, level: str = "INFO", console: bool = True) -> TestLogger:
|
||||
"""
|
||||
获取日志记录器实例
|
||||
|
||||
Args:
|
||||
name: 日志记录器名称
|
||||
log_file: 日志文件路径
|
||||
level: 日志级别
|
||||
console: 是否输出到控制台
|
||||
|
||||
Returns:
|
||||
日志记录器实例
|
||||
"""
|
||||
key = f"{name}_{log_file}_{level}_{console}"
|
||||
|
||||
if key not in cls._loggers:
|
||||
cls._loggers[key] = TestLogger(name, log_file, level, console)
|
||||
|
||||
return cls._loggers[key]
|
||||
|
||||
@classmethod
|
||||
def clear_all(cls) -> None:
|
||||
"""清除所有日志记录器"""
|
||||
cls._loggers.clear()
|
||||
@@ -0,0 +1,516 @@
|
||||
"""
|
||||
报告生成器模块
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, List
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
"""测试结果数据类"""
|
||||
test_name: str
|
||||
test_type: str
|
||||
url: str
|
||||
method: str
|
||||
status_code: int
|
||||
response_time: float
|
||||
success: bool
|
||||
error_message: str
|
||||
request_data: Dict[str, Any]
|
||||
response_data: Dict[str, Any]
|
||||
timestamp: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestSummary:
|
||||
"""测试摘要数据类"""
|
||||
total: int
|
||||
passed: int
|
||||
failed: int
|
||||
skipped: int
|
||||
pass_rate: float
|
||||
total_time: float
|
||||
start_time: str
|
||||
end_time: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class ReportGenerator:
|
||||
"""测试报告生成器"""
|
||||
|
||||
def __init__(self, output_dir: str = "reports"):
|
||||
"""
|
||||
初始化报告生成器
|
||||
|
||||
Args:
|
||||
output_dir: 报告输出目录
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def generate_json_report(self, results: List[TestResult], summary: TestSummary, filename: str = None) -> str:
|
||||
"""
|
||||
生成JSON格式报告
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
summary: 测试摘要
|
||||
filename: 报告文件名
|
||||
|
||||
Returns:
|
||||
报告文件路径
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"test_report_{timestamp}.json"
|
||||
|
||||
report_path = self.output_dir / filename
|
||||
|
||||
report_data = {
|
||||
"summary": summary.to_dict(),
|
||||
"results": [result.to_dict() for result in results],
|
||||
"generated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(report_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
return str(report_path)
|
||||
|
||||
def generate_html_report(self, results: List[TestResult], summary: TestSummary, filename: str = None) -> str:
|
||||
"""
|
||||
生成HTML格式报告
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
summary: 测试摘要
|
||||
filename: 报告文件名
|
||||
|
||||
Returns:
|
||||
报告文件路径
|
||||
"""
|
||||
if filename is None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"test_report_{timestamp}.html"
|
||||
|
||||
report_path = self.output_dir / filename
|
||||
|
||||
html_content = self._generate_html_content(results, summary)
|
||||
|
||||
with open(report_path, 'w', encoding='utf-8') as f:
|
||||
f.write(html_content)
|
||||
|
||||
return str(report_path)
|
||||
|
||||
def _generate_html_content(self, results: List[TestResult], summary: TestSummary) -> str:
|
||||
"""
|
||||
生成HTML内容
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
summary: 测试摘要
|
||||
|
||||
Returns:
|
||||
HTML内容
|
||||
"""
|
||||
passed_results = [r for r in results if r.success]
|
||||
failed_results = [r for r in results if not r.success]
|
||||
|
||||
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>测试报告 - {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</title>
|
||||
<style>
|
||||
* {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}}
|
||||
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}}
|
||||
|
||||
.container {{
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.header {{
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.header h1 {{
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.header p {{
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}}
|
||||
|
||||
.summary {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
padding: 30px;
|
||||
background: #f8f9fa;
|
||||
}}
|
||||
|
||||
.summary-card {{
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: center;
|
||||
}}
|
||||
|
||||
.summary-card h3 {{
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.summary-card .value {{
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}}
|
||||
|
||||
.summary-card.passed .value {{
|
||||
color: #52c41a;
|
||||
}}
|
||||
|
||||
.summary-card.failed .value {{
|
||||
color: #ff4d4f;
|
||||
}}
|
||||
|
||||
.progress-bar {{
|
||||
grid-column: 1 / -1;
|
||||
height: 30px;
|
||||
background: #e8e8e8;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
margin-top: 20px;
|
||||
}}
|
||||
|
||||
.progress-fill {{
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #52c41a 0%, #73d13d 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
transition: width 0.3s ease;
|
||||
}}
|
||||
|
||||
.results {{
|
||||
padding: 30px;
|
||||
}}
|
||||
|
||||
.results h2 {{
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
}}
|
||||
|
||||
.test-item {{
|
||||
background: white;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}}
|
||||
|
||||
.test-item.passed {{
|
||||
border-left: 4px solid #52c41a;
|
||||
}}
|
||||
|
||||
.test-item.failed {{
|
||||
border-left: 4px solid #ff4d4f;
|
||||
}}
|
||||
|
||||
.test-header {{
|
||||
padding: 15px 20px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}}
|
||||
|
||||
.test-header:hover {{
|
||||
background: #f0f0f0;
|
||||
}}
|
||||
|
||||
.test-name {{
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}}
|
||||
|
||||
.test-status {{
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
|
||||
.test-status.passed {{
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}}
|
||||
|
||||
.test-status.failed {{
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
}}
|
||||
|
||||
.test-details {{
|
||||
padding: 20px;
|
||||
display: none;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
}}
|
||||
|
||||
.test-item.expanded .test-details {{
|
||||
display: block;
|
||||
}}
|
||||
|
||||
.detail-row {{
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
|
||||
.detail-label {{
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
margin-right: 10px;
|
||||
}}
|
||||
|
||||
.detail-value {{
|
||||
color: #333;
|
||||
}}
|
||||
|
||||
.error-message {{
|
||||
background: #fff1f0;
|
||||
color: #ff4d4f;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
|
||||
.code-block {{
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
margin-top: 10px;
|
||||
}}
|
||||
|
||||
.footer {{
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}}
|
||||
|
||||
@media (max-width: 768px) {{
|
||||
.summary {{
|
||||
grid-template-columns: 1fr;
|
||||
}}
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>API测试报告</h1>
|
||||
<p>生成时间: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<h3>测试总数</h3>
|
||||
<div class="value">{summary.total}</div>
|
||||
</div>
|
||||
<div class="summary-card passed">
|
||||
<h3>通过</h3>
|
||||
<div class="value">{summary.passed}</div>
|
||||
</div>
|
||||
<div class="summary-card failed">
|
||||
<h3>失败</h3>
|
||||
<div class="value">{summary.failed}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>跳过</h3>
|
||||
<div class="value">{summary.skipped}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>通过率</h3>
|
||||
<div class="value">{summary.pass_rate:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>总耗时</h3>
|
||||
<div class="value">{summary.total_time:.0f}ms</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {summary.pass_rate}%">
|
||||
{summary.pass_rate:.1f}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="results">
|
||||
<h2>测试结果</h2>
|
||||
<div id="test-results">
|
||||
"""
|
||||
|
||||
for result in results:
|
||||
status_class = "passed" if result.success else "failed"
|
||||
status_text = "通过" if result.success else "失败"
|
||||
|
||||
html += f"""
|
||||
<div class="test-item {status_class}" onclick="toggleDetails(this)">
|
||||
<div class="test-header">
|
||||
<span class="test-name">{result.test_name}</span>
|
||||
<span class="test-status {status_class}">{status_text}</span>
|
||||
</div>
|
||||
<div class="test-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">测试类型:</span>
|
||||
<span class="detail-value">{result.test_type}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">请求方法:</span>
|
||||
<span class="detail-value">{result.method}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">请求URL:</span>
|
||||
<span class="detail-value">{result.url}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">状态码:</span>
|
||||
<span class="detail-value">{result.status_code}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">响应时间:</span>
|
||||
<span class="detail-value">{result.response_time:.2f}ms</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">时间戳:</span>
|
||||
<span class="detail-value">{result.timestamp}</span>
|
||||
</div>
|
||||
{self._generate_request_details(result)}
|
||||
{self._generate_response_details(result)}
|
||||
{self._generate_error_details(result)}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Everything is Suitable - API测试工具</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleDetails(element) {
|
||||
element.classList.toggle('expanded');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
def _generate_request_details(self, result: TestResult) -> str:
|
||||
"""生成请求详情"""
|
||||
if not result.request_data:
|
||||
return ""
|
||||
|
||||
import json
|
||||
|
||||
return f"""
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">请求数据:</span>
|
||||
</div>
|
||||
<div class="code-block">{json.dumps(result.request_data, ensure_ascii=False, indent=2)}</div>
|
||||
"""
|
||||
|
||||
def _generate_response_details(self, result: TestResult) -> str:
|
||||
"""生成响应详情"""
|
||||
if not result.response_data:
|
||||
return ""
|
||||
|
||||
import json
|
||||
|
||||
return f"""
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">响应数据:</span>
|
||||
</div>
|
||||
<div class="code-block">{json.dumps(result.response_data, ensure_ascii=False, indent=2)}</div>
|
||||
"""
|
||||
|
||||
def _generate_error_details(self, result: TestResult) -> str:
|
||||
"""生成错误详情"""
|
||||
if result.success or not result.error_message:
|
||||
return ""
|
||||
|
||||
return f"""
|
||||
<div class="error-message">
|
||||
<strong>错误信息:</strong> {result.error_message}
|
||||
</div>
|
||||
"""
|
||||
|
||||
def generate_all_reports(self, results: List[TestResult], summary: TestSummary) -> Dict[str, str]:
|
||||
"""
|
||||
生成所有格式的报告
|
||||
|
||||
Args:
|
||||
results: 测试结果列表
|
||||
summary: 测试摘要
|
||||
|
||||
Returns:
|
||||
报告文件路径字典
|
||||
"""
|
||||
reports = {}
|
||||
|
||||
# 生成JSON报告
|
||||
json_path = self.generate_json_report(results, summary)
|
||||
reports['json'] = json_path
|
||||
|
||||
# 生成HTML报告
|
||||
html_path = self.generate_html_report(results, summary)
|
||||
reports['html'] = html_path
|
||||
|
||||
return reports
|
||||
Reference in New Issue
Block a user