feat(e2e-tests): 添加端到端测试框架及测试用例

refactor(components): 调整头部和页脚布局样式
style(hero-section): 更新徽章动画效果

docs: 添加测试框架README文档
test: 实现首页、导航和联系表单的测试用例
ci: 添加CI测试脚本和配置
This commit is contained in:
张翔
2026-02-02 19:36:33 +08:00
parent 150024b6ac
commit f14002559e
30 changed files with 6377 additions and 17 deletions
+1
View File
@@ -0,0 +1 @@
# Utils模块
+413
View File
@@ -0,0 +1,413 @@
"""
测试数据生成模块
提供测试过程中需要的各种测试数据生成功能
"""
import random
import string
import uuid
import re
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple, Union
from dataclasses import dataclass, field
from faker import Faker
from config.settings import get_settings
class ChineseFaker:
"""中文测试数据生成器"""
def __init__(self, locale: str = "zh_CN"):
self.faker_zh = Faker("zh_CN")
self.faker_en = Faker("en_US")
def name(self) -> str:
"""生成中文姓名"""
return self.faker_zh.name()
def first_name(self) -> str:
"""生成中文名字"""
return self.faker_zh.first_name()
def last_name(self) -> str:
"""生成中文姓氏"""
return self.faker_zh.last_name()
def phone_number(self) -> str:
"""生成中国手机号"""
# 生成以13-19开头的11位手机号
prefix = random.choice(["13", "14", "15", "16", "17", "18", "19"])
suffix = "".join(random.choices(string.digits, k=8))
return prefix + suffix
def email(self, domain: Optional[str] = None) -> str:
"""生成邮箱"""
if domain:
return self.faker_zh.email(domain=domain)
return self.faker_zh.email()
def company_name(self) -> str:
"""生成公司名称"""
prefixes = ["四川", "成都", "西部", "西南", "中国", "高新"]
suffixes = ["科技", "信息", "网络", "软件", "数据", "智能", "创新", "未来"]
name = f"{random.choice(prefixes)}{self.faker_zh.company()}{random.choice(suffixes)}"
return name
def address(self) -> str:
"""生成中文地址"""
return self.faker_zh.address().replace("\n", "")
def city(self) -> str:
"""生成城市名"""
return self.faker_zh.city()
def province(self) -> str:
"""生成省份名"""
return self.faker_zh.province()
def job_title(self) -> str:
"""生成职位名称"""
titles = [
"软件工程师", "产品经理", "UI设计师", "测试工程师",
"项目主管", "技术总监", "架构师", "数据分析师",
"运维工程师", "产品运营", "市场经理", "销售代表"
]
return random.choice(titles)
def username(self) -> str:
"""生成用户名"""
return self.faker_zh.user_name()
def password(self, length: int = 12) -> str:
"""生成密码"""
chars = string.ascii_letters + string.digits + "!@#$%^&*"
return "".join(random.choices(chars, k=length))
def text(self, max_chars: int = 200) -> str:
"""生成随机中文文本"""
paragraphs = []
for _ in range(random.randint(1, 3)):
sentences = []
for _ in range(random.randint(3, 8)):
sentence_len = random.randint(10, 30)
sentence = self.faker_zh.sentence(nb_words=sentence_len)
sentences.append(sentence)
paragraphs.append("".join(sentences) + "")
return "".join(paragraphs)[:max_chars]
def sentence(self, nb_words: int = 20) -> str:
"""生成随机句子"""
return self.faker_zh.sentence(nb_words=nb_words)
def word(self) -> str:
"""生成随机词语"""
return self.faker_zh.word()
def words(self, nb: int = 5) -> List[str]:
"""生成随机词语列表"""
return self.faker_zh.words(nb=nb)
def date_of_birth(self, start_year: int = 1960, end_year: int = 2000) -> str:
"""生成出生日期"""
return self.faker_zh.date_of_birth(
minimum_age=end_year - datetime.now().year,
maximum_age=start_year - datetime.now().year
).strftime("%Y-%m-%d")
def credit_card_number(self) -> str:
"""生成信用卡号(测试用)"""
return self.faker_zh.credit_card_number()
def credit_card_provider(self) -> str:
"""生成信用卡提供商"""
providers = ["Visa", "MasterCard", "银联", "JCB", "American Express"]
return random.choice(providers)
def ipv4(self) -> str:
"""生成IPv4地址"""
return self.faker_zh.ipv4()
def mac_address(self) -> str:
"""生成MAC地址"""
return self.faker_zh.mac_address()
def url(self) -> str:
"""生成URL"""
return self.faker_zh.url()
def uri_path(self) -> str:
"""生成URI路径"""
return self.faker_zh.uri_path()
def user_agent(self) -> str:
"""生成User-Agent"""
return self.faker_zh.user_agent()
def hex_color(self) -> str:
"""生成十六进制颜色"""
return self.faker_zh.hex_color()
def rgb_color(self) -> Tuple[int, int, int]:
"""生成RGB颜色"""
return self.faker_zh.rgb_color()
class EnglishFaker:
"""英文测试数据生成器"""
def __init__(self):
self.faker = Faker("en_US")
def name(self) -> str:
"""生成英文姓名"""
return self.faker.name()
def first_name(self) -> str:
"""生成英文名字"""
return self.faker.first_name()
def last_name(self) -> str:
"""生成英文姓氏"""
return self.faker.last_name()
def email(self, domain: Optional[str] = None) -> str:
"""生成邮箱"""
if domain:
return self.faker.email(domain=domain)
return self.faker.email()
def phone_number(self) -> str:
"""生成美国电话号码"""
return self.faker.phone_number()
def company(self) -> str:
"""生成公司名称"""
return self.faker.company()
def address(self) -> str:
"""生成地址"""
return self.faker.address().replace("\n", ", ")
def city(self) -> str:
"""生成城市名"""
return self.faker.city()
def state(self) -> str:
"""生成州/省名"""
return self.faker.state()
def country(self) -> str:
"""生成国家名"""
return self.faker.country()
def zip_code(self) -> str:
"""生成邮编"""
return self.faker.zipcode()
def username(self) -> str:
"""生成用户名"""
return self.faker.user_name()
def password(self, length: int = 12) -> str:
"""生成密码"""
chars = string.ascii_letters + string.digits + "!@#$%^&*"
return "".join(random.choices(chars, k=length))
def text(self, max_chars: int = 200) -> str:
"""生成随机英文文本"""
return self.faker.text(max_nb_chars=max_chars)
def sentence(self, nb_words: int = 10) -> str:
"""生成随机句子"""
return self.faker.sentence(nb_words=nb_words)
def paragraph(self, nb_sentences: int = 3) -> str:
"""生成段落"""
return self.faker.paragraph(nb_sentences=nb_sentences)
def date_of_birth(self, start_year: int = 1960, end_year: int = 2000) -> str:
"""生成出生日期"""
return self.faker.date_of_birth(
minimum_age=end_year - datetime.now().year,
maximum_age=start_year - datetime.now().year
).strftime("%Y-%m-%d")
def date_between(self, start_date: str, end_date: str) -> str:
"""生成日期范围"""
start = datetime.strptime(start_date, "%Y-%m-%d")
end = datetime.strptime(end_date, "%Y-%m-%d")
return self.faker.date_between(start, end).strftime("%Y-%m-%d")
class TestDataGenerator:
"""测试数据生成器主类"""
def __init__(self):
self.settings = get_settings()
self.zh_faker = ChineseFaker()
self.en_faker = EnglishFaker()
def generate_contact_form_data(
self,
use_valid: bool = True,
lang: str = "zh"
) -> Dict[str, str]:
"""生成联系表单数据"""
if use_valid:
if lang == "zh":
return {
"name": self.zh_faker.name(),
"phone": self.zh_faker.phone_number(),
"email": self.zh_faker.email(domain="example.com"),
"subject": self.zh_faker.sentence(nb_words=8),
"message": self.zh_faker.text(max_chars=300)
}
else:
return {
"name": self.en_faker.name(),
"phone": self.en_faker.phone_number(),
"email": self.en_faker.email(domain="example.com"),
"subject": self.en_faker.sentence(nb_words=8),
"message": self.en_faker.paragraph(nb_sentences=3)
}
else:
return self.settings.test_form_data.get("invalid", {
"email": "invalid-email",
"phone": "123"
})
def generate_user_profile(self, lang: str = "zh") -> Dict[str, Any]:
"""生成用户资料数据"""
if lang == "zh":
return {
"name": self.zh_faker.name(),
"email": self.zh_faker.email(domain="example.com"),
"phone": self.zh_faker.phone_number(),
"address": self.zh_faker.address(),
"job_title": self.zh_faker.job_title(),
"company": self.zh_faker.company_name(),
"date_of_birth": self.zh_faker.date_of_birth()
}
else:
return {
"name": self.en_faker.name(),
"email": self.en_faker.email(domain="example.com"),
"phone": self.en_faker.phone_number(),
"address": self.en_faker.address(),
"job_title": random.choice([
"Software Engineer", "Product Manager", "Designer",
"Marketing Manager", "Sales Representative", "Data Analyst"
]),
"company": self.en_faker.company(),
"date_of_birth": self.en_faker.date_of_birth()
}
def generate_search_query(self) -> str:
"""生成搜索查询"""
topics = [
"软件开发", "云计算", "人工智能", "数据分析",
"数字化转型", "企业服务", "智能制造", "物联网"
]
return random.choice(topics)
def generate_news_article(self) -> Dict[str, Any]:
"""生成新闻文章数据"""
return {
"title": self.zh_faker.sentence(nb_words=12),
"summary": self.zh_faker.text(max_chars=200),
"content": self.zh_faker.text(max_chars=1000),
"author": self.zh_faker.name(),
"publish_date": datetime.now().strftime("%Y-%m-%d"),
"category": random.choice(["公司新闻", "行业动态", "产品发布", "技术文章"])
}
def generate_product_data(self) -> Dict[str, Any]:
"""生成产品数据"""
products = [
{"name": "睿新ERP管理系统", "category": "企业软件"},
{"name": "睿新客户关系管理系统", "category": "企业软件"},
{"name": "睿新协同办公平台", "category": "企业软件"},
{"name": "睿新商业智能平台", "category": "数据产品"},
{"name": "睿新物联网平台", "category": "物联网"},
{"name": "睿新AI智能应用套件", "category": "人工智能"}
]
product = random.choice(products)
return {
"name": product["name"],
"category": product["category"],
"description": self.zh_faker.text(max_chars=300),
"features": random.sample([
"高性能", "高可用", "易扩展", "安全可靠",
"智能化", "云原生", "移动优先", "低代码"
], k=4),
"price": round(random.uniform(1000, 100000), 2)
}
def generate_company_info(self) -> Dict[str, str]:
"""生成公司信息"""
return {
"name": self.zh_faker.company_name(),
"short_name": "".join(self.zh_faker.company_name()[:4]),
"slogan": self.zh_faker.sentence(nb_words=6),
"description": self.zh_faker.text(max_chars=200),
"address": self.zh_faker.address(),
"phone": self.zh_faker.phone_number(),
"email": self.zh_faker.email(domain="example.com"),
"website": self.zh_faker.url()
}
def generate_dates(self, count: int = 10) -> List[str]:
"""生成日期列表"""
dates = []
base_date = datetime.now()
for i in range(count):
date = base_date - timedelta(days=random.randint(0, 365))
dates.append(date.strftime("%Y-%m-%d"))
return dates
def generate_unique_id(self) -> str:
"""生成唯一ID"""
return str(uuid.uuid4())
def generate_order_number(self) -> str:
"""生成订单号"""
prefix = "ORD"
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
random_suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=6))
return f"{prefix}{timestamp}{random_suffix}"
def generate_numeric_range(
self,
min_val: int = 1,
max_val: int = 100,
decimals: int = 0
) -> Union[int, float]:
"""生成数值范围"""
value = random.uniform(min_val, max_val)
return round(value, decimals) if decimals else int(value)
def generate_boolean(self) -> bool:
"""生成布尔值"""
return random.choice([True, False])
def generate_choice(self, options: List[Any]) -> Any:
"""从列表中随机选择一个"""
return random.choice(options)
def generate_color(self, format: str = "hex") -> Union[str, Tuple[int, int, int]]:
"""生成颜色值"""
if format == "hex":
return self.zh_faker.hex_color()
elif format == "rgb":
return self.zh_faker.rgb_color()
return self.zh_faker.hex_color()
# 全局测试数据生成器实例
test_data_generator = TestDataGenerator()
def get_test_data_generator() -> TestDataGenerator:
"""获取测试数据生成器"""
return test_data_generator
+584
View File
@@ -0,0 +1,584 @@
"""
辅助工具模块
提供常用的测试辅助函数和工具
"""
import os
import re
import time
import hashlib
import json
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple, Union, Callable
from urllib.parse import urlparse, parse_qs, urljoin
from functools import lru_cache
from playwright.sync_api import Page, Locator, FrameLocator
from playwright.sync_api import Error as PlaywrightError, TimeoutError as PlaywrightTimeoutError
from config.settings import get_settings
from utils.logger import get_logger, PerformanceTimer
class WaitCondition:
"""等待条件类"""
@staticmethod
def for_element_visible(selector: str, timeout: Optional[int] = None) -> Callable:
"""等待元素可见"""
def condition(page: Page) -> bool:
try:
element = page.locator(selector).first
return element.is_visible(timeout=timeout or 1000)
except (PlaywrightError, PlaywrightTimeoutError):
return False
return condition
@staticmethod
def for_element_hidden(selector: str, timeout: Optional[int] = None) -> Callable:
"""等待元素隐藏"""
def condition(page: Page) -> bool:
try:
element = page.locator(selector).first
return not element.is_visible(timeout=timeout or 1000)
except (PlaywrightError, PlaywrightTimeoutError):
return True
return condition
@staticmethod
def for_element_enabled(selector: str, timeout: Optional[int] = None) -> Callable:
"""等待元素可用"""
def condition(page: Page) -> bool:
try:
element = page.locator(selector).first
return element.is_enabled(timeout=timeout or 1000)
except (PlaywrightError, PlaywrightTimeoutError):
return False
return condition
@staticmethod
def for_url_change(expected_url: str, timeout: Optional[int] = None) -> Callable:
"""等待URL变化"""
def condition(page: Page) -> bool:
return expected_url in page.url
return condition
@staticmethod
def for_load_state(state: str = "networkidle", timeout: Optional[int] = None) -> Callable:
"""等待页面加载状态"""
def condition(page: Page) -> bool:
try:
page.wait_for_load_state(state, timeout=timeout)
return True
except PlaywrightTimeoutError:
return False
return condition
@staticmethod
def for_function_return_true(func: Callable[[], bool], timeout: int = 5000, interval: int = 100) -> Callable:
"""等待函数返回True"""
def condition(page: Page) -> bool:
start_time = time.time() * 1000
while time.time() * 1000 - start_time < timeout:
try:
result = page.evaluate(f"() => ({func.__code__.co_code})")
if result:
return True
except Exception:
pass
time.sleep(interval / 1000)
return False
return condition
class ElementHelper:
"""元素操作辅助类"""
def __init__(self, page: Page):
self.page = page
self.logger = get_logger()
def find_element(
self,
selector: str,
timeout: Optional[int] = None,
state: str = "visible"
) -> Locator:
"""查找元素"""
timeout = timeout or get_settings().element_timeout
locator = self.page.locator(selector).first
try:
locator.wait_for(state=state, timeout=timeout)
return locator
except PlaywrightTimeoutError:
raise PlaywrightTimeoutError(
f"未找到元素: {selector} (超时: {timeout}ms)"
)
def find_elements(self, selector: str) -> List[Locator]:
"""查找多个元素"""
return self.page.locator(selector).all()
def click_element(
self,
selector: str,
timeout: Optional[int] = None,
force: bool = False,
**kwargs
) -> None:
"""点击元素"""
self.logger.log_action(f"点击元素: {selector}")
element = self.find_element(selector, timeout)
element.click(force=force, **kwargs)
def fill_input(
self,
selector: str,
value: str,
timeout: Optional[int] = None,
clear: bool = True
) -> None:
"""填充输入框"""
self.logger.log_action(f"填充输入框: {selector} = '{value}'")
element = self.find_element(selector, timeout)
if clear:
element.clear()
element.fill(value)
def type_text(
self,
selector: str,
text: str,
timeout: Optional[int] = None,
delay: Optional[int] = None
) -> None:
"""输入文本(逐字符)"""
self.logger.log_action(f"输入文本: {selector} = '{text}'")
element = self.find_element(selector, timeout)
element.type(text, delay=delay)
def get_element_text(
self,
selector: str,
timeout: Optional[int] = None,
strip: bool = True
) -> str:
"""获取元素文本"""
element = self.find_element(selector, timeout)
text = element.text_content()
return text.strip() if strip and text else text
def get_element_attribute(
self,
selector: str,
attribute: str,
timeout: Optional[int] = None
) -> Optional[str]:
"""获取元素属性"""
element = self.find_element(selector, timeout)
return element.get_attribute(attribute)
def is_element_visible(
self,
selector: str,
timeout: Optional[int] = None
) -> bool:
"""检查元素是否可见"""
try:
element = self.find_element(selector, timeout)
return element.is_visible()
except PlaywrightTimeoutError:
return False
def is_element_enabled(
self,
selector: str,
timeout: Optional[int] = None
) -> bool:
"""检查元素是否可用"""
try:
element = self.find_element(selector, timeout)
return element.is_enabled()
except PlaywrightTimeoutError:
return False
def wait_for_selector(
self,
selector: str,
timeout: Optional[int] = None,
state: str = "visible"
) -> Locator:
"""等待选择器出现"""
timeout = timeout or get_settings().element_timeout
return self.page.wait_for_selector(selector, timeout=timeout, state=state)
def wait_for_url(
self,
pattern: Union[str, re.Pattern],
timeout: Optional[int] = None
) -> None:
"""等待URL匹配模式"""
timeout = timeout or get_settings().page_load_timeout
self.page.wait_for_url(pattern, timeout=timeout)
def wait_for_load_state(
self,
state: str = "networkidle",
timeout: Optional[int] = None
) -> None:
"""等待加载状态"""
timeout = timeout or get_settings().page_load_timeout
self.page.wait_for_load_state(state, timeout=timeout)
class PageHelper:
"""页面操作辅助类"""
def __init__(self, page: Page):
self.page = page
self.logger = get_logger()
self.element_helper = ElementHelper(page)
def navigate(
self,
url: str,
wait_until: str = "networkidle",
timeout: Optional[int] = None
) -> None:
"""导航到指定URL"""
self.logger.log_action(f"导航到: {url}")
timeout = timeout or get_settings().page_load_timeout
self.page.goto(url, wait_until=wait_until, timeout=timeout)
def reload_page(self, wait_until: str = "networkidle") -> None:
"""刷新页面"""
self.logger.log_action("刷新页面")
self.page.reload(wait_until=wait_until)
def go_back(self, wait_until: str = "networkidle") -> None:
"""返回上一页"""
self.logger.log_action("返回上一页")
self.page.go_back(wait_until=wait_until)
def go_forward(self, wait_until: str = "networkidle") -> None:
"""前进到下一页"""
self.logger.log_action("前进到下一页")
self.page.go_forward(wait_until=wait_until)
def scroll_to_top(self) -> None:
"""滚动到页面顶部"""
self.page.evaluate("window.scrollTo(0, 0)")
def scroll_to_bottom(self) -> None:
"""滚动到页面底部"""
self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
def scroll_to_element(self, selector: str) -> None:
"""滚动到指定元素"""
element = self.page.locator(selector).first
element.scroll_into_view_if_needed()
def get_current_url(self) -> str:
"""获取当前URL"""
return self.page.url
def get_page_title(self) -> str:
"""获取页面标题"""
return self.page.title()
def get_page_source(self) -> str:
"""获取页面源码"""
return self.page.content()
def take_screenshot(
self,
filename: str,
path: Optional[str] = None,
full_page: bool = False
) -> str:
"""截取页面截图"""
path = path or get_settings().screenshots_dir
Path(path).mkdir(parents=True, exist_ok=True)
filepath = str(Path(path) / filename)
self.page.screenshot(path=filepath, full_page=full_page)
self.logger.log_action(f"截图已保存: {filepath}")
return filepath
def execute_javascript(self, script: str, *args) -> Any:
"""执行JavaScript代码"""
return self.page.evaluate(script, *args)
def wait_for_load_state(self, state: str = "networkidle", timeout: Optional[int] = None) -> None:
"""等待页面加载状态"""
timeout = timeout or get_settings().page_load_timeout
self.page.wait_for_load_state(state, timeout=timeout)
class AssertionHelper:
"""断言辅助类"""
def __init__(self, page: Page):
self.page = page
self.logger = get_logger()
def assert_element_visible(
self,
selector: str,
timeout: Optional[int] = None,
message: Optional[str] = None
) -> None:
"""断言元素可见"""
try:
element = ElementHelper(self.page).find_element(selector, timeout)
assert element.is_visible(), message or f"元素不可见: {selector}"
self.logger.log_assertion(f"元素可见: {selector}", True)
except (PlaywrightError, PlaywrightTimeoutError, AssertionError):
self.logger.log_assertion(f"元素可见: {selector}", False)
raise
def assert_element_hidden(
self,
selector: str,
timeout: Optional[int] = None,
message: Optional[str] = None
) -> None:
"""断言元素隐藏"""
self.logger.log_assertion(f"元素隐藏: {selector}", True)
element = ElementHelper(self.page).find_element(selector, timeout)
assert not element.is_visible(), message or f"元素应该隐藏但可见: {selector}"
def assert_element_text_contains(
self,
selector: str,
expected_text: str,
timeout: Optional[int] = None,
message: Optional[str] = None
) -> None:
"""断言元素文本包含预期文本"""
self.logger.log_assertion(f"文本包含: {selector} 包含 '{expected_text}'", True)
element = ElementHelper(self.page).find_element(selector, timeout)
actual_text = element.text_content()
assert expected_text in actual_text, message or (
f"元素文本不匹配: 预期包含 '{expected_text}',实际为 '{actual_text}'"
)
def assert_element_text_equals(
self,
selector: str,
expected_text: str,
timeout: Optional[int] = None,
message: Optional[str] = None
) -> None:
"""断言元素文本等于预期文本"""
self.logger.log_assertion(f"文本相等: {selector} == '{expected_text}'", True)
element = ElementHelper(self.page).find_element(selector, timeout)
actual_text = element.text_content()
assert actual_text == expected_text, message or (
f"元素文本不匹配: 预期 '{expected_text}',实际为 '{actual_text}'"
)
def assert_url_contains(self, expected_url: str, message: Optional[str] = None) -> None:
"""断言URL包含预期文本"""
self.logger.log_assertion(f"URL包含: {expected_url}", True)
assert expected_url in self.page.url, message or (
f"URL不匹配: 预期包含 '{expected_url}',实际为 '{self.page.url}'"
)
def assert_url_equals(self, expected_url: str, message: Optional[str] = None) -> None:
"""断言URL等于预期URL"""
self.logger.log_assertion(f"URL相等: {expected_url}", True)
assert self.page.url == expected_url, message or (
f"URL不匹配: 预期 '{expected_url}',实际为 '{self.page.url}'"
)
def assert_page_title_contains(self, expected_title: str, message: Optional[str] = None) -> None:
"""断言页面标题包含预期文本"""
self.logger.log_assertion(f"标题包含: {expected_title}", True)
actual_title = self.page.title()
assert expected_title in actual_title, message or (
f"页面标题不匹配: 预期包含 '{expected_title}',实际为 '{actual_title}'"
)
def assert_element_count(
self,
selector: str,
expected_count: int,
message: Optional[str] = None
) -> None:
"""断言元素数量"""
self.logger.log_assertion(f"元素数量: {selector} == {expected_count}", True)
elements = self.page.locator(selector).all()
assert len(elements) == expected_count, message or (
f"元素数量不匹配: 预期 {expected_count},实际 {len(elements)}"
)
def assert_element_attribute_equals(
self,
selector: str,
attribute: str,
expected_value: str,
timeout: Optional[int] = None,
message: Optional[str] = None
) -> None:
"""断言元素属性等于预期值"""
self.logger.log_assertion(f"属性相等: {selector}.{attribute} == '{expected_value}'", True)
element = ElementHelper(self.page).find_element(selector, timeout)
actual_value = element.get_attribute(attribute)
assert actual_value == expected_value, message or (
f"元素属性不匹配: {attribute} 预期 '{expected_value}',实际 '{actual_value}'"
)
class UrlHelper:
"""URL辅助类"""
@staticmethod
def parse_url(url: str) -> Dict[str, Any]:
"""解析URL"""
parsed = urlparse(url)
return {
"scheme": parsed.scheme,
"netloc": parsed.netloc,
"path": parsed.path,
"params": parse_qs(parsed.query),
"query": parsed.query,
"fragment": parsed.fragment
}
@staticmethod
def get_domain(url: str) -> str:
"""获取URL域名"""
parsed = urlparse(url)
return parsed.netloc
@staticmethod
def get_path(url: str) -> str:
"""获取URL路径"""
parsed = urlparse(url)
return parsed.path
@staticmethod
def is_absolute_url(url: str) -> bool:
"""判断是否为绝对URL"""
return bool(urlparse(url).scheme)
@staticmethod
def join_url(base: str, path: str) -> str:
"""拼接URL"""
return urljoin(base, path)
@staticmethod
def remove_trailing_slash(url: str) -> str:
"""移除URL末尾的斜杠"""
if url.endswith("/") and len(url) > 1:
return url[:-1]
return url
class FileHelper:
"""文件操作辅助类"""
@staticmethod
def read_json(filepath: str) -> Dict[str, Any]:
"""读取JSON文件"""
with open(filepath, 'r', encoding='utf-8') as f:
return json.load(f)
@staticmethod
def write_json(filepath: str, data: Any, indent: int = 2) -> None:
"""写入JSON文件"""
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=indent)
@staticmethod
def read_text(filepath: str) -> str:
"""读取文本文件"""
with open(filepath, 'r', encoding='utf-8') as f:
return f.read()
@staticmethod
def write_text(filepath: str, content: str) -> None:
"""写入文本文件"""
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
@staticmethod
def create_directory(path: str) -> None:
"""创建目录"""
Path(path).mkdir(parents=True, exist_ok=True)
@staticmethod
def delete_directory(path: str) -> None:
"""删除目录"""
import shutil
if Path(path).exists():
shutil.rmtree(path)
@staticmethod
def cleanup_directory(path: str, pattern: str = "*") -> None:
"""清理目录中的文件"""
import glob
files = glob.glob(str(Path(path) / pattern))
for file in files:
try:
os.remove(file)
except IsADirectoryError:
FileHelper.delete_directory(file)
def wait(
condition: Callable,
timeout: int = 10000,
interval: int = 500,
message: str = "等待条件超时"
) -> bool:
"""等待条件满足"""
start_time = time.time() * 1000
while time.time() * 1000 - start_time < timeout:
if condition():
return True
time.sleep(interval / 1000)
raise TimeoutError(message)
def retry(
func: Callable,
max_retries: int = 3,
delay: int = 1000,
exceptions: Tuple[Exception, ...] = (Exception,)
) -> Any:
"""重试函数"""
last_exception = None
for attempt in range(max_retries + 1):
try:
return func()
except exceptions as e:
last_exception = e
if attempt < max_retries:
time.sleep(delay / 1000)
raise last_exception
def generate_test_id(prefix: str = "test") -> str:
"""生成测试ID"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
random_hash = hashlib.md5(f"{timestamp}_{os.getpid()}".encode()).hexdigest()[:8]
return f"{prefix}_{timestamp}_{random_hash}"
@lru_cache(maxsize=128)
def normalize_text(text: str) -> str:
"""标准化文本(去除多余空白)"""
return re.sub(r'\s+', ' ', text).strip()
+272
View File
@@ -0,0 +1,272 @@
"""
日志工具模块
提供测试过程中的日志记录功能
"""
import os
import sys
import logging
from datetime import datetime
from pathlib import Path
from typing import Optional, Union
from functools import wraps
import traceback
from config.settings import get_settings
class ColoredFormatter(logging.Formatter):
"""彩色日志格式化器"""
# ANSI颜色代码
COLORS = {
'DEBUG': '\033[36m', # 青色
'INFO': '\033[32m', # 绿色
'WARNING': '\033[33m', # 黄色
'ERROR': '\033[31m', # 红色
'CRITICAL': '\033[35m', # 紫色
'RESET': '\033[0m', # 重置
}
def format(self, record: logging.LogRecord) -> str:
# 获取颜色
color = self.COLORS.get(record.levelname, self.COLORS['RESET'])
# 格式化消息
message = super().format(record)
# 添加颜色(如果不是纯文本输出)
if sys.stdout.isatty():
return f"{color}{message}{self.COLORS['RESET']}"
return message
class TestLogger:
"""测试日志管理器"""
_instance: Optional['TestLogger'] = None
_initialized: bool = False
def __new__(cls) -> 'TestLogger':
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not TestLogger._initialized:
self._setup_logging()
TestLogger._initialized = True
def _setup_logging(self) -> None:
"""设置日志配置"""
self.settings = get_settings()
self.logger = logging.getLogger("e2e_tests")
self.logger.setLevel(getattr(logging, self.settings.log_level))
# 清除现有处理器
self.logger.handlers.clear()
# 创建日志目录
log_dir = Path(self.settings.log_file).parent
log_dir.mkdir(parents=True, exist_ok=True)
# 文件处理器
file_handler = logging.FileHandler(
self.settings.log_file,
encoding='utf-8',
mode='a'
)
file_handler.setLevel(logging.DEBUG)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(getattr(logging, self.settings.log_level))
# 设置格式化器
file_format = logging.Formatter(
'%(asctime)s | %(levelname)-8s | %(name)s | %(filename)s:%(lineno)d | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
console_format_str = '%(asctime)s | %(levelname)-8s | %(message)s'
file_handler.setFormatter(file_format)
if sys.stdout.isatty():
console_handler.setFormatter(ColoredFormatter(console_format_str, datefmt='%H:%M:%S'))
else:
console_handler.setFormatter(logging.Formatter(console_format_str, datefmt='%H:%M:%S'))
self.logger.addHandler(file_handler)
self.logger.addHandler(console_handler)
def debug(self, message: str, **kwargs) -> None:
"""记录DEBUG级别日志"""
self.logger.debug(self._format_message(message, **kwargs))
def info(self, message: str, **kwargs) -> None:
"""记录INFO级别日志"""
self.logger.info(self._format_message(message, **kwargs))
def warning(self, message: str, **kwargs) -> None:
"""记录WARNING级别日志"""
self.logger.warning(self._format_message(message, **kwargs))
def error(self, message: str, exc_info: bool = True, **kwargs) -> None:
"""记录ERROR级别日志"""
self.logger.error(
self._format_message(message, **kwargs),
exc_info=exc_info
)
def critical(self, message: str, exc_info: bool = True, **kwargs) -> None:
"""记录CRITICAL级别日志"""
self.logger.critical(
self._format_message(message, **kwargs),
exc_info=exc_info
)
def exception(self, message: str, **kwargs) -> None:
"""记录异常日志(自动包含堆栈信息)"""
self.error(message, exc_info=True, **kwargs)
def _format_message(self, message: str, **kwargs) -> str:
"""格式化日志消息"""
if kwargs:
extra_info = " | ".join(f"{k}={v}" for k, v in kwargs.items())
return f"{message} | {extra_info}"
return message
def log_test_start(self, test_name: str, **extra_info) -> None:
"""记录测试开始"""
self.info(f"🧪 测试开始: {test_name}", **extra_info)
def log_test_end(self, test_name: str, status: str, duration: float, **extra_info) -> None:
"""记录测试结束"""
emoji = "" if status == "PASSED" else "" if status == "FAILED" else "⏭️"
self.info(f"{emoji} 测试结束: {test_name} | 状态: {status} | 耗时: {duration:.2f}s", **extra_info)
def log_step(self, step_name: str, **extra_info) -> None:
"""记录测试步骤"""
self.info(f"📋 步骤: {step_name}", **extra_info)
def log_action(self, action: str, **extra_info) -> None:
"""记录用户操作"""
self.info(f"👆 操作: {action}", **extra_info)
def log_assertion(self, assertion: str, result: bool, **extra_info) -> None:
"""记录断言结果"""
status = "✅ 通过" if result else "❌ 失败"
self.info(f"🔍 断言: {assertion} | {status}", **extra_info)
def log_performance(self, metric: str, value: float, threshold: Optional[float] = None, **extra_info) -> None:
"""记录性能指标"""
if threshold and value > threshold:
self.warning(f"📊 性能指标 - {metric}: {value:.2f}ms (阈值: {threshold:.2f}ms)", **extra_info)
else:
self.info(f"📊 性能指标 - {metric}: {value:.2f}ms", **extra_info)
def log_error_context(self, context: str, error: Exception, **extra_info) -> None:
"""记录错误上下文"""
self.error(f"🚨 错误上下文: {context}", exc_info=False, **extra_info)
self.error(f"错误信息: {str(error)}", exc_info=False)
self.debug(f"堆栈跟踪:\n{traceback.format_exc()}")
def section(self, title: str) -> None:
"""记录分段标题"""
separator = "=" * 60
self.info(f"\n{separator}")
self.info(f" {title}")
self.info(f"{separator}\n")
def divider(self, char: str = "-", length: int = 40) -> None:
"""记录分隔线"""
self.info(char * length)
def get_logger() -> TestLogger:
"""获取日志管理器实例"""
return TestLogger()
def log_decorator(func):
"""函数日志装饰器"""
@wraps(func)
def wrapper(*args, **kwargs):
logger = get_logger()
func_name = func.__name__
logger.log_test_start(func_name)
logger.divider()
try:
result = func(*args, **kwargs)
logger.log_test_end(func_name, "PASSED", 0)
return result
except Exception as e:
logger.log_test_end(func_name, "FAILED", 0)
logger.log_error_context(func_name, e)
raise
return wrapper
class PerformanceTimer:
"""性能计时器"""
def __init__(self, logger: Optional[TestLogger] = None):
self.logger = logger or get_logger()
self.start_time: Optional[float] = None
self.end_time: Optional[float] = None
self.elapsed: Optional[float] = None
def start(self) -> 'PerformanceTimer':
"""开始计时"""
self.start_time = self._time_ms()
return self
def stop(self) -> 'PerformanceTimer':
"""停止计时"""
self.end_time = self._time_ms()
self.elapsed = self.end_time - self.start_time
return self
def reset(self) -> 'PerformanceTimer':
"""重置计时器"""
self.start_time = None
self.end_time = None
self.elapsed = None
return self
@staticmethod
def _time_ms() -> float:
"""获取当前时间(毫秒)"""
import time
return time.time() * 1000
@property
def seconds(self) -> float:
"""获取经过时间(秒)"""
return self.elapsed / 1000 if self.elapsed else 0
@property
def milliseconds(self) -> float:
"""获取经过时间(毫秒)"""
return self.elapsed if self.elapsed else 0
def log(self, operation: str, threshold: Optional[float] = None) -> None:
"""记录操作耗时"""
self.logger.log_performance(
operation,
self.milliseconds,
threshold
)
def __enter__(self) -> 'PerformanceTimer':
"""上下文管理器入口"""
self.start()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""上下文管理器出口"""
self.stop()
+606
View File
@@ -0,0 +1,606 @@
"""
测试报告生成模块
提供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()