Files
novalon-manage-system/scripts/test_report_generator.py
T
张翔 e2ad1331cc feat: 添加测试框架和覆盖率报告功能
feat(测试): 新增Playwright和Vitest测试配置
feat(测试): 添加测试覆盖率报告生成功能
feat(测试): 实现前后端测试脚本集成

fix(测试): 修复测试密码不匹配问题
fix(测试): 修正URL等待策略
fix(测试): 调整错误消息选择器

refactor(测试): 重构测试目录结构
refactor(测试): 优化测试用例组织方式

docs: 更新测试报告文档
docs: 添加测试覆盖率报告模板

ci: 添加Docker测试环境配置
ci: 实现测试自动化脚本

chore: 更新依赖版本
chore: 添加测试相关配置文件
2026-03-25 09:03:37 +08:00

734 lines
22 KiB
Python

#!/usr/bin/env python3
"""
测试报告生成脚本
生成详细的HTML测试报告,包含测试结果、截图、缺陷统计等
"""
import json
import os
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Optional
from dataclasses import dataclass, asdict
import base64
@dataclass
class TestCase:
"""测试用例"""
id: str
name: str
description: str
status: str # passed, failed, skipped
duration: float
error_message: Optional[str] = None
screenshot_path: Optional[str] = None
steps: List[str] = None
timestamp: str = None
def __post_init__(self):
if self.steps is None:
self.steps = []
self.timestamp = datetime.now().isoformat()
@dataclass
class TestSuite:
"""测试套件"""
name: str
test_cases: List[TestCase]
start_time: str
end_time: str
total_duration: float
@property
def total_tests(self) -> int:
return len(self.test_cases)
@property
def passed_tests(self) -> int:
return len([tc for tc in self.test_cases if tc.status == "passed"])
@property
def failed_tests(self) -> int:
return len([tc for tc in self.test_cases if tc.status == "failed"])
@property
def skipped_tests(self) -> int:
return len([tc for tc in self.test_cases if tc.status == "skipped"])
@property
def pass_rate(self) -> float:
if self.total_tests == 0:
return 0.0
return (self.passed_tests / self.total_tests) * 100
class TestReportGenerator:
"""测试报告生成器"""
def __init__(self, output_dir: str = "test_reports"):
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.test_suites: List[TestSuite] = []
def add_test_suite(self, test_suite: TestSuite):
"""添加测试套件"""
self.test_suites.append(test_suite)
def generate_html_report(self) -> str:
"""生成HTML报告"""
html_content = self._generate_html()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_file = self.output_dir / f"test_report_{timestamp}.html"
with open(report_file, 'w', encoding='utf-8') as f:
f.write(html_content)
return str(report_file)
def generate_json_report(self) -> str:
"""生成JSON报告"""
report_data = {
"generated_at": datetime.now().isoformat(),
"test_suites": [asdict(suite) for suite in self.test_suites],
"summary": self._generate_summary()
}
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_file = self.output_dir / f"test_report_{timestamp}.json"
with open(report_file, 'w', encoding='utf-8') as f:
json.dump(report_data, f, indent=2, ensure_ascii=False)
return str(report_file)
def _generate_summary(self) -> Dict:
"""生成测试摘要"""
total_tests = sum(suite.total_tests for suite in self.test_suites)
total_passed = sum(suite.passed_tests for suite in self.test_suites)
total_failed = sum(suite.failed_tests for suite in self.test_suites)
total_skipped = sum(suite.skipped_tests for suite in self.test_suites)
total_duration = sum(suite.total_duration for suite in self.test_suites)
overall_pass_rate = (total_passed / total_tests * 100) if total_tests > 0 else 0
# 统计缺陷
defects = []
for suite in self.test_suites:
for test_case in suite.test_cases:
if test_case.status == "failed" and test_case.error_message:
defects.append({
"test_case": test_case.name,
"test_suite": suite.name,
"error": test_case.error_message,
"timestamp": test_case.timestamp
})
return {
"total_tests": total_tests,
"total_passed": total_passed,
"total_failed": total_failed,
"total_skipped": total_skipped,
"overall_pass_rate": overall_pass_rate,
"total_duration": total_duration,
"defects": defects,
"test_suites_count": len(self.test_suites)
}
def _generate_html(self) -> str:
"""生成HTML内容"""
summary = self._generate_summary()
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>E2E和UAT测试报告</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, 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.2);
overflow: hidden;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}}
.header h1 {{
font-size: 2.5em;
margin-bottom: 10px;
}}
.header p {{
font-size: 1.1em;
opacity: 0.9;
}}
.summary {{
padding: 30px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}}
.summary-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-top: 20px;
}}
.summary-card {{
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}}
.summary-card h3 {{
font-size: 2em;
margin-bottom: 5px;
}}
.summary-card p {{
color: #666;
font-size: 0.9em;
}}
.summary-card.total h3 {{ color: #667eea; }}
.summary-card.passed h3 {{ color: #28a745; }}
.summary-card.failed h3 {{ color: #dc3545; }}
.summary-card.rate h3 {{ color: #17a2b8; }}
.test-suites {{
padding: 30px;
}}
.test-suite {{
margin-bottom: 30px;
border: 1px solid #e9ecef;
border-radius: 8px;
overflow: hidden;
}}
.test-suite-header {{
background: #f8f9fa;
padding: 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}}
.test-suite-header h2 {{
margin: 0;
color: #667eea;
}}
.test-suite-stats {{
display: flex;
gap: 15px;
}}
.stat-badge {{
padding: 5px 15px;
border-radius: 20px;
font-size: 0.9em;
font-weight: bold;
}}
.stat-badge.total {{ background: #667eea; color: white; }}
.stat-badge.passed {{ background: #28a745; color: white; }}
.stat-badge.failed {{ background: #dc3545; color: white; }}
.stat-badge.duration {{ background: #17a2b8; color: white; }}
.test-cases {{
padding: 20px;
}}
.test-case {{
border: 1px solid #e9ecef;
border-radius: 6px;
margin-bottom: 15px;
overflow: hidden;
}}
.test-case-header {{
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: background 0.3s;
}}
.test-case-header:hover {{
background: #f8f9fa;
}}
.test-case-info {{
display: flex;
align-items: center;
gap: 10px;
}}
.test-case-id {{
font-weight: bold;
color: #667eea;
}}
.test-case-name {{
font-weight: 500;
}}
.test-case-status {{
padding: 5px 12px;
border-radius: 4px;
font-size: 0.85em;
font-weight: bold;
text-transform: uppercase;
}}
.test-case-status.passed {{ background: #d4edda; color: #155724; }}
.test-case-status.failed {{ background: #f8d7da; color: #721c24; }}
.test-case-status.skipped {{ background: #fff3cd; color: #856404; }}
.test-case-details {{
padding: 15px;
border-top: 1px solid #e9ecef;
display: none;
}}
.test-case-details.show {{
display: block;
}}
.test-case-description {{
margin-bottom: 15px;
color: #666;
}}
.test-case-steps {{
margin-bottom: 15px;
}}
.test-case-steps h4 {{
margin-bottom: 10px;
color: #333;
}}
.test-case-steps ul {{
list-style: none;
padding-left: 0;
}}
.test-case-steps li {{
padding: 5px 0;
padding-left: 20px;
position: relative;
}}
.test-case-steps li:before {{
content: "";
position: absolute;
left: 0;
color: #28a745;
font-weight: bold;
}}
.test-case-error {{
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
}}
.test-case-error h4 {{
color: #721c24;
margin-bottom: 10px;
}}
.test-case-error pre {{
background: #f5c6cb;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 0.9em;
}}
.test-case-screenshot {{
margin-top: 15px;
}}
.test-case-screenshot img {{
max-width: 100%;
border: 1px solid #e9ecef;
border-radius: 4px;
cursor: pointer;
}}
.test-case-meta {{
display: flex;
gap: 20px;
color: #666;
font-size: 0.9em;
margin-top: 10px;
}}
.defects-section {{
padding: 30px;
background: #fff3cd;
border-top: 1px solid #ffeeba;
}}
.defects-section h2 {{
color: #856404;
margin-bottom: 20px;
}}
.defect-item {{
background: white;
border: 1px solid #ffeeba;
border-radius: 6px;
padding: 15px;
margin-bottom: 15px;
}}
.defect-item h3 {{
color: #856404;
margin-bottom: 10px;
}}
.defect-item p {{
color: #666;
margin-bottom: 5px;
}}
.footer {{
padding: 20px;
text-align: center;
background: #f8f9fa;
color: #666;
border-top: 1px solid #e9ecef;
}}
@media (max-width: 768px) {{
.summary-grid {{
grid-template-columns: 1fr;
}}
.test-suite-header {{
flex-direction: column;
align-items: flex-start;
gap: 10px;
}}
.test-case-header {{
flex-direction: column;
align-items: flex-start;
gap: 10px;
}}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🧪 E2E和UAT测试报告</h1>
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="summary">
<h2>📊 测试摘要</h2>
<div class="summary-grid">
<div class="summary-card total">
<h3>{summary['total_tests']}</h3>
<p>总测试数</p>
</div>
<div class="summary-card passed">
<h3>{summary['total_passed']}</h3>
<p>通过测试</p>
</div>
<div class="summary-card failed">
<h3>{summary['total_failed']}</h3>
<p>失败测试</p>
</div>
<div class="summary-card rate">
<h3>{summary['overall_pass_rate']:.1f}%</h3>
<p>通过率</p>
</div>
</div>
</div>
<div class="test-suites">
<h2>📋 测试套件</h2>
"""
# 生成测试套件内容
for suite in self.test_suites:
html += f"""
<div class="test-suite">
<div class="test-suite-header">
<h2>{suite.name}</h2>
<div class="test-suite-stats">
<span class="stat-badge total">总计: {suite.total_tests}</span>
<span class="stat-badge passed">通过: {suite.passed_tests}</span>
<span class="stat-badge failed">失败: {suite.failed_tests}</span>
<span class="stat-badge duration">耗时: {suite.total_duration:.2f}s</span>
</div>
</div>
<div class="test-cases">
"""
# 生成测试用例
for test_case in suite.test_cases:
status_class = test_case.status
status_text = "通过" if test_case.status == "passed" else "失败" if test_case.status == "failed" else "跳过"
html += f"""
<div class="test-case">
<div class="test-case-header" onclick="toggleDetails('{test_case.id}')">
<div class="test-case-info">
<span class="test-case-id">{test_case.id}</span>
<span class="test-case-name">{test_case.name}</span>
</div>
<span class="test-case-status {status_class}">{status_text}</span>
</div>
<div class="test-case-details" id="details-{test_case.id}">
<p class="test-case-description">{test_case.description}</p>
<div class="test-case-steps">
<h4>测试步骤:</h4>
<ul>
"""
for step in test_case.steps:
html += f" <li>{step}</li>\n"
html += """
</ul>
</div>
"""
# 错误信息
if test_case.error_message:
html += f"""
<div class="test-case-error">
<h4>❌ 错误信息:</h4>
<pre>{test_case.error_message}</pre>
</div>
"""
# 截图
if test_case.screenshot_path and Path(test_case.screenshot_path).exists():
try:
with open(test_case.screenshot_path, 'rb') as img_file:
img_data = base64.b64encode(img_file.read()).decode()
html += f"""
<div class="test-case-screenshot">
<h4>📸 失败截图:</h4>
<img src="data:image/png;base64,{img_data}" alt="测试失败截图" onclick="window.open(this.src)">
</div>
"""
except Exception as e:
html += f"""
<div class="test-case-screenshot">
<h4>📸 失败截图:</h4>
<p>截图加载失败: {str(e)}</p>
</div>
"""
html += f"""
<div class="test-case-meta">
<span>⏱️ 耗时: {test_case.duration:.2f}s</span>
<span>🕐 时间: {test_case.timestamp}</span>
</div>
</div>
</div>
"""
html += """
</div>
</div>
"""
# 缺陷部分
if summary['defects']:
html += """
</div>
<div class="defects-section">
<h2>🐛 缺陷统计</h2>
"""
for defect in summary['defects']:
html += f"""
<div class="defect-item">
<h3>❌ {defect['test_case']}</h3>
<p><strong>测试套件:</strong> {defect['test_suite']}</p>
<p><strong>错误:</strong> {defect['error']}</p>
<p><strong>时间:</strong> {defect['timestamp']}</p>
</div>
"""
html += """
</div>
"""
html += """
<div class="footer">
<p>🤖 自动化测试系统 | Novalon Manage System</p>
</div>
</div>
<script>
function toggleDetails(testCaseId) {
const details = document.getElementById('details-' + testCaseId);
details.classList.toggle('show');
}
</script>
</body>
</html>
"""
return html
def create_sample_report():
"""创建示例报告"""
generator = TestReportGenerator()
# 创建测试用例
test_cases = [
TestCase(
id="TC-001",
name="完整登录流程",
description="测试管理员和普通用户的完整登录流程,包括UI登录和API验证",
status="passed",
duration=12.5,
steps=[
"打开登录页面",
"输入管理员用户名和密码",
"点击登录按钮",
"验证跳转到Dashboard页面",
"验证Dashboard数据加载",
"通过API验证登录日志",
"测试普通用户登录"
]
),
TestCase(
id="TC-002",
name="角色管理完整流程",
description="测试角色管理的完整流程,包括创建、编辑、删除角色",
status="passed",
duration=8.3,
steps=[
"登录系统",
"导航到角色管理页面",
"验证角色列表显示",
"创建新角色",
"编辑角色信息",
"删除角色"
]
),
TestCase(
id="TC-003",
name="菜单管理数据验证",
description="验证菜单管理页面的数据结构和字段映射",
status="passed",
duration=6.2,
steps=[
"登录系统",
"导航到菜单管理页面",
"验证菜单树结构",
"验证一级菜单显示",
"验证二级菜单显示",
"通过API验证字段映射"
]
),
TestCase(
id="TC-004",
name="前后端字段映射一致性",
description="验证前后端API的字段映射一致性",
status="passed",
duration=4.8,
steps=[
"登录系统",
"验证角色API字段映射",
"验证菜单API字段映射",
"验证用户API字段映射"
]
),
TestCase(
id="TC-005",
name="RBAC权限验证",
description="验证基于角色的访问控制权限",
status="passed",
duration=7.1,
steps=[
"测试管理员权限",
"验证管理员可访问所有菜单",
"测试普通用户权限",
"验证普通用户只能看到授权菜单",
"测试未授权访问拦截"
]
)
]
# 创建测试套件
test_suite = TestSuite(
name="E2E和UAT测试套件",
test_cases=test_cases,
start_time=datetime.now().isoformat(),
end_time=datetime.now().isoformat(),
total_duration=sum(tc.duration for tc in test_cases)
)
generator.add_test_suite(test_suite)
# 生成报告
html_report = generator.generate_html_report()
json_report = generator.generate_json_report()
print(f"✅ HTML报告已生成: {html_report}")
print(f"✅ JSON报告已生成: {json_report}")
return generator
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="测试报告生成器")
parser.add_argument("--output-dir", default="test_reports", help="报告输出目录")
parser.add_argument("--sample", action="store_true", help="生成示例报告")
args = parser.parse_args()
if args.sample:
generator = create_sample_report()
else:
print("请提供测试数据或使用 --sample 生成示例报告")
print("示例: python test_report_generator.py --sample")