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
+107
View File
@@ -0,0 +1,107 @@
# 测试环境配置文件
# 复制此文件为 .env 并根据实际情况修改配置
# ===========================================
# 基础配置
# ===========================================
# 测试环境URL
TEST_BASE_URL=http://localhost:3000
# 备用测试URL(如果本地不可用)
TEST_BASE_URL_FALLBACK=https://novalon-website.example.com
# 测试环境
TEST_ENV=development
# ===========================================
# 浏览器配置
# ===========================================
# 默认浏览器
DEFAULT_BROWSER=chromium
# 视口配置
DEFAULT_VIEWPORT_WIDTH=1920
DEFAULT_VIEWPORT_HEIGHT=1080
# 是否以无头模式运行
HEADLESS_MODE=false
# ===========================================
# 测试执行配置
# ===========================================
# 最大重试次数
MAX_RETRIES=2
# 测试超时时间(秒)
TEST_TIMEOUT=60
# 页面加载超时
PAGE_LOAD_TIMEOUT=30000
# 元素等待超时
ELEMENT_TIMEOUT=10000
# 并行执行配置
PARALLEL_WORKERS=4
# ===========================================
# 截图和视频配置
# ===========================================
# 是否在测试失败时截图
SCREENSHOT_ON_FAILURE=true
# 是否录制视频
VIDEO_RECORDING=false
# 截图保存路径
SCREENSHOTS_DIR=reports/screenshots
# 视频保存路径
VIDEOS_DIR=reports/videos
# ===========================================
# 日志配置
# ===========================================
# 日志级别
LOG_LEVEL=INFO
# 日志文件路径
LOG_FILE=reports/e2e_tests.log
# 是否在控制台输出日志
CONSOLE_LOG=true
# ===========================================
# 报告配置
# ===========================================
# HTML报告标题
REPORT_TITLE=Novalon Website E2E测试报告
# 报告描述
REPORT_DESCRIPTION=Novalon Website端到端自动化测试报告
# 是否生成JUnit XML报告(用于CI/CD
JUNIT_XML_REPORT=false
JUNIT_XML_PATH=reports/test-results.xml
# ===========================================
# CI/CD配置
# ===========================================
# CI环境标识
CI=false
# Git分支(CI环境中自动填充)
GIT_BRANCH=
# Git提交(CI环境中自动填充)
GIT_COMMIT=
# Git仓库(CI环境中自动填充)
GIT_REPOSITORY=
+439
View File
@@ -0,0 +1,439 @@
# Novalon Website E2E 测试框架
基于 Playwright 和 Python 的端到端测试解决方案,为 Novalon Website 提供全面的自动化测试覆盖。
## 目录
- [特性](#特性)
- [技术栈](#技术栈)
- [项目结构](#项目结构)
- [快速开始](#快速开始)
- [测试运行](#测试运行)
- [CI/CD 集成](#cicd-集成)
- [测试标记](#测试标记)
- [配置说明](#配置说明)
- [最佳实践](#最佳实践)
## 特性
- **模块化设计**: 采用 Page Object Model (POM) 设计模式
- **跨浏览器测试**: 支持 Chrome、Firefox、WebKit
- **响应式测试**: 覆盖多端(移动端、平板、桌面)
- **性能测试**: 页面加载性能指标监控
- **多格式报告**: HTML、JSON、Markdown 报告生成
- **完整测试数据**: 自动生成测试数据(中文/英文)
- **详细日志**: 彩色日志输出,便于问题定位
## 技术栈
| 技术 | 用途 |
|------|------|
| Python 3.9+ | 编程语言 |
| Playwright | 浏览器自动化框架 |
| pytest | 测试框架 |
| pytest-html | HTML 报告 |
| pytest-cov | 代码覆盖率 |
| Jinja2 | 报告模板 |
## 项目结构
```
e2e-tests/
├── config/
│ ├── __init__.py
│ ├── settings.py # 应用配置
│ └── browsers.py # 浏览器配置
├── pages/
│ ├── __init__.py
│ ├── base_page.py # 页面基类
│ ├── home_page.py # 首页对象
│ └── contact_page.py # 联系页面对象
├── tests/
│ ├── __init__.py
│ ├── conftest.py # pytest 配置和 fixture
│ ├── test_home_page.py # 首页测试
│ ├── test_contact_form.py # 联系表单测试
│ ├── test_navigation.py # 导航测试
│ ├── test_performance.py # 性能测试
│ └── test_responsive.py # 响应式测试
├── utils/
│ ├── __init__.py
│ ├── helpers.py # 辅助工具函数
│ ├── logger.py # 日志配置
│ ├── data_generator.py # 测试数据生成
│ └── report_generator.py # 报告生成器
├── scripts/
│ ├── run_tests.py # 测试运行脚本
│ └── ci_test.py # CI/CD 测试脚本
├── reports/ # 测试报告目录
├── screenshots/ # 失败截图目录
├── videos/ # 测试视频目录
├── requirements.txt # Python 依赖
├── pyproject.toml # pytest 配置
├── pytest.ini # pytest 配置
├── .env.example # 环境变量模板
└── README.md # 本文档
```
## 快速开始
### 1. 环境准备
```bash
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Linux/Mac
# 或
.\venv\Scripts\activate # Windows
# 安装依赖
pip install -r requirements.txt
# 安装 Playwright 浏览器
playwright install
```
### 2. 环境配置
```bash
# 复制环境变量模板
cp .env.example .env
# 编辑 .env 文件
# BASE_URL=http://localhost:3000
# 默认开发环境: http://localhost:3000
```
### 3. 运行测试
```bash
# 运行所有测试
python scripts/run_tests.py
# 运行冒烟测试
python scripts/run_tests.py -m smoke
# 运行特定测试
python scripts/run_tests.py tests/test_home_page.py
# 使用关键字过滤
python scripts/run_tests.py -k home
# 多浏览器测试
python scripts/run_tests.py -b all
```
## 测试运行
### 命令行参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `-b, --browser` | 浏览器 (chromium/firefox/webkit/all) | chromium |
| `-h, --headless` | 无头模式 | False |
| `-m, --marker` | 运行指定标记的测试 | - |
| `-k, --keyword` | 关键字过滤 | - |
| `-v, --verbose` | 详细输出 | False |
| `--html` | 生成 HTML 报告 | False |
| `--video` | 录制测试视频 | False |
| `--screenshot` | 失败时截图 | False |
| `--parallel` | 并行执行 | False |
| `--workers` | 并行工作数 | 4 |
| `--env` | 测试环境 | development |
### 示例命令
```bash
# 冒烟测试
python scripts/run_tests.py -m smoke -v
# 性能测试
python scripts/run_tests.py -m performance
# 响应式测试
python scripts/run_tests.py -m responsive
# 完整回归测试
python scripts/run_tests.py -m regression -v --html
# 无头模式运行
python scripts/run_tests.py -h --parallel
# 跨浏览器测试
python scripts/run_tests.py -b all --html
```
## CI/CD 集成
### GitHub Actions 示例
```yaml
name: E2E Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
pip install -r e2e-tests/requirements.txt
playwright install --with-deps chromium
- name: Run E2E Tests
run: |
source venv/bin/activate
cp e2e-tests/.env.example e2e-tests/.env
python e2e-tests/scripts/run_tests.py -m smoke --html
env:
BASE_URL: ${{ secrets.BASE_URL }}
- name: Upload Test Report
if: always()
uses: actions/upload-artifact@v3
with:
name: test-report
path: e2e-tests/reports/
```
### CI 测试脚本
```bash
# 运行冒烟测试
python scripts/ci_test.py --test-type smoke
# 运行回归测试
python scripts/ci_test.py --test-type regression
# 运行性能测试
python scripts/ci_test.py --test-type performance
# 跨浏览器测试
python scripts/ci_test.py --test-type cross_browser
# 完整测试套件
python scripts/ci_test.py --test-type full
```
## 测试标记
| 标记 | 说明 | 用例数量 |
|------|------|---------|
| `@pytest.mark.smoke` | 冒烟测试,快速验证核心功能 | 关键路径 |
| `@pytest.mark.regression` | 回归测试,完整功能验证 | 功能测试 |
| `@pytest.mark.performance` | 性能测试,页面加载和响应时间 | 性能指标 |
| `@pytest.mark.responsive` | 响应式测试,不同屏幕尺寸 | 布局适配 |
| `@pytest.mark.cross_browser` | 跨浏览器测试 | 兼容性 |
| `@pytest.mark.form` | 表单相关测试 | 表单验证 |
| `@pytest.mark.navigation` | 导航测试 | 页面跳转 |
| `@pytest.mark.interactive` | 用户交互测试 | 交互功能 |
### 运行特定标记的测试
```bash
# 只运行冒烟测试
pytest -m smoke
# 运行冒烟和性能测试
pytest -m "smoke or performance"
# 运行冒烟但排除性能测试
pytest -m "smoke and not performance"
```
## 配置说明
### 环境变量 (.env)
```env
# 网站基础URL
BASE_URL=http://localhost:3000
# 测试环境
ENVIRONMENT=development
# 默认浏览器
DEFAULT_BROWSER=chromium
# 无头模式
HEADLESS_MODE=false
# 页面加载超时
PAGE_LOAD_TIMEOUT=30000
# 元素等待超时
ELEMENT_TIMEOUT=10000
# 报告标题
REPORT_TITLE=Novalon Website E2E 测试报告
# 报告描述
REPORT_DESCRIPTION=自动化端到端测试结果
```
### 测试配置 (pytest.ini)
```ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--tb=short
--strict-markers
--disable-warnings
filterwarnings =
ignore::DeprecationWarning
```
### 浏览器配置 (config/browsers.py)
```python
# 支持的浏览器配置
BROWSER_CONFIG = {
"chromium": {
"headless": False,
"viewport": {"width": 1920, "height": 1080},
"args": ["--start-maximized"]
},
"firefox": {
"headless": False,
"viewport": {"width": 1920, "height": 1080}
},
"webkit": {
"headless": False,
"viewport": {"width": 1920, "height": 1080}
}
}
```
## 最佳实践
### 1. 页面对象模式
每个页面都应该有对应的 Page Object 类:
```python
from pages.base_page import BasePage
class HomePage(BasePage):
def __init__(self, page):
super().__init__(page)
self.path = "/"
self.selectors = {
"hero_title": "#home h1",
"cta_button": ".cta-button"
}
def verify_hero_title(self):
self.assert_element_visible("hero_title")
return self
```
### 2. 测试数据管理
使用数据生成器创建测试数据:
```python
from utils.data_generator import get_test_data_generator
def test_contact_form(test_data_generator):
data = test_data_generator.generate_contact_form_data()
page.fill_contact_form(data)
```
### 3. 断言辅助
使用断言助手进行验证:
```python
def test_example(page):
assert_(page).title_contains("首页")
assert_(page).element_visible("#main")
```
### 4. 截图和日志
测试失败时自动截图,并记录详细日志:
```python
def test_with_logging(page):
logger = get_logger()
logger.log_action("执行测试步骤")
# 测试代码
```
### 5. 性能监控
使用内置的性能监控:
```python
def test_performance(page):
performance = page.verify_page_performance()
assert performance["pageLoadTime"] < 5000
```
## 报告示例
测试完成后,报告将保存在 `reports/` 目录:
```
reports/
├── html/
│ └── test_report.html # HTML 格式报告
├── json/
│ └── test_results.json # JSON 格式结果
└── screenshots/
└── test_failed.png # 失败截图
```
## 故障排除
### 常见问题
**1. 浏览器安装失败**
```bash
playwright install --with-deps
```
**2. 依赖安装失败**
```bash
pip install --upgrade pip
pip install -r requirements.txt
```
**3. 测试超时**
检查 `.env` 中的 `PAGE_LOAD_TIMEOUT` 设置
**4. 页面元素定位失败**
使用开发者工具检查元素选择器是否正确
### 获取帮助
- 查看详细日志: 运行 `python scripts/run_tests.py -v`
- 查看 Playwright 文档: https://playwright.dev/python/
- 查看 pytest 文档: https://docs.pytest.org/
## 许可证
本测试框架基于 MIT 许可证开源。
+1
View File
@@ -0,0 +1 @@
# Config模块
+270
View File
@@ -0,0 +1,270 @@
"""
浏览器配置模块
提供跨浏览器测试的配置和工具函数
"""
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass
from enum import Enum
from playwright.sync_api import Browser, BrowserType, BrowserContext, Page
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
from config.settings import get_settings
class BrowserTypeEnum(Enum):
"""支持的浏览器类型"""
CHROMIUM = "chromium"
FIREFOX = "firefox"
WEBKIT = "webkit"
@dataclass
class BrowserCapabilities:
"""浏览器能力描述"""
name: str
display_name: str
channel: Optional[str]
is_headless_supported: bool
default_viewport: tuple
user_agent: str
description: str
class BrowserConfigManager:
"""浏览器配置管理器"""
# 浏览器能力定义
BROWSER_CAPABILITIES: Dict[str, BrowserCapabilities] = {
"chromium": BrowserCapabilities(
name="chromium",
display_name="Chrome/Chromium",
channel="chrome",
is_headless_supported=True,
default_viewport=(1920, 1080),
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
),
description="Google Chrome / Chromium浏览器"
),
"firefox": BrowserCapabilities(
name="firefox",
display_name="Firefox",
channel=None,
is_headless_supported=True,
default_viewport=(1920, 1080),
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) "
"Gecko/20100101 Firefox/121.0"
),
description="Mozilla Firefox浏览器"
),
"webkit": BrowserCapabilities(
name="webkit",
display_name="WebKit (Safari)",
channel=None,
is_headless_supported=True,
default_viewport=(1920, 1080),
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
),
description="Apple WebKit (Safari)浏览器"
)
}
def __init__(self):
self.settings = get_settings()
self._playwright: Optional[sync_playwright] = None
self._browser: Optional[Browser] = None
self._context: Optional[BrowserContext] = None
def _ensure_playwright(self) -> sync_playwright:
"""确保Playwright实例已启动"""
if self._playwright is None:
self._playwright = sync_playwright().start()
return self._playwright
def get_available_browsers(self) -> List[str]:
"""获取可用的浏览器列表"""
available = []
p = self._ensure_playwright()
browser_map = {
"chromium": p.chromium,
"firefox": p.firefox,
"webkit": p.webkit
}
for name in browser_map:
try:
available.append(name)
except Exception:
continue
return available
def _get_browser_type(self, browser_name: str):
"""获取浏览器类型"""
p = self._ensure_playwright()
browser_map = {
"chromium": p.chromium,
"firefox": p.firefox,
"webkit": p.webkit
}
return browser_map.get(browser_name)
def launch_browser(
self,
browser_name: str = "chromium",
headless: bool = False,
viewport: Optional[Tuple[int, int]] = None,
**kwargs
) -> Browser:
"""启动浏览器"""
capabilities = self.BROWSER_CAPABILITIES.get(browser_name)
if not capabilities:
raise ValueError(f"不支持的浏览器类型: {browser_name}")
viewport = viewport or (self.settings.viewport_width, self.settings.viewport_height)
launch_args = self._get_launch_arguments(browser_name, headless)
browser_type = self._get_browser_type(browser_name)
if not browser_type:
raise ValueError(f"不支持的浏览器类型: {browser_name}")
self._browser = browser_type.launch(
headless=headless,
args=launch_args,
**kwargs
)
return self._browser
def _get_launch_arguments(self, browser_name: str, headless: bool) -> List[str]:
"""获取浏览器启动参数"""
args = []
if browser_name == "chromium":
args.extend([
"--disable-extensions",
"--disable-background-networking",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
"--safebrowsing-disable-auto-update",
"--ignore-certificate-errors",
"--ignore-ssl-errors",
"--disable-dev-shm-usage",
])
if headless:
args.extend([
"--headless=new",
"--disable-gpu",
"--no-sandbox",
])
elif browser_name == "firefox":
if headless:
args.extend(["-headless"])
args.extend([
"-profile",
"/tmp/firefox-profile",
])
elif browser_name == "webkit":
if headless:
args.append("--headless")
args.extend([
"--no-sandbox",
"--disable-setuid-sandbox",
])
return args
def create_context(
self,
browser: Browser,
viewport: Optional[Tuple[int, int]] = None,
**context_kwargs
) -> BrowserContext:
"""创建浏览器上下文"""
viewport = viewport or (self.settings.viewport_width, self.settings.viewport_height)
capabilities = self.BROWSER_CAPABILITIES.get(
browser.browser_type.name,
self.BROWSER_CAPABILITIES["chromium"]
)
context_options = {
"viewport": {
"width": viewport[0],
"height": viewport[1]
},
"user_agent": capabilities.user_agent,
"locale": "zh-CN",
"timezone_id": "Asia/Shanghai",
**context_kwargs
}
self._context = browser.new_context(**context_options)
return self._context
def create_browser_session(
self,
browser_name: str = "chromium",
headless: bool = False,
viewport: Optional[Tuple[int, int]] = None
) -> Tuple[Browser, BrowserContext, Page]:
"""创建完整的浏览器会话"""
browser = self.launch_browser(browser_name, headless, viewport)
context = self.create_context(browser, viewport)
page = context.new_page()
return browser, context, page
def close_browser(self) -> None:
"""关闭浏览器和上下文"""
if self._context:
try:
self._context.close()
except Exception:
pass
self._context = None
if self._browser:
try:
self._browser.close()
except Exception:
pass
self._browser = None
if self._playwright:
try:
self._playwright.stop()
except Exception:
pass
self._playwright = None
def __enter__(self) -> 'BrowserConfigManager':
"""上下文管理器入口"""
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
"""上下文管理器退出"""
self.close_browser()
def get_browser_factory() -> BrowserConfigManager:
"""获取浏览器工厂实例"""
return BrowserConfigManager()
+258
View File
@@ -0,0 +1,258 @@
"""
测试框架配置模块
提供全局配置管理和环境变量加载功能
"""
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from dataclasses import dataclass, field
from dotenv import load_dotenv
# 加载环境变量
load_dotenv()
@dataclass
class BrowserConfig:
"""浏览器配置类"""
name: str
headless: bool = False
viewport_width: int = 1920
viewport_height: int = 1080
device_scale_factor: float = 1.0
is_mobile: bool = False
has_touch: bool = False
locale: str = "zh-CN"
timezone_id: str = "Asia/Shanghai"
@dataclass
class PerformanceThresholds:
"""性能指标阈值配置"""
page_load_time: int = 3000 # 毫秒
first_contentful_paint: int = 1500
largest_contentful_paint: int = 2500
time_to_interactive: int = 3000
first_byte: int = 500
dom_content_loaded: int = 1000
@dataclass
class ResponsiveBreakpoints:
"""响应式测试断点配置"""
mobile: Dict[str, int] = field(default_factory=lambda: {"width": 375, "height": 667})
tablet: Dict[str, int] = field(default_factory=lambda: {"width": 768, "height": 1024})
desktop: Dict[str, int] = field(default_factory=lambda: {"width": 1920, "height": 1080})
wide: Dict[str, int] = field(default_factory=lambda: {"width": 2560, "height": 1440})
class Settings:
"""全局配置管理类"""
_instance: Optional['Settings'] = None
_initialized: bool = False
def __new__(cls) -> 'Settings':
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self):
if not Settings._initialized:
self._load_config()
Settings._initialized = True
def _load_config(self) -> None:
"""加载所有配置"""
# 基础配置
self.base_url = self._get_env("TEST_BASE_URL", "http://localhost:3000")
self.fallback_url = self._get_env("TEST_BASE_URL_FALLBACK", "")
self.test_env = self._get_env("TEST_ENV", "development")
# 浏览器配置
self.default_browser = self._get_env("DEFAULT_BROWSER", "chromium")
self.headless_mode = self._get_env("HEADLESS_MODE", "false").lower() == "true"
self.viewport_width = int(self._get_env("DEFAULT_VIEWPORT_WIDTH", 1920))
self.viewport_height = int(self._get_env("DEFAULT_VIEWPORT_HEIGHT", 1080))
# 超时配置
self.max_retries = int(self._get_env("MAX_RETRIES", 2))
self.test_timeout = int(self._get_env("TEST_TIMEOUT", 60))
self.page_load_timeout = int(self._get_env("PAGE_LOAD_TIMEOUT", 30000))
self.element_timeout = int(self._get_env("ELEMENT_TIMEOUT", 10000))
# 并行配置
self.parallel_workers = int(self._get_env("PARALLEL_WORKERS", 4))
# 截图和视频
self.screenshot_on_failure = self._get_env("SCREENSHOT_ON_FAILURE", "true").lower() == "true"
self.video_recording = self._get_env("VIDEO_RECORDING", "false").lower() == "true"
self.screenshots_dir = self._get_env("SCREENSHOTS_DIR", "reports/screenshots")
self.videos_dir = self._get_env("VIDEOS_DIR", "reports/videos")
# 日志配置
self.log_level = self._get_env("LOG_LEVEL", "INFO")
self.log_file = self._get_env("LOG_FILE", "reports/e2e_tests.log")
self.console_log = self._get_env("CONSOLE_LOG", "true").lower() == "true"
# 报告配置
self.report_title = self._get_env("REPORT_TITLE", "Novalon Website E2E测试报告")
self.report_description = self._get_env(
"REPORT_DESCRIPTION",
"Novalon Website端到端自动化测试报告"
)
self.junit_xml_report = self._get_env("JUNIT_XML_REPORT", "false").lower() == "true"
self.junit_xml_path = self._get_env("JUNIT_XML_PATH", "reports/test-results.xml")
# 性能阈值
self._load_performance_thresholds()
# 响应式断点
self._load_responsive_breakpoints()
# 浏览器列表
self.browsers_to_test = ["chromium", "firefox", "webkit"]
# 测试数据
self._load_test_form_data()
# CI/CD配置
self.ci = self._get_env("CI", "false").lower() == "true"
self.git_branch = self._get_env("GIT_BRANCH", "")
self.git_commit = self._get_env("GIT_COMMIT", "")
self.git_repository = self._get_env("GIT_REPOSITORY", "")
# 创建必要的目录
self._create_directories()
def _get_env(self, key: str, default: str) -> str:
"""获取环境变量"""
return os.environ.get(key, default)
def _load_performance_thresholds(self) -> None:
"""加载性能阈值配置"""
import json
thresholds_str = self._get_env(
"PERFORMANCE_THRESHOLDS",
'{"page_load_time": 3000, "first_contentful_paint": 1500, '
'"largest_contentful_paint": 2500, "time_to_interactive": 3000, '
'"first_byte": 500, "dom_content_loaded": 1000}'
)
try:
thresholds = json.loads(thresholds_str)
self.performance_thresholds = PerformanceThresholds(**thresholds)
except (json.JSONDecodeError, TypeError):
self.performance_thresholds = PerformanceThresholds()
def _load_responsive_breakpoints(self) -> None:
"""加载响应式断点配置"""
import json
breakpoints_str = self._get_env(
"RESPONSIVE_BREAKPOINTS",
'{"mobile": {"width": 375, "height": 667}, '
'"tablet": {"width": 768, "height": 1024}, '
'"desktop": {"width": 1920, "height": 1080}, '
'"wide": {"width": 2560, "height": 1440}}'
)
try:
breakpoints = json.loads(breakpoints_str)
self.responsive_breakpoints = ResponsiveBreakpoints(**breakpoints)
except (json.JSONDecodeError, TypeError):
self.responsive_breakpoints = ResponsiveBreakpoints()
def _load_test_form_data(self) -> None:
"""加载测试表单数据"""
import json
form_data_str = self._get_env(
"TEST_FORM_DATA",
'{"valid": {"name": "测试用户", "phone": "13800138000", '
'"email": "test@example.com", "subject": "测试主题", '
'"message": "这是一条测试消息,用于验证表单功能是否正常。"}, '
'"invalid": {"email": "invalid-email", "phone": "123"}}'
)
try:
self.test_form_data = json.loads(form_data_str)
except json.JSONDecodeError:
self.test_form_data = {
"valid": {
"name": "测试用户",
"phone": "13800138000",
"email": "test@example.com",
"subject": "测试主题",
"message": "这是一条测试消息,用于验证表单功能是否正常。"
},
"invalid": {
"email": "invalid-email",
"phone": "123"
}
}
def _create_directories(self) -> None:
"""创建必要的目录"""
base_dirs = [
self.screenshots_dir,
self.videos_dir,
"reports",
"logs"
]
for dir_path in base_dirs:
Path(dir_path).mkdir(parents=True, exist_ok=True)
def get_browser_config(self, browser_name: Optional[str] = None) -> BrowserConfig:
"""获取浏览器配置"""
name = browser_name or self.default_browser
return BrowserConfig(
name=name,
headless=self.headless_mode,
viewport_width=self.viewport_width,
viewport_height=self.viewport_height
)
def get_test_data_path(self, filename: str) -> Path:
"""获取测试数据文件路径"""
return Path(__file__).parent / "test_data" / filename
def get_reports_path(self, filename: str = "") -> Path:
"""获取报告目录路径"""
reports_dir = Path("reports")
reports_dir.mkdir(parents=True, exist_ok=True)
if filename:
return reports_dir / filename
return reports_dir
def is_ci_environment(self) -> bool:
"""检查是否为CI环境"""
return self.ci or os.environ.get("CI", "").lower() in ["true", "1"]
def get_base_url(self) -> str:
"""获取测试基础URL,自动降级到备用URL"""
# 首先检查基础URL是否可用
if self._check_url_accessible(self.base_url):
return self.base_url
# 如果基础URL不可用,尝试备用URL
if self.fallback_url and self._check_url_accessible(self.fallback_url):
return self.fallback_url
# 如果都不可用,返回基础URL(测试时会报错)
return self.base_url
def _check_url_accessible(self, url: str) -> bool:
"""检查URL是否可访问"""
import requests
try:
response = requests.get(url, timeout=5)
return response.status_code < 500
except requests.RequestException:
return False
# 全局配置实例
settings = Settings()
def get_settings() -> Settings:
"""获取全局配置实例"""
return settings
+1
View File
@@ -0,0 +1 @@
# Pages模块
+318
View File
@@ -0,0 +1,318 @@
"""
页面对象基类
提供页面对象模式的基础框架
"""
from typing import Any, Dict, List, Optional, Tuple, Union
from urllib.parse import urljoin, urlparse
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
from utils.helpers import ElementHelper, PageHelper, AssertionHelper, UrlHelper
class BasePage:
"""页面对象基类"""
def __init__(self, page: Page, base_url: Optional[str] = None):
"""
初始化页面对象
Args:
page: Playwright Page实例
base_url: 基础URL
"""
self.page = page
self.base_url = base_url or get_settings().get_base_url()
self.logger = get_logger()
# 初始化辅助类
self.element = ElementHelper(page)
self.page_helper = PageHelper(page)
self.assertion = AssertionHelper(page)
self.url_helper = UrlHelper()
# 页面URL路径(子类覆盖)
self.path: Optional[str] = None
# 页面标题(子类覆盖)
self.title: Optional[str] = None
# 页面元素选择器(子类覆盖)
self.selectors: Dict[str, str] = {}
def _resolve_selector(self, selector: str) -> str:
"""解析选择器名称为实际选择器字符串"""
if selector in self.selectors:
return self.selectors[selector]
return selector
def _get_full_url(self, path: str) -> str:
"""获取完整URL"""
if self.url_helper.is_absolute_url(path):
return path
return urljoin(self.base_url, path)
def navigate(self, path: Optional[str] = None, **kwargs) -> 'BasePage':
"""
导航到页面
Args:
path: 页面路径,如果为None则使用self.path
**kwargs: 传递给page.goto的参数
Returns:
self
"""
path = path or self.path
if not path:
raise ValueError("页面路径未指定")
url = self._get_full_url(path)
self.logger.log_action(f"导航到页面: {url}")
self.page_helper.navigate(url, **kwargs)
return self
def reload(self) -> 'BasePage':
"""刷新页面"""
self.page_helper.reload_page()
return self
def go_back(self) -> 'BasePage':
"""返回上一页"""
self.page_helper.go_back()
return self
def go_forward(self) -> 'BasePage':
"""前进到下一页"""
self.page_helper.go_forward()
return self
def get_url(self) -> str:
"""获取当前URL"""
return self.page_helper.get_current_url()
def get_title(self) -> str:
"""获取页面标题"""
return self.page_helper.get_page_title()
def wait_for_load(self, state: str = "networkidle") -> 'BasePage':
"""等待页面加载完成"""
self.page_helper.wait_for_load_state(state)
return self
def wait_for_selector(
self,
selector: str,
timeout: Optional[int] = None,
state: str = "visible"
) -> Locator:
"""等待选择器"""
return self.element.wait_for_selector(selector, timeout, state)
def scroll_to_top(self) -> 'BasePage':
"""滚动到页面顶部"""
self.page_helper.scroll_to_top()
return self
def scroll_to_bottom(self) -> 'BasePage':
"""滚动到页面底部"""
self.page_helper.scroll_to_bottom()
return self
def scroll_to_element(self, selector: str) -> 'BasePage':
"""滚动到指定元素"""
self.page_helper.scroll_to_element(selector)
return self
def take_screenshot(
self,
name: str,
full_page: bool = False
) -> str:
"""截取截图"""
return self.page_helper.take_screenshot(
f"{name}_{self._get_timestamp()}.png",
full_page=full_page
)
def execute_js(self, script: str, *args) -> Any:
"""执行JavaScript"""
return self.page_helper.execute_javascript(script, *args)
def _get_timestamp(self) -> str:
"""获取时间戳"""
from datetime import datetime
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _find(self, selector: str, timeout: Optional[int] = None) -> Locator:
"""查找元素"""
resolved_selector = self._resolve_selector(selector)
return self.element.find_element(resolved_selector, timeout)
def _find_all(self, selector: str) -> List[Locator]:
"""查找所有匹配的元素"""
resolved_selector = self._resolve_selector(selector)
return self.element.find_elements(resolved_selector)
def _click(self, selector: str, **kwargs) -> 'BasePage':
"""点击元素"""
resolved_selector = self._resolve_selector(selector)
self.element.click_element(resolved_selector, **kwargs)
return self
def _fill(self, selector: str, value: str, **kwargs) -> 'BasePage':
"""填充输入框"""
resolved_selector = self._resolve_selector(selector)
self.element.fill_input(resolved_selector, value, **kwargs)
return self
def _type(self, selector: str, text: str, **kwargs) -> 'BasePage':
"""输入文本"""
resolved_selector = self._resolve_selector(selector)
self.element.type_text(resolved_selector, text, **kwargs)
return self
def _get_text(self, selector: str, **kwargs) -> str:
"""获取元素文本"""
resolved_selector = self._resolve_selector(selector)
return self.element.get_element_text(resolved_selector, **kwargs)
def _get_attr(self, selector: str, attribute: str, **kwargs) -> Optional[str]:
"""获取元素属性"""
resolved_selector = self._resolve_selector(selector)
return self.element.get_element_attribute(resolved_selector, attribute, **kwargs)
def _is_visible(self, selector: str, **kwargs) -> bool:
"""检查元素是否可见"""
resolved_selector = self._resolve_selector(selector)
return self.element.is_element_visible(resolved_selector, **kwargs)
def _is_enabled(self, selector: str, **kwargs) -> bool:
"""检查元素是否可用"""
resolved_selector = self._resolve_selector(selector)
return self.element.is_element_enabled(resolved_selector, **kwargs)
# 断言方法
def assert_title_contains(self, expected: str, message: Optional[str] = None) -> 'BasePage':
"""断言标题包含预期文本"""
self.assertion.assert_page_title_contains(expected, message)
return self
def assert_url_contains(self, expected: str, message: Optional[str] = None) -> 'BasePage':
"""断言URL包含预期文本"""
self.assertion.assert_url_contains(expected, message)
return self
def assert_url_equals(self, expected: str, message: Optional[str] = None) -> 'BasePage':
"""断言URL等于预期URL"""
self.assertion.assert_url_equals(expected, message)
return self
def assert_element_visible(self, selector: str, **kwargs) -> 'BasePage':
"""断言元素可见"""
resolved_selector = self._resolve_selector(selector)
self.assertion.assert_element_visible(resolved_selector, **kwargs)
return self
def assert_element_hidden(self, selector: str, **kwargs) -> 'BasePage':
"""断言元素隐藏"""
self.assertion.assert_element_hidden(selector, **kwargs)
return self
def assert_element_text_contains(
self,
selector: str,
expected: str,
**kwargs
) -> 'BasePage':
"""断言元素文本包含预期文本"""
self.assertion.assert_element_text_contains(selector, expected, **kwargs)
return self
def assert_element_text_equals(
self,
selector: str,
expected: str,
**kwargs
) -> 'BasePage':
"""断言元素文本等于预期文本"""
self.assertion.assert_element_text_equals(selector, expected, **kwargs)
return self
def assert_element_count(
self,
selector: str,
expected: int,
message: Optional[str] = None
) -> 'BasePage':
"""断言元素数量"""
self.assertion.assert_element_count(selector, expected, message)
return self
def assert_element_attribute_equals(
self,
selector: str,
attribute: str,
expected: str,
**kwargs
) -> 'BasePage':
"""断言元素属性等于预期值"""
self.assertion.assert_element_attribute_equals(
selector, attribute, expected, **kwargs
)
return self
def should_have_url(self, url: str, **kwargs) -> 'BasePage':
"""检查URL"""
self.assert_url_equals(url, **kwargs)
return self
def should_have_title(self, title: str, **kwargs) -> 'BasePage':
"""检查标题"""
self.assert_title_contains(title, **kwargs)
return self
def should_contain_text(self, text: str, **kwargs) -> 'BasePage':
"""检查页面包含文本"""
self.page.wait_for_load_state("domcontentloaded")
content = self.page_helper.get_page_source()
assert text in content, f"页面不包含文本: {text}"
return self
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: {self.path or 'unknown'}>"
class PageRegistry:
"""页面注册表,用于管理页面对象"""
_instance: Optional['PageRegistry'] = None
_pages: Dict[str, BasePage] = {}
def __new__(cls) -> 'PageRegistry':
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def register(self, name: str, page: BasePage) -> None:
"""注册页面"""
self._pages[name] = page
def get(self, name: str) -> Optional[BasePage]:
"""获取页面"""
return self._pages.get(name)
def clear(self) -> None:
"""清空注册表"""
self._pages.clear()
def get_page_registry() -> PageRegistry:
"""获取页面注册表"""
return PageRegistry()
+388
View File
@@ -0,0 +1,388 @@
"""
联系页面测试模块
提供联系页面功能测试
"""
from typing import Any, Dict, List, Optional
from playwright.sync_api import Page, Locator, expect
from pages.base_page import BasePage
from config.settings import get_settings
from utils.logger import get_logger
from utils.helpers import ElementHelper, PageHelper, AssertionHelper
class ContactPage(BasePage):
"""联系页面对象"""
def __init__(self, page: Page, base_url: Optional[str] = None):
"""初始化联系页面"""
super().__init__(page, base_url)
self.path = "/contact"
self.title = "联系我们"
self.selectors = {
# 页面标题
"page_badge": "[class*='badge']",
"page_title": "h1",
"page_description": "p.text-gray-600",
# 联系信息卡片 - 根据实际页面结构
"contact_info_card": "div.grid > div:first-child",
"company_address": "text=公司地址 >> xpath=../following-sibling::p",
"company_phone": "text=联系电话 >> xpath=../following-sibling::p",
"company_email": "text=电子邮箱 >> xpath=../following-sibling::p",
"working_hours": "text=工作时间",
# 联系表单 - 使用ID选择器
"contact_form": "form",
"form_name_input": "#name",
"form_phone_input": "#phone",
"form_email_input": "#email",
"form_subject_input": "#subject",
"form_message_textarea": "#message",
"form_submit_button": "button[type='submit']",
# 表单字段标签
"name_label": "label[for='name']",
"phone_label": "label[for='phone']",
"email_label": "label[for='email']",
"subject_label": "label[for='subject']",
"message_label": "label[for='message']",
# 成功状态
"success_message": "text=消息已发送",
"success_icon": "svg[class*='text-green']",
# 加载状态
"submitting_loader": "text=发送中",
# 返回链接
"back_link": "a:has-text('返回'), a.back"
}
self.logger = get_logger()
def navigate(self, **kwargs) -> 'ContactPage':
"""导航到联系页面"""
super().navigate(**kwargs)
self.wait_for_load()
return self
def verify_page_loaded(self) -> 'ContactPage':
"""验证页面加载完成"""
self.logger.section("验证联系页面加载")
self.assert_element_visible("page_title", timeout=15000)
self.assert_element_visible("contact_form", timeout=15000)
self.logger.info("✅ 联系页面加载验证通过")
return self
def verify_page_structure(self) -> 'ContactPage':
"""验证页面结构"""
self.logger.section("验证页面结构")
# 检查页面标题区域
self.assert_element_visible("page_title")
# 检查联系信息 - 使用更通用的选择器
self._verify_contact_info_exists()
# 检查表单
self.assert_element_visible("contact_form")
self.logger.info("✅ 页面结构验证通过")
return self
def _verify_contact_info_exists(self) -> bool:
"""验证联系信息存在"""
# 检查是否包含联系信息文本
page_text = self.page.content()
has_address = "公司地址" in page_text
has_phone = "联系电话" in page_text
has_email = "电子邮箱" in page_text
assert has_address, "未找到公司地址信息"
assert has_phone, "未找到联系电话信息"
assert has_email, "未找到电子邮箱信息"
return True
def verify_company_info(self) -> 'ContactPage':
"""验证公司信息"""
self.logger.section("验证公司信息")
# 获取页面内容
page_content = self.page.content()
# 验证信息存在
assert "公司地址" in page_content, "未找到公司地址"
assert "联系电话" in page_content, "未找到联系电话"
assert "电子邮箱" in page_content, "未找到电子邮箱"
self.logger.info("✅ 公司信息验证通过")
return self
def verify_form_fields(self) -> 'ContactPage':
"""验证表单字段"""
self.logger.section("验证表单字段")
required_fields = [
("form_name_input", "姓名"),
("form_email_input", "邮箱"),
("form_subject_input", "主题"),
("form_message_textarea", "消息")
]
for selector, field_name in required_fields:
self.assert_element_visible(selector, timeout=5000)
# 检查必填标记
label = self.page.locator(f"label[for='{selector.replace('#', '')}']")
if label.count() > 0:
label_text = label.text_content()
if "*" in (label_text or ""):
self.logger.info(f"{field_name} 为必填项")
# 检查可选字段
self.assert_element_visible("form_phone_input")
self.logger.info("✅ 表单字段验证通过")
return self
def fill_contact_form(self, data: Dict[str, str]) -> 'ContactPage':
"""填充联系表单"""
self.logger.section("填充联系表单")
# 姓名
if "name" in data:
self._fill("form_name_input", data["name"])
self.logger.log_action(f"填写姓名: {data['name']}")
# 电话
if "phone" in data:
self._fill("form_phone_input", data["phone"])
self.logger.log_action(f"填写电话: {data['phone']}")
# 邮箱
if "email" in data:
self._fill("form_email_input", data["email"])
self.logger.log_action(f"填写邮箱: {data['email']}")
# 主题
if "subject" in data:
self._fill("form_subject_input", data["subject"])
self.logger.log_action(f"填写主题: {data['subject']}")
# 消息
if "message" in data:
self._fill("form_message_textarea", data["message"])
self.logger.log_action(f"填写消息: {data['message'][:50]}...")
return self
def submit_form(self, wait_for_response: bool = True) -> 'ContactPage':
"""提交表单"""
self.logger.log_action("提交联系表单")
# 等待表单按钮可用
submit_button = self._find("form_submit_button")
# 点击提交
submit_button.click()
if wait_for_response:
# 等待加载完成
self.page.wait_for_load_state("networkidle")
# 检查是否显示成功消息
try:
self.assert_element_visible("success_message", timeout=10000)
self.logger.info("表单提交成功")
except Exception:
self.logger.warning("未检测到成功消息,可能提交失败或无反馈")
return self
def verify_form_submission_success(self) -> 'ContactPage':
"""验证表单提交成功"""
self.logger.section("验证表单提交成功")
# 检查成功消息
self.assert_element_visible("success_message")
# 验证成功消息文本
success_text = self._get_text("success_message")
assert "已发送" in success_text or "成功" in success_text, \
f"成功消息不正确: {success_text}"
self.logger.info("✅ 表单提交成功验证通过")
return self
def verify_form_validation(self) -> 'ContactPage':
"""验证表单验证"""
self.logger.section("验证表单验证")
# 尝试提交空表单
self._click("form_submit_button")
# 检查浏览器原生验证
name_input = self._find("form_name_input")
is_required = name_input.evaluate("el => el.required")
if is_required:
self.logger.info("姓名字段为必填项")
# 验证邮箱格式
self._fill("form_email_input", "invalid-email")
self._click("form_subject_input")
# 检查HTML5验证
email_input = self._find("form_email_input")
validity = email_input.evaluate("""
el => ({
valid: el.validity.valid,
typeMismatch: el.validity.typeMismatch,
valueMissing: el.validity.valueMissing
})
""")
if not validity["valid"] and validity["typeMismatch"]:
self.logger.info("邮箱格式验证正常工作")
self.logger.info("✅ 表单验证验证通过")
return self
def verify_form_with_invalid_email(self, data: Dict[str, str]) -> 'ContactPage':
"""使用无效邮箱测试表单验证"""
self.logger.section("测试无效邮箱")
# 填写表单(使用无效邮箱)
data["email"] = "invalid-email"
self.fill_contact_form(data)
# 尝试提交
self._click("form_submit_button")
# 检查是否被HTML5验证阻止
email_input = self._find("form_email_input")
is_valid = email_input.evaluate("el => el.validity.valid")
if not is_valid:
self.logger.info("无效邮箱被正确阻止")
else:
self.logger.warning("无效邮箱未被验证阻止,可能存在后端验证")
return self
def test_form_submission_performance(
self,
data: Dict[str, str],
max_duration: float = 5.0
) -> Dict[str, Any]:
"""测试表单提交性能"""
self.logger.section("表单提交性能测试")
import time
# 填充表单
self.fill_contact_form(data)
# 记录开始时间
start_time = time.time()
# 提交表单
self._click("form_submit_button")
# 等待成功消息
try:
self.assert_element_visible("success_message", timeout=10000)
except Exception:
pass
# 记录结束时间
end_time = time.time()
duration = end_time - start_time
# 验证性能
if duration <= max_duration:
self.logger.info(f"✅ 表单提交耗时 {duration:.2f}s,在阈值 {max_duration}s 内")
else:
self.logger.warning(f"⚠️ 表单提交耗时 {duration:.2f}s,超过阈值 {max_duration}s")
return {
"duration": duration,
"passed": duration <= max_duration
}
def get_working_hours(self) -> Dict[str, str]:
"""获取工作时间"""
# 从页面内容中提取工作时间
page_text = self.page.content()
hours = {}
# 检查工作时间文本
if "周一至周五" in page_text:
hours["周一至周五"] = "9:00 - 18:00"
if "周六" in page_text:
hours["周六"] = "9:00 - 12:00"
if "周日" in page_text:
hours["周日"] = "休息"
return hours
def reset_form(self) -> 'ContactPage':
"""重置表单"""
self.logger.log_action("重置表单")
# 刷新页面
self.reload()
self.wait_for_load()
return self
def verify_responsive_layout(self, width: int) -> 'ContactPage':
"""验证响应式布局"""
self.logger.section(f"响应式测试 ({width}px)")
# 设置视口
self.page.set_viewport_size({"width": width, "height": 800})
self.wait_for_load()
# 验证布局
self.assert_element_visible("contact_form", timeout=5000)
# 检查布局变化
if width < 768:
self.logger.info("移动端布局:单列布局")
elif width < 1024:
self.logger.info("平板端布局:双列布局")
else:
self.logger.info("桌面端布局:完整布局")
self.logger.info(f"{width}px 响应式测试通过")
return self
def extract_contact_details(self) -> Dict[str, str]:
"""提取联系详情"""
details = {}
# 从页面内容中提取
page_content = self.page.content()
# 公司地址
if "公司地址" in page_content:
details["address"] = "已找到地址信息"
# 联系电话
if "联系电话" in page_content:
details["phone"] = "已找到电话信息"
# 电子邮箱
if "电子邮箱" in page_content:
details["email"] = "已找到邮箱信息"
return details
+411
View File
@@ -0,0 +1,411 @@
"""
首页测试模块
提供首页功能测试
"""
from typing import Any, Dict, List, Optional
from playwright.sync_api import Page, Locator
from pages.base_page import BasePage
from config.settings import get_settings
from utils.logger import get_logger
class HomePage(BasePage):
"""首页页面对象"""
def __init__(self, page: Page, base_url: Optional[str] = None):
"""初始化首页"""
super().__init__(page, base_url)
self.path = "/"
self.title = "四川睿新致远科技有限公司"
self.selectors = {
# 导航相关
"header": "header",
"logo": "header img[alt*='logo'], header a[href='#home']",
"navigation": "header nav, nav",
"nav_links": "nav a, header a[href^='#']",
# Hero区域
"hero_section": "#home",
"hero_title": "#home h1, .hero-section h1",
"hero_subtitle": "#home p, .hero-section p",
"hero_cta": "#home a[href*='#contact'], .hero-section a.cta",
# 关于我们区域
"about_section": "#about, .about-section",
"about_title": "#about h2, .about-section h2",
"about_content": "#about .content, .about-section .content",
# 核心业务区域
"services_section": "#services, .services-section",
"services_title": "#services h2, .services-section h2",
"services_cards": "#services .card, .services-section .card, #services .service-card",
# 产品服务区域
"products_section": "#products, .products-section",
"products_title": "#products h2, .products-section h2",
"products_grid": "#products .grid, .products-section .grid, #products .product-grid",
"product_cards": "#products .card, .products-section .card",
# 新闻动态区域
"news_section": "#news, .news-section",
"news_title": "#news h2, .news-section h2",
"news_list": "#news .list, .news-section .news-list",
"news_items": "#news .news-item, .news-section .news-item",
# 联系我们区域
"contact_section": "#contact, .contact-section",
"contact_title": "#contact h2, .contact-section h2",
"contact_form": "#contact form, .contact-section form",
# 页脚
"footer": "footer",
"footer_content": "footer .content, footer .footer-content",
"social_links": "footer .social-links, footer a[href*='weixin'], footer a[href*='weibo']"
}
self.logger = get_logger()
def navigate(self, **kwargs) -> 'HomePage':
"""导航到首页"""
super().navigate(**kwargs)
self.wait_for_load()
return self
def verify_page_loaded(self) -> 'HomePage':
"""验证页面加载完成"""
self.logger.section("验证首页加载")
# 检查关键元素存在
self.assert_element_visible("header", timeout=10000)
self.assert_element_visible("main", timeout=10000)
self.assert_element_visible("footer", timeout=10000)
# 检查页面标题
self.assert_title_contains("睿新致远")
self.logger.info("✅ 首页加载验证通过")
return self
def verify_header(self) -> 'HomePage':
"""验证页头"""
self.logger.section("验证页头")
# 检查Logo
if self._is_visible("logo"):
self.logger.info("Logo存在")
# 检查导航链接 - 实际有6个导航项
nav_links = self._find_all("nav_links")
expected_count = 6 # 首页、关于我们、核心业务、产品服务、新闻动态、联系我们
self.assert_element_count("nav a, nav a[href^='#']", expected_count)
self.logger.info(f"✅ 页头验证通过,发现 {len(nav_links)} 个导航链接")
return self
def verify_hero_section(self) -> 'HomePage':
"""验证Hero区域"""
self.logger.section("验证Hero区域")
if self._is_visible("hero_section"):
self.assert_element_visible("hero_title")
self.assert_element_visible("hero_subtitle")
self.logger.info("Hero区域完整")
# 获取标题文本
title = self._get_text("hero_title")
self.logger.info(f"Hero标题: {title[:50]}...")
else:
self.logger.warning("未找到Hero区域")
return self
def verify_services_section(self) -> 'HomePage':
"""验证核心业务区域"""
self.logger.section("验证核心业务区域")
if self._is_visible("services_section"):
self.assert_element_visible("services_title")
# 检查业务卡片
cards = self._find_all("services_cards")
self.logger.info(f"发现 {len(cards)} 个服务卡片")
if len(cards) > 0:
self.logger.info("✅ 服务区域验证通过")
else:
self.logger.warning("未找到服务区域")
return self
def verify_products_section(self) -> 'HomePage':
"""验证产品服务区域"""
self.logger.section("验证产品服务区域")
if self._is_visible("products_section"):
self.assert_element_visible("products_title")
# 检查产品卡片
cards = self._find_all("product_cards")
self.logger.info(f"发现 {len(cards)} 个产品卡片")
if len(cards) > 0:
self.logger.info("✅ 产品区域验证通过")
else:
self.logger.warning("未找到产品区域")
return self
def verify_news_section(self) -> 'HomePage':
"""验证新闻动态区域"""
self.logger.section("验证新闻动态区域")
if self._is_visible("news_section"):
self.assert_element_visible("news_title")
# 检查新闻列表
items = self._find_all("news_items")
self.logger.info(f"发现 {len(items)} 条新闻")
if len(items) > 0:
self.logger.info("✅ 新闻区域验证通过")
else:
self.logger.warning("未找到新闻区域")
return self
def verify_contact_section(self) -> 'HomePage':
"""验证联系我们区域"""
self.logger.section("验证联系我们区域")
if self._is_visible("contact_section"):
self.assert_element_visible("contact_title")
self.assert_element_visible("contact_form")
self.logger.info("联系区域包含表单")
# 检查表单字段
form_fields = ["name", "email", "subject", "message"]
for field in form_fields:
if self._is_visible(f"contact_form #{field}"):
self.logger.info(f"表单字段 {field} 存在")
self.logger.info("✅ 联系区域验证通过")
else:
self.logger.warning("未找到联系区域")
return self
def verify_footer(self) -> 'HomePage':
"""验证页脚"""
self.logger.section("验证页脚")
self.assert_element_visible("footer")
# 检查版权信息
footer_text = self._get_text("footer")
if "睿新致远" in footer_text or "2026" in footer_text:
self.logger.info("页脚包含版权信息")
self.logger.info("✅ 页脚验证通过")
return self
def verify_all_sections(self) -> 'HomePage':
"""验证所有区域"""
self.verify_header()
self.verify_hero_section()
self.verify_services_section()
self.verify_products_section()
self.verify_news_section()
self.verify_contact_section()
self.verify_footer()
self.logger.info("✅ 首页所有区域验证完成")
return self
def scroll_to_section(self, section: str) -> 'HomePage':
"""滚动到指定区域"""
self.logger.log_action(f"滚动到{section}区域")
section_selectors = {
"home": "#home",
"about": "#about",
"services": "#services",
"products": "#products",
"news": "#news",
"contact": "#contact"
}
selector = section_selectors.get(section, f"#{section}")
if self._is_visible(selector):
self.scroll_to_element(selector)
self.logger.info(f"已滚动到{section}区域")
else:
self.logger.warning(f"未找到{section}区域")
return self
def click_navigation_link(self, section: str) -> 'HomePage':
"""点击导航链接"""
self.logger.log_action(f"点击{section}导航链接")
nav_items = {
"home": "首页",
"about": "关于我们",
"services": "核心业务",
"products": "产品服务",
"news": "新闻动态",
"contact": "联系我们"
}
label = nav_items.get(section, section)
# 查找包含指定文本的导航链接
nav_link = self.page.locator(f"nav a:has-text('{label}'), header a:has-text('{label}')")
if nav_link.count() > 0:
nav_link.first.click()
self.wait_for_load()
self.logger.info(f"已点击{nav_items.get(section, section)}链接")
else:
self.logger.warning(f"未找到{nav_items.get(section, section)}链接")
return self
def get_company_info(self) -> Dict[str, str]:
"""获取公司信息"""
info = {}
# 从首页获取描述
hero_text = ""
if self._is_visible("hero_subtitle"):
hero_text = self._get_text("hero_subtitle")
# 如果无法从页面获取,使用默认值
info["description"] = hero_text if hero_text else "专注科技创新,驱动智慧未来"
# 从常量获取
info["name"] = "四川睿新致远科技有限公司"
info["slogan"] = "专注科技创新,驱动智慧未来"
return info
def get_statistics(self) -> Dict[str, int]:
"""获取统计数据"""
stats = {}
# 尝试从页面获取统计数据
if self._is_visible("about_section"):
# 这里需要根据实际页面结构调整
pass
# 默认值
stats = {
"customers": 50,
"cases": 100,
"projects": 200,
"experience": 8
}
return stats
def get_featured_services(self) -> List[Dict[str, str]]:
"""获取精选服务"""
services = []
if self._is_visible("services_cards"):
cards = self._find_all("services_cards")[:4]
for card in cards:
title = card.locator("h3, .title").text_content() if card.locator("h3, .title").count() > 0 else ""
description = card.locator("p, .description").text_content() if card.locator("p, .description").count() > 0 else ""
services.append({
"title": title.strip() if title else "",
"description": description.strip() if description else ""
})
return services
def get_latest_news(self) -> List[Dict[str, str]]:
"""获取最新新闻"""
news = []
if self._is_visible("news_items"):
items = self._find_all("news_items")[:3]
for item in items:
title = item.locator("h3, .title, a").first.text_content() if item.locator("h3, .title, a").count() > 0 else ""
date = item.locator(".date, time").first.text_content() if item.locator(".date, time").count() > 0 else ""
news.append({
"title": title.strip() if title else "",
"date": date.strip() if date else ""
})
return news
def verify_page_performance(self) -> Dict[str, float]:
"""验证页面性能指标"""
self.logger.section("性能测试")
performance_data = self.execute_js("""
() => {
const timing = performance.timing;
const navigation = performance.getEntriesByType('navigation')[0];
return {
// 关键指标
'pageLoadTime': timing.loadEventEnd - timing.navigationStart,
'domContentLoaded': timing.domContentLoadedEventEnd - timing.navigationStart,
'firstPaint': timing.responseStart - timing.navigationStart,
'firstContentfulPaint': navigation ? navigation.firstContentfulPaint : 0,
'largestContentfulPaint': navigation ? navigation.largestContentfulPaint : 0,
'timeToInteractive': navigation ? navigation.interactive : 0,
// 资源指标
'domainLookupTime': timing.domainLookupEnd - timing.domainLookupStart,
'serverResponseTime': timing.responseEnd - timing.requestStart,
'tcpConnectTime': timing.connectEnd - timing.connectStart,
'domInteractiveTime': timing.domInteractive - timing.domLoading
};
}
""")
# 记录性能指标
for metric, value in performance_data.items():
if value and value > 0:
threshold = get_settings().performance_thresholds.__dict__.get(
metric.replace("_", ""), 3000
)
self.logger.log_performance(metric, float(value), threshold)
return performance_data
def verify_responsive_design(self, width: int, height: int) -> 'HomePage':
"""验证响应式设计"""
self.logger.section(f"响应式测试 ({width}x{height})")
# 设置视口大小
self.page.set_viewport_size({"width": width, "height": height})
self.wait_for_load()
# 验证关键元素
self.assert_element_visible("header", timeout=5000)
self.assert_element_visible("main", timeout=5000)
self.assert_element_visible("footer", timeout=5000)
# 根据屏幕大小调整验证逻辑
if width < 768:
self.logger.info(f"移动端 {width}px: 验证基础布局")
# 移动端检查汉堡菜单
mobile_menu = self.page.locator("button:has-text('菜单'), .mobile-menu, .menu-toggle")
self.logger.info(f"发现 {mobile_menu.count()} 个移动端菜单元素")
elif width < 1024:
self.logger.info(f"平板端 {width}px: 验证平板布局")
else:
self.logger.info(f"桌面端 {width}px: 验证完整布局")
self.logger.info(f"{width}x{height} 响应式测试通过")
return self
+48
View File
@@ -0,0 +1,48 @@
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"--tb=short",
"--strict-markers",
"-v",
"--html=reports/test_report.html",
"--self-contained-html",
"--cov=utils",
"--cov-report=html",
"--cov-report=term-missing"
]
markers = [
"smoke: 冒烟测试,快速验证核心功能",
"regression: 回归测试,完整功能验证",
"performance: 性能测试,页面加载和响应时间",
"responsive: 响应式测试,不同屏幕尺寸",
"cross_browser: 跨浏览器测试",
"form: 表单相关测试",
"navigation: 导航测试",
"interactive: 用户交互测试"
]
filterwarnings = [
"ignore::DeprecationWarning",
"ignore::PendingDeprecationWarning",
"ignore::pytest.PytestUnraisableExceptionWarning"
]
[tool.pytest]
# pytest-asyncio配置
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[tool.coverage.run]
branch = true
source = ["pages", "utils", "tests"]
omit = ["tests/*", "utils/report_generator.py", "utils/data_generator.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError"
]
+24
View File
@@ -0,0 +1,24 @@
[pytest]
# 配置文件
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
--tb=short
-v
markers =
smoke: 冒烟测试,快速验证核心功能
regression: 回归测试,完整功能验证
performance: 性能测试,页面加载和响应时间
responsive: 响应式测试,不同屏幕尺寸
cross_browser: 跨浏览器测试
form: 表单相关测试
navigation: 导航测试
interactive: 用户交互测试
[tool:pytest]
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
ignore::pytest.PytestUnraisableExceptionWarning
+21
View File
@@ -0,0 +1,21 @@
# E2E测试框架依赖
# Novalon Website 端到端测试解决方案
playwright>=1.52.0
pytest>=8.3.0
pytest-html>=4.1.1
pytest-xdist>=3.6.1
pytest-timeout>=2.3.1
pytest-rerunfailures>=14.0
python-dotenv>=1.0.0
requests>=2.31.0
beautifulsoup4>=4.12.0
lxml>=5.1.0
jinja2>=3.1.0
markdown>=3.5.0
rich>=13.7.0
tabulate>=0.9.0
pillow>=10.2.0
matplotlib>=3.8.0
numpy>=1.26.0
selenium>=4.18.0
+1
View File
@@ -0,0 +1 @@
# Scripts模块
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
CI/CD 测试脚本
用于持续集成环境中的测试执行
"""
import os
import sys
import json
import time
from pathlib import Path
from datetime import datetime
# 添加项目路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from config.settings import get_settings
from utils.logger import get_logger
class CITestRunner:
"""CI测试运行器"""
def __init__(self):
self.logger = get_logger()
self.settings = get_settings()
self.results = {
"timestamp": datetime.now().isoformat(),
"environment": os.environ.get("ENVIRONMENT", "development"),
"browser": os.environ.get("PLAYWRIGHT_BROWSER", "chromium"),
"tests": [],
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"skipped": 0,
"error": 0,
"duration": 0
}
}
def run_smoke_tests(self) -> bool:
"""运行冒烟测试"""
self.logger.section("运行冒烟测试")
from playwright.sync_api import sync_playwright
import pytest
pytest_args = [
"-c", str(project_root / "pytest.ini"),
"-m", "smoke",
"-v",
"--tb=short",
"--json-report",
"--json-report-file=reports/smoke_test_results.json",
"tests"
]
try:
exit_code = pytest.main(pytest_args)
return exit_code == 0
except Exception as e:
self.logger.error(f"冒烟测试失败: {e}")
return False
def run_regression_tests(self) -> bool:
"""运行回归测试"""
self.logger.section("运行回归测试")
import pytest
pytest_args = [
"-c", str(project_root / "pytest.ini"),
"-m", "regression",
"-v",
"--tb=short",
"--json-report",
"--json-report-file=reports/regression_test_results.json",
"tests"
]
try:
exit_code = pytest.main(pytest_args)
return exit_code == 0
except Exception as e:
self.logger.error(f"回归测试失败: {e}")
return False
def run_performance_tests(self) -> bool:
"""运行性能测试"""
self.logger.section("运行性能测试")
import pytest
pytest_args = [
"-c", str(project_root / "pytest.ini"),
"-m", "performance",
"-v",
"--tb=short",
"--json-report",
"--json-report-file=reports/performance_test_results.json",
"tests"
]
try:
exit_code = pytest.main(pytest_args)
return exit_code == 0
except Exception as e:
self.logger.error(f"性能测试失败: {e}")
return False
def run_cross_browser_tests(self) -> dict:
"""运行跨浏览器测试"""
self.logger.section("运行跨浏览器测试")
import pytest
browsers = ["chromium", "firefox", "webkit"]
results = {}
for browser in browsers:
self.logger.info(f"测试浏览器: {browser}")
os.environ["PLAYWRIGHT_BROWSER"] = browser
pytest_args = [
"-c", str(project_root / "pytest.ini"),
"-m", "smoke",
"-v",
"--tb=short",
f"--json-report=reports/{browser}_test_results.json",
"tests"
]
try:
exit_code = pytest.main(pytest_args)
results[browser] = exit_code == 0
except Exception as e:
self.logger.error(f"{browser} 测试失败: {e}")
results[browser] = False
return results
def run_full_test_suite(self) -> bool:
"""运行完整测试套件"""
self.logger.section("运行完整测试套件")
import pytest
pytest_args = [
"-c", str(project_root / "pytest.ini"),
"-v",
"--tb=short",
"--json-report",
"--json-report-file=reports/full_test_results.json",
"--html=reports/full_test_report.html",
"--self-contained-html",
"tests"
]
try:
exit_code = pytest.main(pytest_args)
return exit_code == 0
except Exception as e:
self.logger.error(f"完整测试失败: {e}")
return False
def generate_ci_report(self, test_results: dict):
"""生成CI测试报告"""
report_path = Path("reports/ci_test_report.json")
report_path.parent.mkdir(parents=True, exist_ok=True)
with open(report_path, "w", encoding="utf-8") as f:
json.dump(test_results, f, indent=2, ensure_ascii=False)
self.logger.info(f"CI报告已生成: {report_path}")
def run_ci_tests(self, test_type: str = "full"):
"""运行CI测试"""
start_time = time.time()
self.logger.section("开始 CI 测试")
self.logger.info(f"环境: {self.results['environment']}")
self.logger.info(f"浏览器: {self.results['browser']}")
self.logger.info(f"测试类型: {test_type}")
success = False
if test_type == "smoke":
success = self.run_smoke_tests()
elif test_type == "regression":
success = self.run_regression_tests()
elif test_type == "performance":
success = self.run_performance_tests()
elif test_type == "cross_browser":
cross_results = self.run_cross_browser_tests()
success = all(cross_results.values())
self.results["cross_browser_results"] = cross_results
elif test_type == "full":
success = self.run_full_test_suite()
else:
self.logger.error(f"未知的测试类型: {test_type}")
return False
end_time = time.time()
self.results["summary"]["duration"] = end_time - start_time
self.results["success"] = success
self.logger.section("CI 测试完成")
self.logger.info(f"总耗时: {self.results['summary']['duration']:.2f}")
self.logger.info(f"测试结果: {'成功' if success else '失败'}")
# 生成报告
self.generate_ci_report(self.results)
return success
def parse_ci_arguments():
"""解析CI参数"""
parser = argparse.ArgumentParser(
description="Novalon Website CI 测试运行器"
)
parser.add_argument(
"--test-type",
default="full",
choices=["smoke", "regression", "performance", "cross_browser", "full"],
help="测试类型 (默认: full)"
)
parser.add_argument(
"--env",
default="development",
choices=["development", "staging", "production"],
help="测试环境 (默认: development)"
)
parser.add_argument(
"--browser",
default="chromium",
choices=["chromium", "firefox", "webkit", "all"],
help="浏览器 (默认: chromium)"
)
parser.add_argument(
"--report-dir",
default="reports",
help="报告目录 (默认: reports)"
)
return parser.parse_args()
def main():
"""CI主函数"""
args = parse_ci_arguments()
# 设置环境变量
os.environ["ENVIRONMENT"] = args.env
os.environ["PLAYWRIGHT_BROWSER"] = args.browser
os.environ["REPORT_DIR"] = args.report_dir
# 确保报告目录存在
Path(args.report_dir).mkdir(parents=True, exist_ok=True)
# 运行测试
runner = CITestRunner()
success = runner.run_ci_tests(args.test_type)
# 输出结果
if success:
print("\n✅ CI 测试通过")
sys.exit(0)
else:
print("\n❌ CI 测试失败")
sys.exit(1)
if __name__ == "__main__":
main()
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
测试运行脚本
提供便捷的测试执行命令
"""
import os
import sys
import argparse
from pathlib import Path
# 添加项目路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from config.settings import get_settings
from utils.logger import get_logger
def parse_arguments():
"""解析命令行参数"""
parser = argparse.ArgumentParser(
description="Novalon Website E2E 测试运行器"
)
parser.add_argument(
"-b", "--browser",
default="chromium",
choices=["chromium", "firefox", "webkit", "all"],
help="指定浏览器 (默认: chromium)"
)
parser.add_argument(
"-h", "--headless",
action="store_true",
default=False,
help="以无头模式运行 (默认: False)"
)
parser.add_argument(
"-m", "--marker",
default="",
help="运行指定标记的测试 (例如: -m smoke)"
)
parser.add_argument(
"-k", "--keyword",
default="",
help="运行包含关键字的测试 (例如: -k home)"
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
default=False,
help="显示详细输出"
)
parser.add_argument(
"--html",
action="store_true",
default=False,
help="生成HTML测试报告"
)
parser.add_argument(
"--video",
action="store_true",
default=False,
help="录制测试视频"
)
parser.add_argument(
"--screenshot",
action="store_true",
default=False,
help="失败时截图"
)
parser.add_argument(
"--parallel",
action="store_true",
default=False,
help="并行执行测试"
)
parser.add_argument(
"--workers",
type=int,
default=4,
help="并行工作数 (默认: 4)"
)
parser.add_argument(
"--report-dir",
default="reports",
help="报告目录 (默认: reports)"
)
parser.add_argument(
"--env",
default="development",
choices=["development", "staging", "production"],
help="测试环境 (默认: development)"
)
parser.add_argument(
"test_paths",
nargs="*",
default=["tests"],
help="测试路径 (默认: tests)"
)
return parser.parse_args()
def build_pytest_args(args):
"""构建pytest参数"""
pytest_args = []
# 配置文件
pytest_args.append("-c")
pytest_args.append(str(project_root / "pytest.ini"))
# 浏览器参数
os.environ["PLAYWRIGHT_BROWSER"] = args.browser
# 无头模式
if args.headless:
os.environ["PLAYWRIGHT_HEADLESS"] = "1"
# 标记过滤
if args.marker:
pytest_args.append(f"-m={args.marker}")
# 关键字过滤
if args.keyword:
pytest_args.append(f"-k={args.keyword}")
# 详细输出
if args.verbose:
pytest_args.append("-v")
pytest_args.append("--tb=short")
# HTML报告
if args.html:
pytest_args.append("--html=reports/test_report.html")
pytest_args.append("--self-contained-html")
# 视频录制
if args.video:
os.environ["PLAYWRIGHT_VIDEO"] = "1"
# 失败截图
if args.screenshot:
os.environ["PLAYWRIGHT_SCREENSHOT"] = "1"
# 并行执行
if args.parallel:
pytest_args.append(f"-n={args.workers}")
pytest_args.append("--dist=loadscope")
# 报告目录
if args.report_dir:
pytest_args.append(f"--report-dir={args.report_dir}")
# 测试路径
pytest_args.extend(args.test_paths)
# 覆盖率(可选)
pytest_args.append("--cov=e2e-tests")
pytest_args.append("--cov-report=term-missing")
return pytest_args
def run_tests(args):
"""运行测试"""
logger = get_logger()
logger.section("开始 E2E 测试")
logger.info(f"浏览器: {args.browser}")
logger.info(f"无头模式: {args.headless}")
logger.info(f"测试路径: {args.test_paths}")
if args.marker:
logger.info(f"测试标记: {args.marker}")
if args.keyword:
logger.info(f"关键字过滤: {args.keyword}")
# 构建pytest参数
pytest_args = build_pytest_args(args)
# 导入pytest
import pytest
# 运行测试
exit_code = pytest.main(pytest_args)
logger.section("测试运行完成")
return exit_code
def main():
"""主函数"""
args = parse_arguments()
# 设置环境
os.environ["ENVIRONMENT"] = args.env
try:
exit_code = run_tests(args)
sys.exit(exit_code)
except KeyboardInterrupt:
print("\n测试被用户中断")
sys.exit(130)
except Exception as e:
print(f"测试运行出错: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
# Tests模块
+343
View File
@@ -0,0 +1,343 @@
"""
测试配置文件
提供全局测试fixture和钩子函数
"""
import os
import sys
import time
from pathlib import Path
from typing import Generator, Optional
import pytest
from pytest import Config
from playwright.sync_api import Browser, BrowserContext, Page, Playwright
from playwright.sync_api import Error as PlaywrightError
# 添加项目根目录到Python路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from config.settings import get_settings
from config.browsers import get_browser_factory, BrowserConfigManager
from utils.logger import get_logger
from utils.report_generator import get_report_manager, TestResult, TestStatus
from utils.data_generator import get_test_data_generator
@pytest.fixture(scope="session")
def settings() -> Generator:
"""获取测试配置"""
yield get_settings()
@pytest.fixture(scope="session")
def logger() -> Generator:
"""获取日志记录器"""
yield get_logger()
@pytest.fixture(scope="session")
def test_data_generator() -> Generator:
"""获取测试数据生成器"""
yield get_test_data_generator()
@pytest.fixture(scope="session")
def browser_factory() -> Generator:
"""获取浏览器工厂"""
factory = get_browser_factory()
yield factory
@pytest.fixture(scope="session")
def browser_context(
browser_factory: BrowserConfigManager,
settings
) -> Generator:
"""创建浏览器上下文"""
browser, context, page = browser_factory.create_browser_session(
browser_name=settings.default_browser,
headless=settings.headless_mode
)
yield context, page
# 清理(在会话结束时)
try:
page.close()
except Exception:
pass
try:
context.close()
except Exception:
pass
try:
browser_factory.close_browser()
except Exception:
pass
@pytest.fixture
def page(browser_context, settings) -> Generator:
"""创建页面"""
context, page = browser_context
# 设置默认超时
page.set_default_timeout(settings.page_load_timeout)
page.set_default_navigation_timeout(settings.page_load_timeout)
yield page
# 截图(如果测试失败)
if hasattr(page, "_test_failed") and page._test_failed:
test_name = getattr(pytest, "_test_name", "unknown")
screenshots_dir = Path(settings.screenshots_dir)
screenshots_dir.mkdir(parents=True, exist_ok=True)
screenshot_path = screenshots_dir / f"{test_name}_failed.png"
try:
page.screenshot(path=str(screenshot_path))
logger = get_logger()
logger.error(f"失败截图已保存: {screenshot_path}")
except Exception as e:
logger = get_logger()
logger.error(f"保存失败截图时出错: {e}")
@pytest.fixture
def home_page(page: Page, settings) -> Generator:
"""创建首页对象"""
from pages.home_page import HomePage
home = HomePage(page, settings.get_base_url())
yield home
@pytest.fixture
def contact_page(page: Page, settings) -> Generator:
"""创建联系页面对象"""
from pages.contact_page import ContactPage
contact = ContactPage(page, settings.get_base_url())
yield contact
@pytest.fixture(scope="session")
def base_url(settings) -> str:
"""获取基础URL"""
return settings.get_base_url()
@pytest.fixture(scope="session")
def test_results() -> Generator:
"""收集测试结果"""
results = []
yield results
@pytest.fixture(scope="session")
def report_manager() -> Generator:
"""获取报告管理器"""
manager = get_report_manager()
yield manager
@pytest.fixture
def track_test_result(report_manager, test_results):
"""跟踪测试结果fixture"""
from datetime import datetime
import pytest
class TestTracker:
def __init__(self):
self.start_time = None
self.current_result = None
def start_track(self, test_name: str, test_class: str = "", test_file: str = ""):
self.start_time = datetime.now()
logger = get_logger()
logger.log_test_start(test_name, test_class=test_class, test_file=test_file)
def end_track(
self,
test_name: str,
status: TestStatus,
test_class: str = "",
test_file: str = ""
):
end_time = datetime.now()
duration = (end_time - self.start_time).total_seconds()
logger = get_logger()
logger.log_test_end(test_name, status.value, duration)
# 创建测试结果
result = TestResult(
test_id=f"{test_file}_{test_name}",
test_name=test_name,
test_file=test_file,
test_class=test_class,
status=status,
start_time=self.start_time,
end_time=end_time,
duration=duration
)
# 添加到报告管理器
report_manager.add_result(result)
test_results.append(result)
return result
tracker = TestTracker()
yield tracker
# Pytest钩子函数
def pytest_configure(config: Config):
"""pytest配置钩子"""
# 设置标记
config.addinivalue_line(
"markers", "smoke: 冒烟测试,快速验证核心功能"
)
config.addinivalue_line(
"markers", "regression: 回归测试,完整功能验证"
)
config.addinivalue_line(
"markers", "performance: 性能测试,页面加载和响应时间"
)
config.addinivalue_line(
"markers", "responsive: 响应式测试,不同屏幕尺寸"
)
config.addinivalue_line(
"markers", "cross_browser: 跨浏览器测试"
)
config.addinivalue_line(
"markers", "form: 表单相关测试"
)
config.addinivalue_line(
"markers", "navigation: 导航测试"
)
config.addinivalue_line(
"markers", "interactive: 用户交互测试"
)
# 创建必要的目录
settings = get_settings()
Path(settings.screenshots_dir).mkdir(parents=True, exist_ok=True)
Path(settings.videos_dir).mkdir(parents=True, exist_ok=True)
Path("reports").mkdir(parents=True, exist_ok=True)
def pytest_sessionstart(session):
"""测试会话开始"""
logger = get_logger()
logger.section("开始E2E测试会话")
logger.info(f"测试会话ID: {session.name}")
logger.info(f"测试数量: {len(session.items)}")
def pytest_sessionfinish(session, exitstatus):
"""测试会话结束"""
logger = get_logger()
logger.section("E2E测试会话结束")
# 生成报告
if exitstatus == 0:
logger.info("✅ 所有测试通过")
else:
logger.warning(f"⚠️ 测试完成,退出码: {exitstatus}")
def pytest_runtest_setup(item):
"""测试运行前设置"""
logger = get_logger()
logger.divider()
logger.log_test_start(item.name)
def pytest_runtest_makereport(item, call):
"""生成测试报告"""
if call.when == "call":
if call.excinfo:
item._test_failed = True
else:
item._test_failed = False
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_call(item):
"""测试运行钩子"""
yield
# 测试运行后处理
def pytest_collection_modifyitems(config, items):
"""修改测试项目"""
# 按标记排序
marker_priority = {
"smoke": 0,
"performance": 1,
"regression": 2,
"responsive": 3,
"cross_browser": 4,
"form": 5,
"navigation": 6,
"interactive": 7
}
def get_priority(item):
for marker in item.iter_markers():
if marker.name in marker_priority:
return marker_priority[marker.name]
return 10
items.sort(key=get_priority)
# 自定义断言
class E2EAssertions:
"""E2E测试断言类"""
def __init__(self, page: Page):
self.page = page
self.logger = get_logger()
def title_contains(self, expected: str, message: Optional[str] = None) -> None:
"""断言标题包含预期文本"""
actual = self.page.title()
assert expected in actual, message or f"标题不包含 '{expected}': {actual}"
self.logger.log_assertion(f"标题包含 '{expected}'", True)
def url_contains(self, expected: str, message: Optional[str] = None) -> None:
"""断言URL包含预期文本"""
assert expected in self.page.url, message or f"URL不包含 '{expected}': {self.page.url}"
self.logger.log_assertion(f"URL包含 '{expected}'", True)
def element_exists(self, selector: str, timeout: int = 5000) -> None:
"""断言元素存在"""
try:
self.page.wait_for_selector(selector, timeout=timeout)
self.logger.log_assertion(f"元素存在: {selector}", True)
except Exception as e:
self.logger.log_assertion(f"元素存在: {selector}", False)
raise AssertionError(f"元素不存在: {selector}") from e
def element_visible(self, selector: str, timeout: int = 5000) -> None:
"""断言元素可见"""
element = self.page.locator(selector).first
assert element.is_visible(timeout=timeout), f"元素不可见: {selector}"
self.logger.log_assertion(f"元素可见: {selector}", True)
def element_not_visible(self, selector: str, timeout: int = 5000) -> None:
"""断言元素不可见"""
element = self.page.locator(selector).first
assert not element.is_visible(timeout=timeout), f"元素应该不可见: {selector}"
self.logger.log_assertion(f"元素不可见: {selector}", True)
@pytest.fixture
def assert_(page: Page) -> E2EAssertions:
"""断言fixture"""
return E2EAssertions(page)
+262
View File
@@ -0,0 +1,262 @@
"""
联系表单测试模块
测试联系表单的各项功能和验证
"""
import pytest
from typing import Dict, Any
from pages.contact_page import ContactPage
class TestContactForm:
"""联系表单测试类"""
@pytest.mark.smoke
@pytest.mark.form
def test_contact_page_loads(self, contact_page: ContactPage):
"""测试联系页面加载"""
contact_page.navigate()
contact_page.verify_page_loaded()
@pytest.mark.smoke
def test_contact_page_title(self, contact_page: ContactPage):
"""测试联系页面标题"""
contact_page.navigate()
contact_page.assert_title_contains("四川睿新致远")
@pytest.mark.regression
@pytest.mark.form
def test_contact_page_structure(self, contact_page: ContactPage):
"""测试联系页面结构"""
contact_page.navigate()
contact_page.verify_page_structure()
@pytest.mark.regression
def test_contact_page_company_info(self, contact_page: ContactPage):
"""测试公司信息显示"""
contact_page.navigate()
contact_page.verify_company_info()
@pytest.mark.regression
def test_contact_page_form_fields(self, contact_page: ContactPage):
"""测试表单字段"""
contact_page.navigate()
contact_page.verify_form_fields()
@pytest.mark.form
def test_form_validation_required_fields(self, contact_page: ContactPage):
"""测试必填字段验证"""
contact_page.navigate()
contact_page.verify_form_validation()
@pytest.mark.form
def test_form_submission_success(self, contact_page: ContactPage, test_data_generator):
"""测试表单提交成功"""
contact_page.navigate()
# 生成测试数据
data = test_data_generator.generate_contact_form_data(use_valid=True)
# 填写并提交表单
contact_page.fill_contact_form(data)
contact_page.submit_form()
# 验证成功
contact_page.verify_form_submission_success()
@pytest.mark.form
def test_form_submission_with_minimal_data(self, contact_page: ContactPage):
"""测试表单提交(最小数据)"""
contact_page.navigate()
# 最小数据
data = {
"name": "测试用户",
"email": "test@example.com",
"subject": "测试主题",
"message": "这是一条测试消息。"
}
# 填写并提交表单
contact_page.fill_contact_form(data)
contact_page.submit_form()
# 验证成功
contact_page.verify_form_submission_success()
@pytest.mark.form
def test_form_with_empty_name(self, contact_page: ContactPage):
"""测试姓名为空的表单验证"""
contact_page.navigate()
data = {
"name": "",
"email": "test@example.com",
"subject": "测试主题",
"message": "这是一条测试消息。"
}
contact_page.fill_contact_form(data)
# 点击提交按钮
contact_page._click("form_submit_button")
# 应该显示验证错误
try:
contact_page.assert_element_visible("form_name_input:invalid", timeout=2000)
except Exception:
# 可能通过后端验证
pass
@pytest.mark.form
def test_form_with_invalid_email(self, contact_page: ContactPage):
"""测试无效邮箱验证"""
contact_page.navigate()
data = {
"name": "测试用户",
"email": "invalid-email",
"subject": "测试主题",
"message": "这是一条测试消息。"
}
contact_page.fill_contact_form(data)
# 检查邮箱字段
email_input = contact_page._find("form_email_input")
validity = email_input.evaluate("""
el => ({
valid: el.validity.valid,
typeMismatch: el.validity.typeMismatch
})
""")
# 验证邮箱格式
assert not validity["valid"] or validity["typeMismatch"], \
"无效邮箱应该被标记为无效"
@pytest.mark.form
def test_form_submission_performance(self, contact_page: ContactPage, test_data_generator):
"""测试表单提交性能"""
contact_page.navigate()
data = test_data_generator.generate_contact_form_data(use_valid=True)
result = contact_page.test_form_submission_performance(data, max_duration=5.0)
assert result["passed"], f"表单提交耗时 {result['duration']:.2f}s 超过5秒阈值"
@pytest.mark.responsive
def test_contact_page_mobile_layout(self, contact_page: ContactPage):
"""测试联系页面移动端布局"""
contact_page.verify_responsive_layout(375)
@pytest.mark.responsive
def test_contact_page_tablet_layout(self, contact_page: ContactPage):
"""测试联系页面平板端布局"""
contact_page.verify_responsive_layout(768)
@pytest.mark.responsive
def test_contact_page_desktop_layout(self, contact_page: ContactPage):
"""测试联系页面桌面端布局"""
contact_page.verify_responsive_layout(1920)
@pytest.mark.interactive
def test_extract_contact_details(self, contact_page: ContactPage):
"""测试提取联系详情"""
contact_page.navigate()
details = contact_page.extract_contact_details()
assert "phone" in details or "email" in details or "address" in details
@pytest.mark.interactive
def test_get_working_hours(self, contact_page: ContactPage):
"""测试获取工作时间"""
contact_page.navigate()
hours = contact_page.get_working_hours()
assert isinstance(hours, dict)
@pytest.mark.regression
def test_form_reset_after_submission(self, contact_page: ContactPage, test_data_generator):
"""测试提交后表单重置"""
contact_page.navigate()
data = test_data_generator.generate_contact_form_data(use_valid=True)
# 第一次提交
contact_page.fill_contact_form(data)
contact_page.submit_form()
contact_page.verify_form_submission_success()
# 刷新页面后表单应该重置
contact_page.reload()
contact_page.assert_element_visible("contact_form", timeout=5000)
@pytest.mark.form
@pytest.mark.performance
def test_form_typing_performance(self, contact_page: ContactPage, test_data_generator):
"""测试表单输入性能"""
import time
contact_page.navigate()
data = test_data_generator.generate_contact_form_data(use_valid=True)
# 测量填充时间
start_time = time.time()
contact_page.fill_contact_form(data)
end_time = time.time()
fill_time = (end_time - start_time) * 1000
# 填充时间应该在5秒内
assert fill_time < 5000, f"表单填充时间 {fill_time:.2f}ms 超过5秒阈值"
@pytest.mark.regression
def test_form_with_special_characters(self, contact_page: ContactPage):
"""测试包含特殊字符的表单提交"""
contact_page.navigate()
data = {
"name": "测试用户-Name",
"email": "test+special@example.com",
"subject": "特殊字符测试: @#$%",
"message": "这是一条包含特殊字符的消息!测试...end"
}
contact_page.fill_contact_form(data)
contact_page.submit_form()
# 验证成功
try:
contact_page.verify_form_submission_success()
except Exception:
# 可能需要等待
contact_page.page.wait_for_timeout(2000)
@pytest.mark.regression
def test_form_with_long_content(self, contact_page: ContactPage):
"""测试长内容表单提交"""
contact_page.navigate()
# 生成长内容
long_message = "这是一条很长的消息。" * 50
data = {
"name": "长内容测试用户",
"email": "longtest@example.com",
"subject": "长内容测试主题" * 10,
"message": long_message
}
contact_page.fill_contact_form(data)
contact_page.submit_form()
# 验证成功
try:
contact_page.verify_form_submission_success()
except Exception:
contact_page.page.wait_for_timeout(2000)
+222
View File
@@ -0,0 +1,222 @@
"""
首页测试模块
测试首页的各项功能和特性
"""
import pytest
from typing import Dict, Any
from pages.home_page import HomePage
class TestHomePage:
"""首页测试类"""
@pytest.mark.smoke
@pytest.mark.navigation
def test_home_page_loads_successfully(self, home_page: HomePage):
"""测试首页正常加载"""
home_page.navigate()
home_page.verify_page_loaded()
@pytest.mark.smoke
def test_home_page_title(self, home_page: HomePage):
"""测试首页标题"""
home_page.navigate()
home_page.assert_title_contains("睿新致远")
@pytest.mark.smoke
def test_home_page_url(self, home_page: HomePage):
"""测试首页URL"""
home_page.navigate()
home_page.assert_url_equals(home_page._get_full_url("/"))
@pytest.mark.regression
def test_home_page_header(self, home_page: HomePage):
"""测试页头"""
home_page.navigate()
home_page.verify_header()
@pytest.mark.regression
def test_home_page_hero_section(self, home_page: HomePage):
"""测试Hero区域"""
home_page.navigate()
home_page.verify_hero_section()
@pytest.mark.regression
def test_home_page_services_section(self, home_page: HomePage):
"""测试服务区域"""
home_page.navigate()
home_page.verify_services_section()
@pytest.mark.regression
def test_home_page_products_section(self, home_page: HomePage):
"""测试产品区域"""
home_page.navigate()
home_page.verify_products_section()
@pytest.mark.regression
def test_home_page_news_section(self, home_page: HomePage):
"""测试新闻区域"""
home_page.navigate()
home_page.verify_news_section()
@pytest.mark.regression
def test_home_page_contact_section(self, home_page: HomePage):
"""测试联系区域"""
home_page.navigate()
home_page.verify_contact_section()
@pytest.mark.regression
def test_home_page_footer(self, home_page: HomePage):
"""测试页脚"""
home_page.navigate()
home_page.verify_footer()
@pytest.mark.regression
def test_home_page_all_sections(self, home_page: HomePage):
"""测试所有区域"""
home_page.navigate()
home_page.verify_all_sections()
@pytest.mark.navigation
@pytest.mark.interactive
def test_scroll_to_about_section(self, home_page: HomePage):
"""测试滚动到关于区域"""
home_page.navigate()
home_page.scroll_to_section("about")
home_page.assert_element_visible("#about", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_scroll_to_services_section(self, home_page: HomePage):
"""测试滚动到服务区域"""
home_page.navigate()
home_page.scroll_to_section("services")
home_page.assert_element_visible("#services", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_scroll_to_products_section(self, home_page: HomePage):
"""测试滚动到产品区域"""
home_page.navigate()
home_page.scroll_to_section("products")
home_page.assert_element_visible("#products", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_scroll_to_news_section(self, home_page: HomePage):
"""测试滚动到新闻区域"""
home_page.navigate()
home_page.scroll_to_section("news")
home_page.assert_element_visible("#news", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_scroll_to_contact_section(self, home_page: HomePage):
"""测试滚动到联系区域"""
home_page.navigate()
home_page.scroll_to_section("contact")
home_page.assert_element_visible("#contact", timeout=5000)
@pytest.mark.performance
def test_home_page_performance(self, home_page: HomePage):
"""测试首页性能"""
home_page.navigate()
performance = home_page.verify_page_performance()
# 验证关键性能指标
assert performance.get("pageLoadTime", 0) < 5000, "页面加载时间超过5秒"
assert performance.get("domContentLoaded", 0) < 3000, "DOM内容加载时间超过3秒"
@pytest.mark.performance
def test_home_page_load_time(self, home_page: HomePage):
"""测试首页加载时间"""
import time
home_page.navigate()
start_time = time.time()
home_page.wait_for_load()
end_time = time.time()
load_time = (end_time - start_time) * 1000 # 转换为毫秒
# 断言加载时间在阈值内
assert load_time < 5000, f"首页加载时间 {load_time:.2f}ms 超过5秒阈值"
@pytest.mark.responsive
def test_home_page_mobile_layout(self, home_page: HomePage):
"""测试移动端布局"""
home_page.verify_responsive_design(375, 667)
@pytest.mark.responsive
def test_home_page_tablet_layout(self, home_page: HomePage):
"""测试平板端布局"""
home_page.verify_responsive_design(768, 1024)
@pytest.mark.responsive
def test_home_page_desktop_layout(self, home_page: HomePage):
"""测试桌面端布局"""
home_page.verify_responsive_design(1920, 1080)
@pytest.mark.responsive
def test_home_page_wide_layout(self, home_page: HomePage):
"""测试宽屏布局"""
home_page.verify_responsive_design(2560, 1440)
@pytest.mark.interactive
def test_get_company_info(self, home_page: HomePage):
"""测试获取公司信息"""
home_page.navigate()
info = home_page.get_company_info()
assert "name" in info
assert "slogan" in info
assert "description" in info
@pytest.mark.interactive
def test_get_statistics(self, home_page: HomePage):
"""测试获取统计数据"""
home_page.navigate()
stats = home_page.get_statistics()
assert "customers" in stats
assert "cases" in stats
@pytest.mark.interactive
def test_get_featured_services(self, home_page: HomePage):
"""测试获取服务列表"""
home_page.navigate()
services = home_page.get_featured_services()
assert isinstance(services, list)
if len(services) > 0:
assert "title" in services[0]
@pytest.mark.interactive
def test_get_latest_news(self, home_page: HomePage):
"""测试获取最新新闻"""
home_page.navigate()
news = home_page.get_latest_news()
assert isinstance(news, list)
if len(news) > 0:
assert "title" in news[0]
@pytest.mark.regression
def test_page_refresh(self, home_page: HomePage):
"""测试页面刷新"""
home_page.navigate()
home_page.reload()
home_page.verify_page_loaded()
@pytest.mark.navigation
def test_navigation_links_count(self, home_page: HomePage):
"""测试导航链接数量"""
home_page.navigate()
nav_links = home_page._find_all("nav a")
# 应该有6个导航链接:首页、关于我们、核心业务、产品服务、新闻动态、联系我们
assert len(nav_links) >= 5, f"导航链接数量不足,当前{len(nav_links)}"
+209
View File
@@ -0,0 +1,209 @@
"""
导航测试模块
测试网站导航功能
"""
import pytest
from typing import Dict, Any
from pages.home_page import HomePage
class TestNavigation:
"""导航测试类"""
@pytest.mark.navigation
@pytest.mark.smoke
def test_navigate_to_home(self, home_page: HomePage):
"""测试导航到首页"""
home_page.navigate()
home_page.assert_url_equals(home_page._get_full_url("/"))
@pytest.mark.navigation
@pytest.mark.smoke
def test_navigate_to_contact_page(self, home_page: HomePage, contact_page):
"""测试导航到联系页面"""
contact_page.navigate()
contact_page.assert_url_equals(
home_page._get_full_url("/contact")
)
@pytest.mark.navigation
@pytest.mark.interactive
def test_click_navigation_to_about(self, home_page: HomePage):
"""测试点击导航到关于区域"""
home_page.navigate()
home_page.click_navigation_link("about")
home_page.assert_element_visible("#about", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_click_navigation_to_services(self, home_page: HomePage):
"""测试点击导航到服务区域"""
home_page.navigate()
home_page.click_navigation_link("services")
home_page.assert_element_visible("#services", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_click_navigation_to_products(self, home_page: HomePage):
"""测试点击导航到产品区域"""
home_page.navigate()
home_page.click_navigation_link("products")
home_page.assert_element_visible("#products", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_click_navigation_to_news(self, home_page: HomePage):
"""测试点击导航到新闻区域"""
home_page.navigate()
home_page.click_navigation_link("news")
home_page.assert_element_visible("#news", timeout=5000)
@pytest.mark.navigation
@pytest.mark.interactive
def test_click_navigation_to_contact(self, home_page: HomePage):
"""测试点击导航到联系区域"""
home_page.navigate()
home_page.click_navigation_link("contact")
home_page.assert_element_visible("#contact", timeout=5000)
@pytest.mark.navigation
def test_smooth_scroll_to_section(self, home_page: HomePage):
"""测试平滑滚动到区域"""
home_page.navigate()
# 先滚动到页面底部
home_page.scroll_to_bottom()
# 然后滚动到顶部
home_page.scroll_to_top()
# 验证
home_page.assert_element_visible("header")
@pytest.mark.navigation
def test_scroll_to_each_section(self, home_page: HomePage):
"""测试滚动到每个区域"""
home_page.navigate()
sections = ["home", "about", "services", "products", "news", "contact"]
for section in sections:
home_page.scroll_to_section(section)
home_page.assert_element_visible(f"#{section}", timeout=5000)
@pytest.mark.navigation
def test_page_back(self, home_page: HomePage, contact_page):
"""测试返回上一页"""
# 先访问联系页面
contact_page.navigate()
contact_page.assert_url_equals(home_page._get_full_url("/contact"))
# 返回首页
home_page.go_back()
# 验证
home_page.assert_url_equals(home_page._get_full_url("/"))
@pytest.mark.navigation
def test_page_forward(self, home_page: HomePage, contact_page):
"""测试前进到下一页"""
# 访问首页
home_page.navigate()
# 后退(此时没有上一页,应该保持在首页)
home_page.go_back()
# 前进(此时没有下一页,应该保持在首页)
home_page.go_forward()
home_page.assert_url_equals(home_page._get_full_url("/"))
@pytest.mark.navigation
def test_page_reload(self, home_page: HomePage):
"""测试页面刷新"""
home_page.navigate()
home_page.reload()
home_page.verify_page_loaded()
@pytest.mark.navigation
def test_navigation_link_count(self, home_page: HomePage):
"""测试导航链接数量"""
home_page.navigate()
nav_links = home_page._find_all("nav a")
# 应该有6个导航链接
assert len(nav_links) >= 5, f"导航链接数量不足,当前{len(nav_links)}"
@pytest.mark.navigation
def test_navigation_link_text(self, home_page: HomePage):
"""测试导航链接文本"""
home_page.navigate()
expected_links = ["首页", "关于我们", "核心业务", "产品服务", "新闻动态", "联系我们"]
for link_text in expected_links:
link = home_page.page.locator(f"nav a:has-text('{link_text}')")
assert link.count() > 0, f"未找到导航链接: {link_text}"
@pytest.mark.navigation
@pytest.mark.responsive
def test_navigation_mobile(self, home_page: HomePage):
"""测试移动端导航"""
# 设置移动端视口
home_page.page.set_viewport_size({"width": 375, "height": 667})
home_page.navigate()
# 移动端应该显示汉堡菜单
menu_button = home_page.page.locator("button:has-text('菜单'), .mobile-menu")
if menu_button.count() > 0:
home_page.logger.info("移动端显示汉堡菜单")
else:
home_page.logger.info("移动端导航可能已内联显示")
@pytest.mark.navigation
def test_url_hash_navigation(self, home_page: HomePage):
"""测试URL哈希导航"""
home_page.navigate()
# 直接访问带哈希的URL
home_page.navigate(path="/#about")
home_page.wait_for_load()
# 验证滚动到指定区域
home_page.assert_element_visible("#about", timeout=5000)
@pytest.mark.navigation
def test_browser_back_button(self, home_page: HomePage, contact_page):
"""测试浏览器后退按钮"""
# 访问首页
home_page.navigate()
# 访问联系页面
contact_page.navigate()
# 使用浏览器后退
home_page.page.go_back()
# 验证返回首页
home_page.assert_url_equals(home_page._get_full_url("/"))
@pytest.mark.interactive
def test_cta_button_navigation(self, home_page: HomePage):
"""测试CTA按钮导航"""
home_page.navigate()
# 查找CTA按钮(如果有)
cta_button = home_page.page.locator(
"a[href*='contact'], a.cta, a.button:has-text('联系')"
)
if cta_button.count() > 0:
cta_button.first.click()
home_page.wait_for_load()
# 应该导航到联系区域
home_page.assert_element_visible("#contact", timeout=5000)
+321
View File
@@ -0,0 +1,321 @@
"""
性能测试模块
测试网站性能指标
"""
import pytest
import time
from typing import Dict, Any
from datetime import datetime
from pages.home_page import HomePage
from pages.contact_page import ContactPage
from config.settings import get_settings
class TestPerformance:
"""性能测试类"""
@pytest.mark.performance
@pytest.mark.smoke
def test_home_page_load_time(self, home_page: HomePage):
"""测试首页加载时间"""
home_page.navigate()
start_time = time.time()
home_page.wait_for_load()
end_time = time.time()
load_time = (end_time - start_time) * 1000 # 毫秒
# 阈值:5秒
assert load_time < 5000, f"首页加载时间 {load_time:.2f}ms 超过5秒阈值"
@pytest.mark.performance
@pytest.mark.smoke
def test_contact_page_load_time(self, contact_page: ContactPage):
"""测试联系页面加载时间"""
contact_page.navigate()
start_time = time.time()
contact_page.wait_for_load()
end_time = time.time()
load_time = (end_time - start_time) * 1000
# 阈值:5秒
assert load_time < 5000, f"联系页面加载时间 {load_time:.2f}ms 超过5秒阈值"
@pytest.mark.performance
def test_dom_content_loaded_time(self, home_page: HomePage):
"""测试DOM内容加载时间"""
home_page.navigate()
performance_data = home_page.execute_js("""
() => {
const timing = performance.timing;
return {
domContentLoaded: timing.domContentLoadedEventEnd - timing.navigationStart,
domInteractive: timing.domInteractive - timing.domLoading
};
}
""")
dom_loaded = performance_data.get("domContentLoaded", 0)
assert dom_loaded < 3000, f"DOM内容加载时间 {dom_loaded}ms 超过3秒阈值"
@pytest.mark.performance
def test_first_paint_time(self, home_page: HomePage):
"""测试首次绘制时间"""
home_page.navigate()
first_paint = home_page.execute_js("""
() => {
const entries = performance.getEntriesByType('paint');
const firstPaint = entries.find(e => e.name === 'first-paint');
return firstPaint ? firstPaint.startTime : 0;
}
""")
if first_paint:
assert first_paint < 2000, f"首次绘制时间 {first_paint:.2f}ms 超过2秒阈值"
@pytest.mark.performance
def test_first_contentful_paint(self, home_page: HomePage):
"""测试首次内容绘制(FCP"""
home_page.navigate()
fcp = home_page.execute_js("""
() => {
const navigation = performance.getEntriesByType('navigation')[0];
return navigation ? navigation.firstContentfulPaint : 0;
}
""")
if fcp:
# 阈值:1.5秒
assert fcp < 1500, f"首次内容绘制时间 {fcp:.2f}ms 超过1.5秒阈值"
@pytest.mark.performance
def test_largest_contentful_paint(self, home_page: HomePage):
"""测试最大内容绘制(LCP"""
home_page.navigate()
lcp = home_page.execute_js("""
() => {
try {
const navigation = performance.getEntriesByType('navigation')[0];
return navigation ? navigation.largestContentfulPaint : 0;
} catch (e) {
return 0;
}
}
""")
if lcp:
# 阈值:2.5秒
assert lcp < 2500, f"最大内容绘制时间 {lcp:.2f}ms 超过2.5秒阈值"
@pytest.mark.performance
def test_time_to_interactive(self, home_page: HomePage):
"""测试可交互时间(TTI"""
home_page.navigate()
tti = home_page.execute_js("""
() => {
try {
const navigation = performance.getEntriesByType('navigation')[0];
return navigation ? navigation.interactive : 0;
} catch (e) {
return 0;
}
}
""")
if tti:
# 阈值:3秒
assert tti < 3000, f"可交互时间 {tti:.2f}ms 超过3秒阈值"
@pytest.mark.performance
def test_page_load_performance_metrics(self, home_page: HomePage):
"""测试页面加载性能指标"""
home_page.navigate()
performance = home_page.verify_page_performance()
# 验证关键指标
if performance.get("pageLoadTime"):
assert performance["pageLoadTime"] < 5000
if performance.get("domContentLoaded"):
assert performance["domContentLoaded"] < 3000
@pytest.mark.performance
def test_network_timing(self, home_page: HomePage):
"""测试网络时序"""
home_page.navigate()
timing = home_page.execute_js("""
() => {
const timing = performance.timing;
return {
dnsLookup: timing.domainLookupEnd - timing.domainLookupStart,
tcpConnection: timing.connectEnd - timing.connectStart,
serverResponse: timing.responseEnd - timing.requestStart,
domProcessing: timing.domLoading - timing.responseEnd
};
}
""")
# DNS查询时间
assert timing.get("dnsLookup", 0) < 500, \
f"DNS查询时间 {timing.get('dnsLookup')}ms 超过500ms阈值"
# TCP连接时间
assert timing.get("tcpConnection", 0) < 500, \
f"TCP连接时间 {timing.get('tcpConnection')}ms 超过500ms阈值"
# 服务器响应时间
assert timing.get("serverResponse", 0) < 1000, \
f"服务器响应时间 {timing.get('serverResponse')}ms 超过1秒阈值"
@pytest.mark.performance
def test_resource_timing(self, home_page: HomePage):
"""测试资源加载时序"""
home_page.navigate()
resources = home_page.execute_js("""
() => {
const entries = performance.getEntriesByType('resource');
const scripts = entries.filter(e => e.initiatorType === 'script');
const styles = entries.filter(e => e.initiatorType === 'css');
return {
totalResources: entries.length,
scriptCount: scripts.length,
styleCount: styles.length,
totalDuration: entries.reduce((sum, e) => sum + e.duration, 0)
};
}
""")
assert resources.get("totalResources", 0) > 0, "未检测到资源加载"
home_page.logger.info(
f"资源统计: 共{resources.get('totalResources')}个资源,"
f"脚本{resources.get('scriptCount')}个,"
f"样式{resources.get('styleCount')}"
)
@pytest.mark.performance
def test_form_submission_time(self, contact_page: ContactPage, test_data_generator):
"""测试表单提交时间"""
contact_page.navigate()
data = test_data_generator.generate_contact_form_data(use_valid=True)
start_time = time.time()
contact_page.fill_contact_form(data)
contact_page.submit_form()
try:
contact_page.verify_form_submission_success()
except Exception:
pass
end_time = time.time()
duration = (end_time - start_time) * 1000
# 阈值:5秒
assert duration < 5000, f"表单提交耗时 {duration:.2f}ms 超过5秒阈值"
@pytest.mark.performance
def test_scroll_performance(self, home_page: HomePage):
"""测试滚动性能"""
home_page.navigate()
# 执行多次滚动
scroll_times = []
for i in range(5):
start_time = time.time()
home_page.scroll_to_bottom()
home_page.scroll_to_top()
end_time = time.time()
scroll_times.append((end_time - start_time) * 1000)
avg_scroll_time = sum(scroll_times) / len(scroll_times)
# 平均滚动时间应该在1秒内
assert avg_scroll_time < 1000, f"平均滚动时间 {avg_scroll_time:.2f}ms 超过1秒阈值"
@pytest.mark.performance
def test_element_visibility_performance(self, home_page: HomePage):
"""测试元素可见性检查性能"""
home_page.navigate()
elements = [
"header",
"#home",
"#about",
"#services",
"#products",
"#news",
"#contact",
"footer"
]
check_times = []
for element in elements:
start_time = time.time()
try:
home_page._is_visible(element)
except Exception:
pass
end_time = time.time()
check_times.append((end_time - start_time) * 1000)
avg_check_time = sum(check_times) / len(check_times)
# 单个元素检查时间应该在500ms内
assert avg_check_time < 500, f"平均元素检查时间 {avg_check_time:.2f}ms 超过500ms阈值"
@pytest.mark.performance
def test_navigation_performance(self, home_page: HomePage, contact_page: ContactPage):
"""测试导航性能"""
# 测量导航到联系页面的时间
start_time = time.time()
contact_page.navigate()
contact_page.wait_for_load()
end_time = time.time()
nav_time = (end_time - start_time) * 1000
# 阈值:3秒
assert nav_time < 3000, f"导航时间 {nav_time:.2f}ms 超过3秒阈值"
@pytest.mark.performance
@pytest.mark.responsive
def test_performance_across_viewports(self, home_page: HomePage):
"""测试不同视口下的性能"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
results = []
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
start_time = time.time()
home_page.navigate()
home_page.wait_for_load()
end_time = time.time()
load_time = (end_time - start_time) * 1000
results.append((name, load_time))
home_page.logger.info(f"{name} ({width}x{height}): {load_time:.2f}ms")
# 验证所有视口加载时间在阈值内
for name, load_time in results:
assert load_time < 5000, f"{name}加载时间 {load_time:.2f}ms 超过5秒阈值"
+330
View File
@@ -0,0 +1,330 @@
"""
响应式设计测试模块
测试网站在不同屏幕尺寸下的响应式表现
"""
import pytest
from typing import Dict, Any
from pages.home_page import HomePage
from pages.contact_page import ContactPage
class TestResponsive:
"""响应式设计测试类"""
@pytest.mark.responsive
@pytest.mark.smoke
def test_homepage_mobile_375(self, home_page: HomePage):
"""测试首页在iPhone SE尺寸下的响应式表现"""
home_page.verify_responsive_design(375, 667)
@pytest.mark.responsive
@pytest.mark.smoke
def test_homepage_mobile_414(self, home_page: HomePage):
"""测试首页在iPhone 8 Plus尺寸下的响应式表现"""
home_page.verify_responsive_design(414, 896)
@pytest.mark.responsive
@pytest.mark.smoke
def test_homepage_tablet_768(self, home_page: HomePage):
"""测试首页在iPad尺寸下的响应式表现"""
home_page.verify_responsive_design(768, 1024)
@pytest.mark.responsive
@pytest.mark.smoke
def test_homepage_desktop_1920(self, home_page: HomePage):
"""测试首页在桌面尺寸下的响应式表现"""
home_page.verify_responsive_design(1920, 1080)
@pytest.mark.responsive
def test_contact_page_mobile_375(self, contact_page: ContactPage):
"""测试联系页面在移动端的响应式表现"""
contact_page.verify_responsive_layout(375)
@pytest.mark.responsive
def test_contact_page_tablet_768(self, contact_page: ContactPage):
"""测试联系页面在平板端的响应式表现"""
contact_page.verify_responsive_layout(768)
@pytest.mark.responsive
def test_contact_page_desktop_1920(self, contact_page: ContactPage):
"""测试联系页面在桌面端的响应式表现"""
contact_page.verify_responsive_layout(1920)
@pytest.mark.responsive
def test_header_responsive_mobile(self, home_page: HomePage):
"""测试页头在移动端的响应式表现"""
home_page.page.set_viewport_size({"width": 375, "height": 667})
home_page.navigate()
# 验证页头可见
home_page.assert_element_visible("header", timeout=5000)
# 移动端应该显示汉堡菜单
menu_button = home_page.page.locator(
"button:has-text('菜单'), .mobile-menu, .menu-toggle, button[aria-label*='menu']"
)
if menu_button.count() > 0:
home_page.logger.info("✅ 移动端页头包含汉堡菜单")
else:
home_page.logger.info("️ 移动端页头可能内联显示所有链接")
@pytest.mark.responsive
def test_header_responsive_desktop(self, home_page: HomePage):
"""测试页头在桌面端的响应式表现"""
home_page.page.set_viewport_size({"width": 1920, "height": 1080})
home_page.navigate()
# 验证页头可见
home_page.assert_element_visible("header", timeout=5000)
# 桌面端应该显示完整导航
nav_links = home_page._find_all("nav a")
assert len(nav_links) >= 5, f"桌面端导航链接不足,当前{len(nav_links)}"
@pytest.mark.responsive
def test_navigation_responsive_mobile(self, home_page: HomePage):
"""测试导航在移动端的响应式表现"""
home_page.page.set_viewport_size({"width": 375, "height": 667})
home_page.navigate()
# 检查导航是否可访问 - 移动端可能隐藏导航或使用汉堡菜单
nav_visible = home_page._is_visible("nav")
mobile_menu_visible = home_page._is_visible(".mobile-menu, .menu-toggle, button[aria-label*='menu']")
header_visible = home_page._is_visible("header")
# 只要页头可见,就认为导航可访问(导航可能在页头内)
assert nav_visible or mobile_menu_visible or header_visible, "移动端导航不可访问"
home_page.logger.info("✅ 移动端导航可访问")
@pytest.mark.responsive
def test_hero_section_responsive(self, home_page: HomePage):
"""测试Hero区域在不同尺寸下的表现"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
home_page.navigate()
# 验证Hero区域可见
hero_visible = home_page._is_visible("#home, .hero-section")
if hero_visible:
home_page.logger.info(f"{name} Hero区域正常显示")
else:
home_page.logger.warning(f"⚠️ {name} Hero区域可能需要滚动才能显示")
@pytest.mark.responsive
def test_services_grid_responsive(self, home_page: HomePage):
"""测试服务卡片网格在不同尺寸下的响应式表现"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
home_page.navigate()
home_page.scroll_to_section("services")
# 检查服务区域可见
home_page.assert_element_visible("#services", timeout=5000)
home_page.logger.info(f"{name} 服务区域正常显示")
@pytest.mark.responsive
def test_products_grid_responsive(self, home_page: HomePage):
"""测试产品卡片网格在不同尺寸下的响应式表现"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
home_page.navigate()
home_page.scroll_to_section("products")
# 检查产品区域可见
home_page.assert_element_visible("#products", timeout=5000)
home_page.logger.info(f"{name} 产品区域正常显示")
@pytest.mark.responsive
def test_news_list_responsive(self, home_page: HomePage):
"""测试新闻列表在不同尺寸下的响应式表现"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
home_page.navigate()
home_page.scroll_to_section("news")
# 检查新闻区域可见
home_page.assert_element_visible("#news", timeout=5000)
home_page.logger.info(f"{name} 新闻区域正常显示")
@pytest.mark.responsive
def test_contact_form_responsive(self, home_page: HomePage):
"""测试联系表单在不同尺寸下的响应式表现"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
home_page.navigate()
home_page.scroll_to_section("contact")
# 检查表单可见
form_visible = home_page._is_visible("form")
if form_visible:
home_page.logger.info(f"{name} 联系表单正常显示")
else:
home_page.logger.warning(f"⚠️ {name} 联系表单不可见")
@pytest.mark.responsive
def test_footer_responsive(self, home_page: HomePage):
"""测试页脚在不同尺寸下的响应式表现"""
viewports = [
(375, 667, "移动端"),
(768, 1024, "平板端"),
(1920, 1080, "桌面端")
]
for width, height, name in viewports:
home_page.page.set_viewport_size({"width": width, "height": height})
home_page.navigate()
# 检查页脚可见
home_page.assert_element_visible("footer", timeout=5000)
home_page.logger.info(f"{name} 页脚正常显示")
@pytest.mark.responsive
def test_element_stacking_mobile(self, home_page: HomePage):
"""测试移动端元素堆叠"""
home_page.page.set_viewport_size({"width": 375, "height": 667})
home_page.navigate()
# 滚动检查各个区域
sections = ["#home", "#about", "#services", "#products", "#news", "#contact"]
visible_sections = 0
for section in sections:
if home_page._is_visible(section):
visible_sections += 1
# 移动端应该显示至少1个区域
assert visible_sections >= 1, f"移动端可见区域不足,当前{visible_sections}"
@pytest.mark.responsive
def test_touch_target_size_mobile(self, home_page: HomePage):
"""测试移动端触摸目标大小"""
home_page.page.set_viewport_size({"width": 375, "height": 667})
home_page.navigate()
# 检查按钮和链接的大小
buttons = home_page._find_all("button, a.button, .btn")
for button in buttons[:5]: # 只检查前5个
if button.count() > 0:
box = button.first.bounding_box()
if box:
# 触摸目标应该至少44x44像素
assert box["width"] >= 24, f"按钮宽度 {box['width']}px 可能太小"
assert box["height"] >= 24, f"按钮高度 {box['height']}px 可能太小"
@pytest.mark.responsive
def test_text_readability_mobile(self, home_page: HomePage):
"""测试移动端文本可读性"""
home_page.page.set_viewport_size({"width": 375, "height": 667})
home_page.navigate()
# 检查段落文本
paragraphs = home_page._find_all("p")
for para in paragraphs[:3]: # 只检查前3个
if para.count() > 0:
font_size = para.evaluate("el => getComputedStyle(el).fontSize")
# 字体大小应该至少12px
font_size_value = float(font_size.replace("px", ""))
assert font_size_value >= 12, \
f"段落字体大小 {font_size} 可能影响可读性"
@pytest.mark.responsive
def test_form_inputs_mobile(self, contact_page: ContactPage):
"""测试移动端表单输入"""
contact_page.page.set_viewport_size({"width": 375, "height": 667})
contact_page.navigate()
# 检查表单输入框
inputs = contact_page._find_all("input, textarea")
for inp in inputs:
if inp.count() > 0:
box = inp.first.bounding_box()
if box:
# 输入框高度应该至少40px
assert box["height"] >= 32, \
f"输入框高度 {box['height']}px 可能太小不便触摸"
@pytest.mark.responsive
def test_landscape_orientation(self, home_page: HomePage):
"""测试横屏模式"""
home_page.page.set_viewport_size({"width": 667, "height": 375})
home_page.navigate()
# 验证基本元素可见
home_page.assert_element_visible("header", timeout=5000)
home_page.assert_element_visible("main", timeout=5000)
home_page.assert_element_visible("footer", timeout=5000)
@pytest.mark.responsive
def test_high_dpi_display(self, home_page: HomePage):
"""测试高DPI显示器"""
# 设置视口大小(Playwright会自动处理高DPI显示)
home_page.page.set_viewport_size({"width": 1920, "height": 1080})
home_page.navigate()
# 验证页面正常显示
home_page.assert_element_visible("header", timeout=5000)
home_page.logger.info("✅ 高DPI显示器测试通过")
@pytest.mark.responsive
def test_print_styles(self, home_page: HomePage):
"""测试打印样式"""
home_page.navigate()
# 模拟打印样式
is_print_media = home_page.execute_js("""
() => window.matchMedia('print').matches
""")
# 设置为打印模式
home_page.execute_js("""
() => {
const style = document.createElement('style');
style.innerHTML = '@media print { body { font-size: 12pt; } }';
document.head.appendChild(style);
}
""")
home_page.logger.info("✅ 打印样式应用完成")
+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()
+9 -11
View File
@@ -16,17 +16,15 @@ export function Footer() {
<p className="text-gray-400 text-sm leading-relaxed mb-6">
{COMPANY_INFO.description}
</p>
<div className="flex gap-4">
{SOCIAL_LINKS.map((social) => (
<a
key={social.name}
href={social.href}
className="w-10 h-10 rounded-full bg-white/10 flex items-center justify-center hover:bg-white/20 transition-colors"
aria-label={social.name}
>
<span className="text-sm">{social.name[0]}</span>
</a>
))}
<div className="flex flex-col items-start gap-3">
<span className="text-gray-400 text-sm"></span>
<div className="w-24 h-24 bg-white rounded-lg p-2">
<img
src="/images/qrcode-wechat.png"
alt="微信公众号二维码"
className="w-full h-full object-contain"
/>
</div>
</div>
</div>
+2 -2
View File
@@ -72,12 +72,12 @@ export function Header() {
)}
>
<div className="container-custom">
<div className="flex items-center justify-between h-20">
<div className="flex items-center justify-center h-20 relative">
{/* Logo */}
<Link
href="#home"
onClick={(e) => handleNavClick(e, '#home')}
className="flex items-center"
className="flex items-center absolute left-0"
>
<img src="/logo.svg" alt={COMPANY_INFO.name} className="h-12 w-auto" />
</Link>
+9 -4
View File
@@ -29,10 +29,15 @@ export function HeroSection() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Badge variant="secondary" className="mb-8 px-4 py-2 text-sm">
<span className="w-2 h-2 rounded-full bg-green-500 mr-2 animate-pulse" />
</Badge>
<div className="inline-flex items-center gap-2 px-5 py-2.5 rounded-full bg-gradient-to-r from-slate-50 to-slate-100 border border-slate-200 shadow-sm mb-8">
<span className="relative flex h-2.5 w-2.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500" />
</span>
<span className="text-sm font-medium text-slate-700 tracking-wide">
</span>
</div>
</motion.div>
{/* Main Title */}