PregoPal / tests /run_tests.py
J.B-Lin
chore: save current version before UI improvements (i18n + font fix)
ec90eae
Raw
History Blame Contribute Delete
9.96 kB
"""
PregoPal - 测试运行器
=====================
无需 pytest,直接运行所有测试并生成 HTML 测试报告。
"""
import sys
import os
import time
import traceback
import json
from datetime import datetime
from pathlib import Path
# 确保项目根目录在路径中
sys.path.insert(0, str(Path(__file__).parent.parent))
# ============================================================
# 测试框架(极简版)
# ============================================================
class TestResult:
def __init__(self, class_name, method_name, doc, passed, message=""):
self.class_name = class_name
self.method_name = method_name
self.doc = doc
self.passed = passed
self.message = message
@property
def full_name(self):
return f"{self.class_name}.{self.method_name}"
class TestRunner:
def __init__(self):
self.results = []
self.start_time = None
self.end_time = None
def run(self, test_module):
"""运行一个测试模块中的所有测试类"""
self.start_time = time.time()
for name in dir(test_module):
obj = getattr(test_module, name)
if isinstance(obj, type) and name.startswith("Test"):
self._run_test_class(obj)
self.end_time = time.time()
def _run_test_class(self, cls):
"""运行一个测试类中的所有测试方法"""
for name in dir(cls):
if not name.startswith("test_"):
continue
method = getattr(cls, name)
doc = method.__doc__ or name
# 每个测试方法创建独立实例,确保 setup/teardown 隔离
instance = cls()
# setup
if hasattr(instance, "setup_method"):
try:
instance.setup_method()
except Exception as e:
print(f" [WARN] setup_method failed for {cls.__name__}.{name}: {e}")
try:
method(instance)
self.results.append(TestResult(cls.__name__, name, doc, True))
except Exception as e:
tb = traceback.format_exc()
# 安全获取错误消息(避免 GBK UnicodeEncodeError)
try:
msg = str(e) if str(e) else tb[:500]
except UnicodeEncodeError:
msg = tb[:500]
self.results.append(TestResult(cls.__name__, name, doc, False, msg))
# teardown
if hasattr(instance, "teardown_method"):
try:
instance.teardown_method()
except Exception as e:
print(f" [WARN] teardown_method failed for {cls.__name__}.{name}: {e}")
@property
def total(self):
return len(self.results)
@property
def passed(self):
return sum(1 for r in self.results if r.passed)
@property
def failed(self):
return sum(1 for r in self.results if not r.passed)
@property
def duration(self):
if self.start_time and self.end_time:
return round(self.end_time - self.start_time, 2)
return 0
def generate_html_report(self, output_path="test_report.html"):
"""生成 HTML 测试报告"""
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
rows = ""
for r in self.results:
status = "PASS" if r.passed else "FAIL"
color = "#4CAF50" if r.passed else "#f44336"
message = f"<pre>{r.message}</pre>" if not r.passed else ""
rows += f"""
<tr>
<td>{status}</td>
<td>{r.class_name}</td>
<td>{r.method_name}</td>
<td>{r.doc}</td>
<td style="color:{color};font-weight:bold">{'通过' if r.passed else '失败'}</td>
<td>{message}</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>PregoPal 测试报告</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ font-family: -apple-system, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 20px; }}
.container {{ max-width: 1200px; margin: 0 auto; }}
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; border-radius: 12px; margin-bottom: 20px; }}
.header h1 {{ font-size: 28px; margin-bottom: 10px; }}
.header p {{ opacity: 0.9; }}
.summary {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 20px; }}
.summary-card {{ background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); text-align: center; }}
.summary-card .number {{ font-size: 36px; font-weight: bold; }}
.summary-card .label {{ color: #666; margin-top: 5px; }}
.summary-card.total .number {{ color: #2196F3; }}
.summary-card.passed .number {{ color: #4CAF50; }}
.summary-card.failed .number {{ color: #f44336; }}
.summary-card.rate .number {{ color: #FF9800; }}
table {{ width: 100%; background: white; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-collapse: collapse; }}
th {{ background: #f8f9fa; padding: 12px 15px; text-align: left; font-weight: 600; color: #333; border-bottom: 2px solid #dee2e6; }}
td {{ padding: 10px 15px; border-bottom: 1px solid #eee; }}
tr:hover {{ background: #f8f9fa; }}
pre {{ background: #f5f5f5; padding: 8px; border-radius: 4px; font-size: 12px; max-height: 100px; overflow: auto; }}
.footer {{ text-align: center; color: #999; margin-top: 20px; font-size: 14px; }}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>PregoPal 测试报告</h1>
<p>生成时间: {now} | 测试框架: 内置 TestRunner</p>
</div>
<div class="summary">
<div class="summary-card total">
<div class="number">{self.total}</div>
<div class="label">总用例数</div>
</div>
<div class="summary-card passed">
<div class="number">{self.passed}</div>
<div class="label">通过</div>
</div>
<div class="summary-card failed">
<div class="number">{self.failed}</div>
<div class="label">失败</div>
</div>
<div class="summary-card rate">
<div class="number">{self.pass_rate:.1f}%</div>
<div class="label">通过率</div>
</div>
</div>
<table>
<thead>
<tr>
<th>状态</th>
<th>测试类</th>
<th>测试方法</th>
<th>描述</th>
<th>结果</th>
<th>错误信息</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
<div class="footer">
<p>PregoPal v5 | 测试耗时: {self.duration}秒 | {self.passed}/{self.total} 通过</p>
</div>
</div>
</body>
</html>"""
output_path = Path(output_path)
output_path.write_text(html, encoding='utf-8')
return output_path
@property
def pass_rate(self):
return (self.passed / self.total * 100) if self.total > 0 else 0
def print_summary(self):
"""打印控制台摘要"""
print("=" * 60)
print(" PregoPal 测试报告")
print(" " + "=" * 56)
print(f" 总用例: {self.total} | 通过: {self.passed} | 失败: {self.failed} | 通过率: {self.pass_rate:.1f}%")
print(f" 耗时: {self.duration}秒")
print("=" * 60)
if self.failed > 0:
print("\n 失败的测试:")
for r in self.results:
if not r.passed:
print(f" - {r.full_name}: {r.message}")
print()
# ============================================================
# 主入口
# ============================================================
def main():
"""运行所有测试并生成报告"""
import importlib
test_modules = [
"tests.test_nutrition_standards",
"tests.test_family_manager",
"tests.test_diet_extractor",
"tests.test_loop",
"tests.test_plugins",
]
runner = TestRunner()
for module_name in test_modules:
try:
mod = importlib.import_module(module_name)
print(f"[TEST] 正在测试: {module_name}")
runner.run(mod)
except Exception as e:
print(f"[ERROR] 导入模块失败 {module_name}: {e}")
runner.print_summary()
# 生成 HTML 报告
report_path = runner.generate_html_report("test_report.html")
print(f"[DONE] HTML 报告已生成: {report_path.resolve()}")
# 生成 JSON 报告
json_path = Path("test_report.json")
json_data = {
"total": runner.total,
"passed": runner.passed,
"failed": runner.failed,
"pass_rate": runner.pass_rate,
"duration": runner.duration,
"timestamp": datetime.now().isoformat(),
"results": [
{
"class": r.class_name,
"method": r.method_name,
"doc": r.doc,
"passed": r.passed,
"message": r.message
}
for r in runner.results
]
}
json_path.write_text(json.dumps(json_data, ensure_ascii=False, indent=2), encoding='utf-8')
print(f"[DONE] JSON 报告已生成: {json_path.resolve()}")
return 0 if runner.failed == 0 else 1
if __name__ == "__main__":
sys.exit(main())