e2ad1331cc
feat(测试): 新增Playwright和Vitest测试配置 feat(测试): 添加测试覆盖率报告生成功能 feat(测试): 实现前后端测试脚本集成 fix(测试): 修复测试密码不匹配问题 fix(测试): 修正URL等待策略 fix(测试): 调整错误消息选择器 refactor(测试): 重构测试目录结构 refactor(测试): 优化测试用例组织方式 docs: 更新测试报告文档 docs: 添加测试覆盖率报告模板 ci: 添加Docker测试环境配置 ci: 实现测试自动化脚本 chore: 更新依赖版本 chore: 添加测试相关配置文件
734 lines
22 KiB
Python
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") |