fix(conftest,config_loader): 修复 get_caps 的 Capabilities 加载逻辑

- 新增 pytest_addoption 增加 "--caps_name" 获取配置文件中的设备/平台名称
- 修复 get_caps 的 Capabilities 加载逻辑
- 优化 其他优化 enums.py
- 删除 移除部分文档,代码,第三方包(loguru)
This commit is contained in:
2026-02-28 16:08:14 +08:00
parent 332deb3666
commit 6ad6b7ff84
20 changed files with 175 additions and 328 deletions

View File

@@ -138,6 +138,7 @@ pytest --platform IOS --udid <your_iphone_udid>
**可用的自定义命令行参数:** **可用的自定义命令行参数:**
- `--platform`: 目标平台 (`Android` 或 `IOS`)。默认为 `Android`。 - `--platform`: 目标平台 (`Android` 或 `IOS`)。默认为 `Android`。
- `--caps_name`: 设备/平台名称。
- `--udid`: 目标设备的唯一设备标识符 (UDID)。 - `--udid`: 目标设备的唯一设备标识符 (UDID)。
- `--host`: Appium 服务器的主机地址。默认为 `127.0.0.1`。 - `--host`: Appium 服务器的主机地址。默认为 `127.0.0.1`。
- `--port`: Appium 服务器的端口。默认为 `4723`。 - `--port`: Appium 服务器的端口。默认为 `4723`。

View File

@@ -17,3 +17,13 @@ ios:
autoAcceptAlerts: true autoAcceptAlerts: true
waitForQuiescence: false waitForQuiescence: false
newCommandTimeout: 60 newCommandTimeout: 60
wan_android:
platformName: "Android"
automationName: "uiautomator2"
deviceName: "Android"
appPackage: "com.manu.wanandroid"
appActivity: "com.manu.wanandroid.ui.main.activity.MainActivity"
noReset: false
newCommandTimeout: 60
# udid: "emulator-5554" # Can be injected via CLI

View File

@@ -16,12 +16,16 @@ from typing import Generator, Any
import pytest import pytest
import allure import allure
from dotenv import load_dotenv
from core.run_appium import start_appium_service, stop_appium_service from core.run_appium import start_appium_service, stop_appium_service
from core.driver import CoreDriver from core.driver import CoreDriver
from core.settings import APPIUM_HOST, APPIUM_PORT from core.settings import APPIUM_HOST, APPIUM_PORT
from core.enums import AppPlatform
from core.config_loader import get_caps from core.config_loader import get_caps
load_dotenv()
# 注册命令行参数 # 注册命令行参数
def pytest_addoption(parser: Any) -> None: def pytest_addoption(parser: Any) -> None:
@@ -29,14 +33,15 @@ def pytest_addoption(parser: Any) -> None:
注册自定义命令行参数 注册自定义命令行参数
:param parser: Pytest 参数解析器对象 :param parser: Pytest 参数解析器对象
""" """
parser.addoption("--platform", action="store", default="Android", help="目标平台: Android or IOS") parser.addoption("--platform", action="store", default="android1", help="目标平台: Android or IOS")
parser.addoption("--caps_name", action="store", default=None, help="配置文件中的设备/平台名称")
parser.addoption("--udid", action="store", default=None, help="设备唯一标识") parser.addoption("--udid", action="store", default=None, help="设备唯一标识")
parser.addoption("--host", action="store", default=APPIUM_HOST, help="Appium Server Host") parser.addoption("--host", action="store", default=APPIUM_HOST, help="Appium Server Host")
parser.addoption("--port", action="store", default=str(APPIUM_PORT), help="Appium Server Port") parser.addoption("--port", action="store", default=str(APPIUM_PORT), help="Appium Server Port")
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def app_server(request: pytest.FixtureRequest) -> Generator[Any, None, None]: def appium_server(request: pytest.FixtureRequest) -> Generator[Any, None, None]:
""" """
第一层:管理 Appium Server 进程。 第一层:管理 Appium Server 进程。
:param request: Pytest 请求对象 :param request: Pytest 请求对象
@@ -52,41 +57,44 @@ def app_server(request: pytest.FixtureRequest) -> Generator[Any, None, None]:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
def driver_session(request: pytest.FixtureRequest, app_server: Any) -> Generator[CoreDriver, None, None]: def driver_session(request: pytest.FixtureRequest, appium_server: Any) -> Generator[CoreDriver, None, None]:
""" """
第二层:全局单例 Driver 管理 (Session Scope)。 第二层:全局单例 Driver 管理 (Session Scope)。
负责创建和销毁 Driver整个测试过程只启动一次 App。 负责创建和销毁 Driver整个测试过程只启动一次 App。
:param request: Pytest 请求对象 :param request: Pytest 请求对象
:param app_server: Appium 服务 fixture 依赖 :param appium_server: Appium 服务 fixture 依赖
:return: CoreDriver 实例 :return: CoreDriver 实例
""" """
platform = request.config.getoption("--platform") platform: AppPlatform = request.config.getoption("--platform")
# 配置名称(caps_name)(决定去 YAML 哪个节点拿数据,默认等于 platform
caps_name = request.config.getoption("--caps_name") or platform
ud_id = request.config.getoption("--udid") ud_id = request.config.getoption("--udid")
host = request.config.getoption("--host") host = request.config.getoption("--host")
port = int(request.config.getoption("--port")) port = int(request.config.getoption("--port"))
# 1. 获取基础 Caps # 1. 获取基础 Caps
caps = get_caps(platform) caps = get_caps(caps_name)
# 2. 动态注入参数 # 2. 动态注入参数
if ud_id: caps["udid"] = ud_id if ud_id: caps["udid"] = ud_id
# 将最终生效的 caps 存入 pytest 配置,方便报告读取 # 将最终生效的 caps 存入 pytest 配置,方便报告读取
request.config._final_caps = caps request.config._final_caps = caps
request.config._caps_name = caps_name
# 3. 初始化 Driver # 3. 初始化 Driver
app_helper = CoreDriver() driver_helper = CoreDriver()
app_helper.server_config(host=host, port=port) driver_helper.server_config(host=host, port=port)
try: try:
app_helper.connect(platform=platform, caps=caps) driver_helper.connect(platform=platform, caps=caps)
except Exception as e: except Exception as e:
pytest.exit(f"无法初始化 Driver: {e}") pytest.exit(f"无法初始化 Driver: {e}")
yield app_helper yield driver_helper
# 4. 清理 # 4. 清理
app_helper.quit() driver_helper.quit()
@pytest.fixture(scope="function") @pytest.fixture(scope="function")
@@ -165,6 +173,8 @@ def pytest_sessionfinish(session: Any, exitstatus: int) -> None:
report_dir = session.config.getoption("--alluredir") report_dir = session.config.getoption("--alluredir")
final_caps = getattr(session.config, "_final_caps", {}) final_caps = getattr(session.config, "_final_caps", {})
caps_name = getattr(session.config, "_caps_name", '')
if not report_dir: if not report_dir:
return return
report_path = Path(report_dir) report_path = Path(report_dir)
@@ -172,6 +182,7 @@ def pytest_sessionfinish(session: Any, exitstatus: int) -> None:
env_info = { env_info = {
"Platform": session.config.getoption("--platform"), "Platform": session.config.getoption("--platform"),
"UDID": final_caps.get("udid") or session.config.getoption("--udid") or "Not Specified", "UDID": final_caps.get("udid") or session.config.getoption("--udid") or "Not Specified",
"CapsName": caps_name,
"Host": session.config.getoption("--host"), "Host": session.config.getoption("--host"),
"Python": "3.11+" "Python": "3.11+"
} }

View File

@@ -9,59 +9,54 @@
@date: 2026/1/16 10:52 @date: 2026/1/16 10:52
@desc: Pytest 核心配置与 Fixture 管理 @desc: Pytest 核心配置与 Fixture 管理
""" """
import logging
from typing import Any from typing import Any, Optional
from utils.data_loader import load_yaml from utils.data_loader import load_yaml
from core.settings import CAPS_CONFIG_PATH, ENV_CONFIG, CURRENT_ENV from core.settings import CAPS_CONFIG_PATH, ENV_CONFIG, CURRENT_ENV
from core.modules import AppPlatform
logger = logging.getLogger(__name__)
def get_env_config() -> dict[str, str]: def get_env_config(env_name: Optional[str] = None) -> dict[str, str]:
""" """
根据当前环境 (CURRENT_ENV) 获取对应的业务配置 获取当前运行环境的业务配置信息。
逻辑:
1. 优先使用传入的 `env_name`。
2. 若未传入,使用全局设置 `CURRENT_ENV`。
3. 若都为空,默认为 "test"
4. 如果目标环境在配置中不存在,强制回退到 "test" 环境并记录警告。
:param env_name: 指定的环境名称 (e.g., "dev", "prod"),可选。
:return: 对应环境的配置字典。
""" """
return ENV_CONFIG.get(CURRENT_ENV, ENV_CONFIG["test"]) target_env = env_name or CURRENT_ENV or "test"
if target_env not in ENV_CONFIG:
logger.warning(f"环境 '{target_env}' 未在配置中定义,将回退到 'test' 环境。")
return ENV_CONFIG.get("test", {})
return ENV_CONFIG[target_env]
def get_caps(platform: str) -> dict[str, Any]: def get_caps(caps_name: str) -> dict[str, Any]:
""" """
从 YAML 文件加载 Capabilities 配置 从 YAML 配置文件加载指定的 Appium Capabilities
:param caps_name: 配置文件中的设备/平台名称 (不区分大小写),例如 "android_pixel"
:return: 该设备对应的 Capabilities 字典。
:raises ValueError: 当指定的 caps_name 在配置文件中不存在时。
:raises RuntimeError: 当配置文件加载失败或格式错误时。
""" """
try: try:
all_caps = load_yaml(CAPS_CONFIG_PATH) all_caps = load_yaml(CAPS_CONFIG_PATH)
all_caps = {k.lower(): v for k, v in all_caps.items()} all_caps = {k.lower(): v for k, v in all_caps.items()}
platform_key = platform.lower() caps_key = caps_name.lower()
if platform_key not in all_caps: if caps_key not in all_caps:
raise ValueError(f"{CAPS_CONFIG_PATH} 中找不到平台 '{caps_key}' 的配置")
base_caps: dict[str, Any] = { return all_caps[caps_key]
"noReset": False,
"newCommandTimeout": 60,
}
match platform_key:
case AppPlatform.ANDROID:
android_caps = {
"platformName": "Android",
"automationName": "uiautomator2",
"deviceName": "Android",
"appPackage": "com.manu.wanandroid",
"appActivity": "com.manu.wanandroid.ui.main.activity.MainActivity",
}
return base_caps | android_caps
case AppPlatform.IOS:
ios_caps = {
"platformName": "iOS",
"automationName": "XCUITest",
"deviceName": "iPhone 14",
"bundleId": "com.example.app",
"autoAcceptAlerts": True,
"waitForQuiescence": False,
}
return base_caps | ios_caps
return all_caps[platform_key]
except Exception as e: except Exception as e:
raise RuntimeError(f"无法加载“caps”内容 {CAPS_CONFIG_PATH}: {e}") raise RuntimeError(f"加载 Capabilities 失败 ({CAPS_CONFIG_PATH}): {e}")

View File

@@ -30,10 +30,10 @@ from selenium.webdriver.common.actions import interaction
from selenium.webdriver.common.actions.action_builder import ActionBuilder from selenium.webdriver.common.actions.action_builder import ActionBuilder
from selenium.webdriver.common.actions.pointer_input import PointerInput from selenium.webdriver.common.actions.pointer_input import PointerInput
from core.enums import AppPlatform
from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT, SCREENSHOT_DIR
from utils.finder import by_converter from utils.finder import by_converter
from utils.decorators import resolve_wait_method from utils.decorators import resolve_wait_method
from core.modules import AppPlatform
from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT, SCREENSHOT_DIR
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -623,6 +623,7 @@ class CoreDriver:
:return: self :return: self
""" """
return self.switch_to_context('NATIVE_APP') return self.switch_to_context('NATIVE_APP')
def full_screen_screenshot(self, name: str | None = None) -> str: def full_screen_screenshot(self, name: str | None = None) -> str:
""" """
截取当前完整屏幕内容 (自愈逻辑、异常报错首选) 截取当前完整屏幕内容 (自愈逻辑、异常报错首选)

58
core/enums.py Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: enums
@date: 2026/2/27 17:05
@desc:
"""
from enum import Enum
class AppiumStatus(Enum):
"""Appium 服务状态枚举"""
READY = "服务已启动" # 服务和驱动都加载完成 (HTTP 200 + ready: true)
INITIALIZING = "驱动正在加载" # 服务已响应但驱动仍在加载 (HTTP 200 + ready: false)
CONFLICT = "端口被其他程序占用" # 端口被其他非 Appium 程序占用
OFFLINE = "服务未启动" # 服务未启动
ERROR = "内部错误"
UNKNOWN = "未知状态"
class ServiceRole(Enum):
"""服务角色枚举:定义服务的所有权和生命周期"""
MANAGED = "托管模式" # 由本脚本启动,负责清理
EXTERNAL = "共享模式" # 复用现有服务,不负责清理
NULL = "空模式" # 无效或未初始化的服务
class AppPlatform(Enum):
"""
定义支持的移动应用平台枚举。
"""
ANDROID = "android"
IOS = "ios"
class Locator(str, Enum):
"""
定义元素定位策略枚举。
继承 str 以便直接作为参数传递给 Selenium/Appium 方法。
"""
# --- 原有 Selenium 支持 ---
ID = "id"
NAME = "name"
CLASS = "class"
TAG = "tag"
LINK_TEXT = "link_text"
PARTIAL_LINK_TEXT = "partial_link_text"
CSS = "css"
XPATH = "xpath"
# --- Appium 特有支持 ---
ACCESSIBILITY_ID = "accessibility_id"
AID = "aid" # 简写
ANDROID_UIAUTOMATOR = "android_uiautomator"
IOS_PREDICATE = "ios_predicate"

View File

@@ -9,33 +9,3 @@
@date: 2026/1/20 11:54 @date: 2026/1/20 11:54
@desc: @desc:
""" """
from enum import Enum
class AppPlatform(Enum):
"""
定义支持的移动应用平台枚举。
"""
ANDROID = "android"
IOS = "ios"
class Locator(str, Enum):
"""
定义元素定位策略枚举。
继承 str 以便直接作为参数传递给 Selenium/Appium 方法。
"""
# --- 原有 Selenium 支持 ---
ID = "id"
NAME = "name"
CLASS = "class"
TAG = "tag"
LINK_TEXT = "link_text"
PARTIAL_LINK_TEXT = "partial_link_text"
CSS = "css"
XPATH = "xpath"
# --- Appium 特有支持 ---
ACCESSIBILITY_ID = "accessibility_id"
AID = "aid" # 简写
ANDROID_UIAUTOMATOR = "android_uiautomator"
IOS_PREDICATE = "ios_predicate"

View File

@@ -5,7 +5,7 @@
@author: CNWei,ChenWei @author: CNWei,ChenWei
@Software: PyCharm @Software: PyCharm
@contact: t6g888@163.com @contact: t6g888@163.com
@file: test @file: run_appium
@date: 2026/1/12 10:21 @date: 2026/1/12 10:21
@desc: @desc:
""" """
@@ -19,10 +19,11 @@ import sys
import http.client import http.client
import socket import socket
import json import json
from enum import Enum
from typing import List from typing import List
from core.settings import BASE_DIR, APPIUM_HOST, APPIUM_PORT, MAX_RETRIES from core.settings import BASE_DIR, APPIUM_HOST, APPIUM_PORT, MAX_RETRIES
from core.enums import AppiumStatus, ServiceRole
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -55,23 +56,6 @@ class AppiumInternalError(AppiumStartupError):
pass pass
class AppiumStatus(Enum):
"""Appium 服务状态枚举"""
READY = "服务已启动" # 服务和驱动都加载完成 (HTTP 200 + ready: true)
INITIALIZING = "驱动正在加载" # 服务已响应但驱动仍在加载 (HTTP 200 + ready: false)
CONFLICT = "端口被其他程序占用" # 端口被其他非 Appium 程序占用
OFFLINE = "服务未启动" # 服务未启动
ERROR = "内部错误"
UNKNOWN = "未知状态"
class ServiceRole(Enum):
"""服务角色枚举:定义服务的所有权和生命周期"""
MANAGED = "托管模式" # 由本脚本启动,负责清理
EXTERNAL = "共享模式" # 复用现有服务,不负责清理
NULL = "空模式" # 无效或未初始化的服务
def resolve_appium_command(host: str, port: int | str) -> List[str]: def resolve_appium_command(host: str, port: int | str) -> List[str]:
""" """
解析 Appium 可执行文件的绝对路径。 解析 Appium 可执行文件的绝对路径。

16
main.py
View File

@@ -16,6 +16,7 @@ from pathlib import Path
import pytest import pytest
from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP
from core.enums import AppPlatform
from utils.dirs_manager import ensure_dirs_ok from utils.dirs_manager import ensure_dirs_ok
from utils.report_handler import generate_allure_report from utils.report_handler import generate_allure_report
@@ -44,6 +45,7 @@ def _archive_logs():
else: else:
print("未找到原始日志文件,跳过备份。") print("未找到原始日志文件,跳过备份。")
# 日志清理 # 日志清理
def _clean_old_logs(backup_dir, keep_count=10): def _clean_old_logs(backup_dir, keep_count=10):
files = sorted(Path(backup_dir).glob("pytest_*.log"), key=lambda p: p.stat().st_mtime) files = sorted(Path(backup_dir).glob("pytest_*.log"), key=lambda p: p.stat().st_mtime)
@@ -54,6 +56,7 @@ def _clean_old_logs(backup_dir, keep_count=10):
except OSError as e: except OSError as e:
print(f"清理旧日志失败 {file_to_remove}: {e}") print(f"清理旧日志失败 {file_to_remove}: {e}")
def _clean_temp_dirs(): def _clean_temp_dirs():
""" """
可选:如果你想在测试前清理掉旧的临时文件 可选:如果你想在测试前清理掉旧的临时文件
@@ -64,6 +67,7 @@ def _clean_temp_dirs():
shutil.rmtree(ALLURE_TEMP, ignore_errors=True) shutil.rmtree(ALLURE_TEMP, ignore_errors=True)
ALLURE_TEMP.mkdir(parents=True, exist_ok=True) ALLURE_TEMP.mkdir(parents=True, exist_ok=True)
def main(): def main():
try: try:
# 1. 创建目录 # 1. 创建目录
@@ -73,8 +77,16 @@ def main():
_archive_logs() _archive_logs()
# 3. 执行 Pytest # 3. 执行 Pytest
# 注意:-x 表示遇到错误立即停止,如果是全量回归建议去掉 -x
pytest.main(["test_cases", "-x", "-v", f"--alluredir={ALLURE_TEMP}"]) args = [
"test_cases",
"-x", # 注意:-x 表示遇到错误立即停止,如果是全量回归建议去掉 -x
"-v",
f"--alluredir={ALLURE_TEMP}",
f"--platform={AppPlatform.ANDROID.value}",
"--caps_name=wan_android"
]
pytest.main(args)
# 4. 生成报告 # 4. 生成报告
generate_allure_report() generate_allure_report()

View File

@@ -5,7 +5,7 @@
@author: CNWei,ChenWei @author: CNWei,ChenWei
@Software: PyCharm @Software: PyCharm
@contact: t6g888@163.com @contact: t6g888@163.com
@file: test_home @file: wan_android_home
@date: 2026/1/30 17:18 @date: 2026/1/30 17:18
@desc: @desc:
""" """
@@ -28,8 +28,8 @@ class HomePage(BasePage):
tv_name = ("id", "com.manu.wanandroid:id/tvName") tv_name = ("id", "com.manu.wanandroid:id/tvName")
account=("-android uiautomator",'new UiSelector().text("账号")') account = ("-android uiautomator", 'new UiSelector().text("账号")')
pass_word=("-android uiautomator",'new UiSelector().text("密码")') pass_word = ("-android uiautomator", 'new UiSelector().text("密码")')
login_button = ("accessibility id", '登录') login_button = ("accessibility id", '登录')
@@ -47,10 +47,10 @@ class HomePage(BasePage):
@allure.step("登录账号:{1}") @allure.step("登录账号:{1}")
def login(self, username, password): def login(self, username, password):
"""执行登录业务逻辑""" """执行登录业务逻辑"""
account_element_id =self.find_element(*self.account).id account_element_id = self.find_element(*self.account).id
account_input = {"elementId": account_element_id, "text": username} account_input = {"elementId": account_element_id, "text": username}
pwd_element_id =self.find_element(*self.pass_word).id pwd_element_id = self.find_element(*self.pass_word).id
pass_word_input = {"elementId": pwd_element_id, "text": password} pass_word_input = {"elementId": pwd_element_id, "text": password}
if self.wait_until_visible(*self.login_button): if self.wait_until_visible(*self.login_button):
@@ -61,5 +61,4 @@ class HomePage(BasePage):
if self.wait_until_visible(*self.tv_name): if self.wait_until_visible(*self.tv_name):
self.full_screen_screenshot("登陆成功") self.full_screen_screenshot("登陆成功")
self.long_press(x=636,y=117,duration=300) self.long_press(x=636, y=117, duration=300)

View File

@@ -5,7 +5,7 @@
@author: CNWei,ChenWei @author: CNWei,ChenWei
@Software: PyCharm @Software: PyCharm
@contact: t6g888@163.com @contact: t6g888@163.com
@file: test_views @file: wan_android_project
@date: 2026/1/30 17:37 @date: 2026/1/30 17:37
@desc: @desc:
""" """
@@ -16,13 +16,15 @@ from appium import webdriver
from core.base_page import BasePage from core.base_page import BasePage
from utils.decorators import StepTracer from utils.decorators import StepTracer
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class ProjectPage(BasePage): class ProjectPage(BasePage):
# 定位参数 # 定位参数
project_title = ("-android uiautomator", 'new UiSelector().text("项目")') project_title = ("-android uiautomator", 'new UiSelector().text("项目")')
pro_table_title = ("-android uiautomator",'new UiSelector().text("完整项目")') pro_table_title = ("-android uiautomator", 'new UiSelector().text("完整项目")')
def __init__(self, driver: webdriver.Remote): def __init__(self, driver: webdriver.Remote):
super().__init__(driver) super().__init__(driver)

View File

@@ -7,7 +7,6 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"allure-pytest==2.13.5", "allure-pytest==2.13.5",
"appium-python-client>=5.2.4", "appium-python-client>=5.2.4",
"loguru>=0.7.3",
"pytest>=8.3.5", "pytest>=8.3.5",
"PyYAML>=6.0.1", "PyYAML>=6.0.1",
"openpyxl>=3.1.2", "openpyxl>=3.1.2",

View File

@@ -13,13 +13,10 @@ import logging
import os import os
import allure import allure
from dotenv import load_dotenv
from page_objects.wan_android_home import HomePage from page_objects.wan_android_home import HomePage
from page_objects.wan_android_project import ProjectPage from page_objects.wan_android_project import ProjectPage
load_dotenv()
# 配置日志 # 配置日志
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -52,11 +49,10 @@ class TestWanAndroidHome:
with allure.step("断言"): with allure.step("断言"):
assert os.getenv("USER_NAME") == 'admintest123456' assert os.getenv("USER_NAME") == 'admintest123456'
# 页面跳转 # 页面跳转
with allure.step("验证页面跳转"): with allure.step("验证页面跳转"):
project = home.go_to(ProjectPage) project = home.go_to(ProjectPage)
project.switch_to_project() project.switch_to_project()
project.assert_text(*project.pro_table_title,expected_text='完整项目') project.assert_text(*project.pro_table_title, expected_text='完整项目')
project.delay(5) project.delay(5)

View File

@@ -14,6 +14,7 @@ import pytest
from appium.webdriver.common.appiumby import AppiumBy from appium.webdriver.common.appiumby import AppiumBy
from utils.finder import by_converter, register_custom_finder, converter from utils.finder import by_converter, register_custom_finder, converter
class TestFinderConverter: class TestFinderConverter:
def setup_method(self): def setup_method(self):
@@ -56,7 +57,7 @@ class TestFinderConverter:
"""测试自定义注册功能""" """测试自定义注册功能"""
register_custom_finder("my_text", "-android uiautomator") register_custom_finder("my_text", "-android uiautomator")
assert by_converter("my_text") == "-android uiautomator" assert by_converter("my_text") == "-android uiautomator"
# 测试注册后归一化依然生效 # 测试注册后归一化依然生效
assert by_converter("MY_TEXT") == "-android uiautomator" assert by_converter("MY_TEXT") == "-android uiautomator"
@@ -64,9 +65,9 @@ class TestFinderConverter:
"""测试重置功能""" """测试重置功能"""
register_custom_finder("temp_key", "xpath") register_custom_finder("temp_key", "xpath")
assert by_converter("temp_key") == "xpath" assert by_converter("temp_key") == "xpath"
converter.clear_custom_finders() converter.clear_custom_finders()
with pytest.raises(ValueError, match="Unsupported locator strategy"): with pytest.raises(ValueError, match="Unsupported locator strategy"):
by_converter("temp_key") by_converter("temp_key")
@@ -81,7 +82,8 @@ class TestFinderConverter:
by_converter(123) # type: ignore by_converter(123) # type: ignore
with pytest.raises(ValueError, match="Invalid selector type"): with pytest.raises(ValueError, match="Invalid selector type"):
by_converter(None) # type: ignore by_converter(None) # type: ignore
if __name__ == "__main__": if __name__ == "__main__":
pytest.main(["-v", __file__]) pytest.main(["-v", __file__])

View File

@@ -1,115 +0,0 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: test
@date: 2026/1/14 10:12
@desc:
"""
# import pytest
from enum import Enum
from typing import TypeVar
from utils.logger import logger, trace_step
# --- 模拟业务逻辑 ---
class ServiceRole(Enum):
MANAGED = "受控模式"
EXTERNAL = "共享模式"
NULL = "无效模式"
class AppiumService:
def __init__(self, device_id: str, role: ServiceRole):
self.device_id = device_id
self.role = role
# 使用 bind 为该实例的所有日志绑定特定的设备 ID
self._log = logger.bind(source=f"Dev:{device_id}")
@trace_step(step_desc="停止服务", source="Appium")
def stop(self, force=False):
"""演示类方法追踪及逻辑分支"""
self._log.info(f"正在尝试停止服务,强制模式={force}")
if self.role == ServiceRole.EXTERNAL:
self._log.warning("外部服务,跳过清理进程")
return "SKIPPED"
if self.role == ServiceRole.MANAGED:
self._log.success("已发送 SIGTERM 信号清理进程")
return "SUCCESS"
raise RuntimeError("无法停止处于未知状态的服务")
@trace_step("简单打印")
def simple_log(self, msg: str):
self._log.info(f"消息回显: {msg}")
# --- 独立函数演示 ---
@trace_step("执行数据计算", source="Calc")
def calculate_data(a: int, b: int):
if b == 0:
raise ZeroDivisionError("除数不能为 0")
return a / b
@trace_step("空值返回测试")
def return_none():
return None
# --- 测试场景覆盖 ---
def run_scenarios():
print("\n" + "=" * 50)
print("🚀 开始执行全场景日志覆盖测试")
print("=" * 50 + "\n")
# 1. 覆盖:正常类方法调用 (过滤 self)
logger.info(">>> 场景 1: 正常类方法 (MANAGED 模式)")
svc1 = AppiumService("emulator-5554", ServiceRole.MANAGED)
svc1.stop(force=True)
# 2. 覆盖:类方法不同返回值
logger.info(">>> 场景 2: 共享模式跳过清理")
svc2 = AppiumService("iPhone_15", ServiceRole.EXTERNAL)
svc2.stop()
# 3. 覆盖:异常捕获 (自动记录错误日志并向上抛出)
logger.info(">>> 场景 3: 异常捕获测试")
try:
calculate_data(10, 0)
except ZeroDivisionError:
logger.warning("主流程已捕获预期的计算异常")
# 4. 覆盖:复杂参数与 None 返回
logger.info(">>> 场景 4: 复杂参数与 None 返回")
return_none()
# 5. 覆盖:未定义状态导致的崩溃
logger.info(">>> 场景 5: 业务逻辑崩溃测试")
svc3 = AppiumService("Unknown_Device", ServiceRole.NULL)
try:
svc3.stop()
except Exception as e:
logger.error(f"捕获到业务逻辑崩溃:{e}")
# 6. 覆盖:原生 logger 与装饰器 logger 混合
logger.info(">>> 场景 6: 验证自定义 source 标签")
# 这里会使用 setup_logger 中定义的默认 'System' 标签
logger.debug("这是一条调试级别的原始日志")
print("\n" + "=" * 50)
print("✅ 测试场景执行完毕,请检查 logs 文件夹中的 .log 文件")
print("=" * 50)
if __name__ == "__main__":
run_scenarios()

View File

@@ -11,10 +11,10 @@
""" """
import yaml import yaml
from pathlib import Path from pathlib import Path
from typing import Any, List from typing import Any
def load_yaml(file_path: Path | str) -> dict[str, Any] | List[Any]: def load_yaml(file_path: Path | str) -> dict[str, Any] | list[Any]:
""" """
加载 YAML 文件 加载 YAML 文件
:param file_path: 文件路径 :param file_path: 文件路径

View File

@@ -15,7 +15,6 @@ import time
import inspect import inspect
from functools import wraps from functools import wraps
from typing import Union, Callable from typing import Union, Callable
from contextvars import ContextVar from contextvars import ContextVar
from contextlib import ContextDecorator from contextlib import ContextDecorator
@@ -41,14 +40,15 @@ class StepTracer(ContextDecorator):
... ...
也可作为装饰器的一部分(通过 step_trace 工厂函数)。 也可作为装饰器的一部分(通过 step_trace 工厂函数)。
Attributes:
step_desc (str): 对当前步骤或操作的描述。
source (str): 日志记录器的名称。
func_info (str, optional): 关联的函数信息,用于日志输出。
""" """
def __init__(self, step_desc, source='wrapper', func_info=None): def __init__(self, step_desc, source='wrapper', func_info=None):
"""
初始化 StepTracer。
:param step_desc: 对当前步骤或操作的描述。
:param source: 日志记录器的名称。
:param func_info: 关联的函数信息,用于日志输出。
"""
self.step_desc = step_desc self.step_desc = step_desc
self.logger = logging.getLogger(source) self.logger = logging.getLogger(source)
self.func_info = func_info self.func_info = func_info
@@ -167,6 +167,7 @@ def action_screenshot(func):
Returns: Returns:
Callable: 包装后的函数。 Callable: 包装后的函数。
""" """
@wraps(func) @wraps(func)
def wrapper(self, *args, **kwargs): def wrapper(self, *args, **kwargs):
# 1. 正常执行原方法 # 1. 正常执行原方法
@@ -241,6 +242,7 @@ def step_trace(step_desc="", source='wrapper'):
Returns: Returns:
Callable: 一个可以装饰函数的装饰器。 Callable: 一个可以装饰函数的装饰器。
""" """
def decorator(func): def decorator(func):
@wraps(func) @wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):

View File

@@ -5,7 +5,7 @@
@author: CNWei,ChenWei @author: CNWei,ChenWei
@Software: PyCharm @Software: PyCharm
@contact: t6g888@163.com @contact: t6g888@163.com
@file: path_manager @file: dirs_manager
@date: 2026/2/3 10:52 @date: 2026/2/3 10:52
@desc: @desc:
""" """

View File

@@ -5,11 +5,12 @@
@author: CNWei,ChenWei @author: CNWei,ChenWei
@Software: PyCharm @Software: PyCharm
@contact: t6g888@163.com @contact: t6g888@163.com
@file: locator_utils @file: finder
@date: 2026/1/20 15:40 @date: 2026/1/20 15:40
@desc: @desc:
""" """
from typing import Literal, Final from typing import Literal, Final
from appium.webdriver.common.appiumby import AppiumBy from appium.webdriver.common.appiumby import AppiumBy
ByType = Literal[ ByType = Literal[

View File

@@ -1,81 +0,0 @@
日志与执行追踪模块使用指南
本模块基于 Loguru 封装,专为自动化测试项目设计,提供工业级的日志记录、多线程安全保障以及业务步骤自动追踪能力。
🌟 核心特性
全局一致性:统一日志格式,控制台带颜色显示,方便快速定位。
自动执行追踪:使用 @trace_step 装饰器,自动记录方法入参、出参、耗时及执行状态。
智能参数解析:装饰器自动识别并隐藏 self 和 cls 参数,保护日志整洁。
上下文透传:支持通过 .bind() 绑定设备 ID 或模块标识,解决多机并行日志混杂问题。
分层标识:默认区分系统日志 (System) 与业务任务日志 (task)。
异步安全:支持多进程/多线程安全写入,不影响 Appium 执行性能。
🚀 快速上手
1. 基础日志记录
在任何模块中直接导入 logger 即可使用。
```python
from utils.logger import logger
logger.info("这是一条普通信息")
logger.debug("调试模式下的详细信息")
logger.error("记录一个错误")
```
2. 使用装饰器追踪业务步骤 (@trace_step)
在 PageObject 方法或任何业务函数上添加装饰器,即可获得全链路追踪。
```python
from utils.logger import trace_step
@trace_step("用户登录")
def login(username, password):
# 执行逻辑...
return True
# 日志输出:
# 🚀 [START] 用户登录 -> login('admin', '****')
# ✅ [SUCCESS] 用户登录 | 耗时: 1.25s | 返回: True
```
3. 多机并行:上下文透传 (.bind)
在 Appium 并行测试中,用于区分不同设备的日志流。
```python
from utils.logger import logger
class BasePage:
def __init__(self, driver, device_id):
self.driver = driver
# 绑定设备 ID 到当前实例的 logger
self.log = logger.bind(source=device_id)
def click_element(self, loc):
self.log.info(f"点击元素: {loc}")
# 日志输出:
# | INFO | Pixel_6 | base_page:click_element:15 - 点击元素: id=login_btn
# | INFO | S22 | base_page:click_element:15 - 点击元素: id=login_btn
```
🛠️ 进阶配置
标识符说明 (source 字段)
日志格式中包含一个 source 字段(占位符为 magenta 颜色),用于区分日志来源:
System: (默认) 框架底层日志、驱动初始化等。
task: (装饰器默认) 具体的业务测试步骤。
自定义: 通过 @trace_step(source="SQL") 或 logger.bind(source="API") 自定义。
日志存储
路径: 项目根目录 /logs/。
滚动: 每天午夜 00:00 自动切割。
保留: 默认保留最近 30 天 的日志。
⚠️ 注意事项
不要在装饰器内手动接收返回值:@trace_step 已经自动处理了函数的返回值记录。
优先使用 self.log在 PageObject 类中,请务必使用 self.log.info() 而非全局 logger.info(),以确保 bind 的上下文信息(如设备 ID能正确显示。
希望这套日志系统能显著提升您的调试效率和项目质量!如有任何疑问,请随时查阅 utils/logger.py 源码。