From d3f9326baab78ba23fbafaef2ba9df45e886eba0 Mon Sep 17 00:00:00 2001 From: CNWei Date: Thu, 29 Jan 2026 18:15:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(driver):=20=E6=96=B0=E5=A2=9ECoreDriver?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=93=8D=E4=BD=9C=EF=BC=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 is_visible,支持快速状态检查。 - 新增 wait_until_visible/wait_until_not_visible 支持元素状态检查。 - 新增 clear_popups 支持弹窗清理。 - 优化 implicit_wait 状态追踪,确保等待时间恢复的准确性。 - 更新 README.md - 其他优化 [clear_popups 采用“非阻塞探测 + 阻塞确认”策略,大幅提升清理效率并减少无效等待] --- README.md | 50 ++++++++ conftest.py | 14 ++- core/base_page.py | 59 ++++++---- core/driver.py | 216 ++++++++++++++++++++++++++++++---- core/settings.py | 21 +++- test_cases/test_login_demo.py | 78 ------------ utils/decorators.py | 4 +- 7 files changed, 312 insertions(+), 130 deletions(-) delete mode 100644 test_cases/test_login_demo.py diff --git a/README.md b/README.md index adadad6..c497f7a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,52 @@ # AppAutoTest +设备能力配置 (Capabilities) + +```python +ANDROID_CAPS = { + "platformName": "Android", + "automationName": "uiautomator2", + "deviceName": "Android", + "appPackage": "com.android.settings", + "appActivity": ".Settings", + "noReset": False +} +``` + +| 字段名称 | 字段含义解释 | 示例值说明 | +|----------------|---------------------------------------------|---------------------------------------------------------------------------------------| +| platformName | 测试的平台/操作系统。这是必填项,告诉自动化框架(如Appium)目标是什么系统。 | "Android" 表示这是一个Android设备。如果是iOS设备,则为 "iOS"。 | +| automationName | 使用的自动化驱动引擎。指定底层用哪个工具来驱动设备进行UI交互。 | "uiautomator2" 是当前主流的Android驱动框架,比旧的 "UiAutomator" 更稳定和高效。 | +| deviceName | 设备标识/名称。用于在同时连接多台设备时指定目标。 | "Android" 是一个通用标识。在实际测试中,通常用 adb devices 获取的真实设备序列号(如 emulator-5554)来替换它,以确保连接到正确的设备。 | +| appPackage | 要测试的应用程序包名。这是应用的唯一标识,就像它在Android系统中的“身份证号”。 | "com.android.settings" 是Android系统“设置”应用的包名。测试你自己的应用时,需替换为你应用的包名。 | +| appActivity | 要启动的应用内具体页面。它指定了应用启动后打开的第一个界面(Activity)。 | ".Settings" 是“设置”应用的主界面。前面的点. 表示它是 appPackage 下的一个相对路径。 | + +获取应用的 appPackage 有几种常用方法 + +```shell +adb shell pm list packages | findstr your_package_name +# adb shell pm list packages -3 仅列出用户安装的第三方应用的包名 +``` + +获取应用的 appActivity 有几种常用方法 + +```shell +# 1,使用 aapt 工具分析 APK 文件(需有安装包)/build-tools/{version}/aapt +aapt dump badging your_app.apk | findstr launchable-activity +# 输出结果:launchable-activity: name='com.example.myapp.MainActivity' +# name= 后面的值 'com.example.myapp.MainActivity' 就是你需要的主 Activity + +# 2,通过 ADB 命令获取(需应用已安装) +adb shell dumpsys window | findstr mCurrentFocus +# 输出结果:mCurrentFocus=Window{... u0 com.example.myapp/com.example.myapp.MainActivity} +# / 后面的部分 com.example.myapp.MainActivity 就是当前 Activity +``` + +常用补充字段: +noReset:True/False。是否在会话开始前重置应用状态(例如清除应用数据)。设置为 True 可以避免每次测试都重新登录。 + +platformVersion:指定设备的Android系统版本(如 "11.0")。虽然不是必须,但指定后能增强兼容性。 + +unicodeKeyboard 和 resetKeyboard:用于处理中文输入等特殊字符输入。 + +newCommandTimeout:设置Appium服务器等待客户端发送新命令的超时时间(秒),默认为60秒。在长时间操作中可能需要增加。 \ No newline at end of file diff --git a/conftest.py b/conftest.py index 8ec07cc..01a6461 100644 --- a/conftest.py +++ b/conftest.py @@ -38,12 +38,20 @@ def driver(app_server): """ # 实例化你提供的类结构 app_helper = CoreDriver() - + caps = { + "platformName": "Android", + "automationName": "uiautomator2", + "deviceName": "Android", + "appPackage": "com.bocionline.ibmp", + "appActivity": "com.bocionline.ibmp.app.main.launcher.LauncherActivity", + "noReset": False, # 不清除应用数据 + "newCommandTimeout": 60 + } # 连接并获取原生 driver 实例 # 这里可以根据需要扩展,比如通过命令行参数选择平台 - app_helper.connect(platform="android", caps=ANDROID_CAPS) + app_helper.connect(platform="android", caps=caps) - yield app_helper + yield app_helper.driver # 用例结束,只关 session,不关 server app_helper.quit() diff --git a/core/base_page.py b/core/base_page.py index d065c7a..aa4f5eb 100644 --- a/core/base_page.py +++ b/core/base_page.py @@ -10,36 +10,53 @@ @desc: """ import logging -from typing import Type, TypeVar +import secrets +from typing import Type, TypeVar, List, Tuple + +from appium import webdriver +from selenium.common import TimeoutException + from core.driver import CoreDriver + # 定义一个泛型,用于类型推断(IDE 依然会有补全提示) T = TypeVar('T', bound='BasePage') logger = logging.getLogger(__name__) -class BasePage: - def __init__(self, driver: CoreDriver): - self.driver = driver - # 这里放全局通用的 Page 属性和逻辑 - # --- 页面工厂:属性懒加载 --- - # 这样你可以在任何页面直接通过 self.home_page 访问首页 - @property - def home_page(self): - from page_objects.home_page import HomePage - return HomePage(self.driver) - @property - def login_page(self): - from page_objects.login_page import LoginPage - return LoginPage(self.driver) + +class BasePage(CoreDriver): + + def __init__(self, driver: webdriver.Remote): + super().__init__(driver) + # 定义常见弹窗的关闭按钮定位 + + # 这里放全局通用的 Page 属性和逻辑 # 封装一些所有页面通用的元动作 - def get_toast(self, text): - return self.driver.is_visible("text", text) + def clear_permission_popups(self): + # 普适性黑名单 + _black_list = [ + ("id", "com.android.packageinstaller:id/permission_allow_button"), + ("xpath", "//*[@text='始终允许']"), + ("xpath", "//*[@text='稍后提醒']"), + ("xpath", "//*[@text='以后再说']"), + ("id", "com.app:id/iv_close_global_ad"), + ("accessibility id", "Close"), # iOS 常用 + ] + self.clear_popups(_black_list) - def to_page(self, page_class: Type[T]) -> T: + def clear_business_ads(self): + """在这里定义一些全 App 通用的业务广告清理""" + _ads = [("id", "com.app:id/global_ad_close")] + return self.clear_popups(_ads) + + def get_toast(self, text): + return self.is_visible("text", text) + + def go_to(self, page_name: Type[T]) -> T: """ 通用的页面跳转/获取方法 - :param page_class: 目标页面类 + :param page_name: 目标页面类 :return: 目标页面的实例 """ - logger.info(f"跳转到页面: {page_class.__name__}") - return page_class(self.driver) \ No newline at end of file + logger.info(f"跳转到页面: {page_name.__name__}") + return page_name(self.driver) diff --git a/core/driver.py b/core/driver.py index 61d0e21..448ac1d 100644 --- a/core/driver.py +++ b/core/driver.py @@ -10,6 +10,7 @@ @desc: Appium 核心驱动封装,提供统一的 API 用于 Appium 会话管理和元素操作。 """ import logging +import secrets # 原生库,用于生成安全的随机数 from typing import Optional, Type, TypeVar, Union, Callable from time import sleep @@ -21,7 +22,7 @@ from appium.webdriver.webdriver import ExtensionBase from appium.webdriver.webelement import WebElement from appium.webdriver.client_config import AppiumClientConfig -from selenium.common import TimeoutException +from selenium.common import TimeoutException, StaleElementReferenceException, NoSuchElementException from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.action_chains import ActionChains @@ -32,7 +33,7 @@ from selenium.webdriver.common.actions.pointer_input import PointerInput from utils.finder import by_converter 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 +from core.settings import IMPLICIT_WAIT_TIMEOUT, EXPLICIT_WAIT_TIMEOUT, APPIUM_HOST, APPIUM_PORT, SCREENSHOT_DIR logger = logging.getLogger(__name__) @@ -40,21 +41,58 @@ T = TypeVar("T") class CoreDriver: - def __init__(self): + def __init__(self, driver: Optional[webdriver.Remote] = None): """ 初始化 CoreDriver 实例。 从 settings.py 加载默认的 Appium 服务器主机和端口。 """ - self.driver: Optional[webdriver.Remote] = None + self.driver = driver + self._current_implicit_timeout = IMPLICIT_WAIT_TIMEOUT self._host = APPIUM_HOST self._port = APPIUM_PORT - def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT): + @property + def server_url(self) -> str: + """ + 动态构造 URL,确保 server_config 修改后立即生效。 + """ + return f"http://{self._host}:{self._port}" + + def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT)-> 'CoreDriver': + """ + 配置服务器信息。支持链式调用。 + :param host: ip + :param port: 端口 + :return: 返回 CoreDriver 实例自身,支持链式调用。 + """ self._host = host self._port = port logger.info(f"Appium Server 指向 -> {self._host}:{self._port}") return self + @staticmethod + def _make_options(platform: str | AppPlatform, caps: dict) -> AppiumOptions: + """ + 根据平台生成对应的 Options + :param platform: 目标平台 ('android' 或 'ios'),支持 AppPlatform 枚举或字符串。 + :param caps: Appium capabilities 字典。 + :return: AppiumOptions + """ + match platform: + case AppPlatform.ANDROID.value: + logger.info(f"正在初始化 Android 会话...") + return UiAutomator2Options().load_capabilities(caps) + + case AppPlatform.IOS.value: + logger.info(f"正在初始化 iOS 会话...") + return XCUITestOptions().load_capabilities(caps) + + case _: + # 优化:不再默认返回 Android,而是显式报错 (Fail Fast) + msg = f"不支持的平台类型: [{platform}]。当前仅支持: [android, ios]" + logger.error(msg) + raise ValueError(msg) + def connect(self, platform: str | AppPlatform, caps: dict, extensions: list[Type[ExtensionBase]] | None = None, client_config: AppiumClientConfig | None = None) -> 'CoreDriver': @@ -71,33 +109,20 @@ class CoreDriver: """ # 1. 统一格式化平台名称 platform_name = platform.value if isinstance(platform, AppPlatform) else platform.lower().strip() - url = f"http://{self._host}:{self._port}" # 2. 预校验:如果已经有 driver 正在运行,先清理(防止 Session 冲突) if self.driver: logger.warning("发现旧的 Driver 实例尚未关闭,正在强制重置...") self.quit() + # 3. 匹配平台并加载 Options + options: AppiumOptions = self._make_options(platform_name, caps) + try: - # 3. 匹配平台并加载 Options - match platform_name: - case AppPlatform.ANDROID.value: - logger.info(f"正在初始化 Android 会话...") - options: AppiumOptions = UiAutomator2Options().load_capabilities(caps) - - case AppPlatform.IOS.value: - logger.info(f"正在初始化 iOS 会话...") - options: AppiumOptions = XCUITestOptions().load_capabilities(caps) - - case _: - # 优化:不再默认返回 Android,而是显式报错 (Fail Fast) - msg = f"不支持的平台类型: [{platform_name}]。当前仅支持: [android, ios]" - logger.error(msg) - raise ValueError(msg) # 4. 创建连接 self.driver = webdriver.Remote( - command_executor=url, + command_executor=self.server_url, options=options, extensions=extensions, client_config=client_config @@ -158,6 +183,7 @@ class CoreDriver: :return: """ self.driver.implicitly_wait(timeout) + self._current_implicit_timeout = timeout # 记录等待时间 @resolve_wait_method def explicit_wait(self, method: Union[Callable[[webdriver.Remote], T], str], timeout: Optional[float] = None) -> \ @@ -256,11 +282,40 @@ class CoreDriver: self.explicit_wait(method, timeout).send_keys(text) return self - def is_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool: + def is_visible(self, by: str, value: str) -> bool | None: """ 判断元素是否可见 :param by: 定位策略。 :param value: 定位值。 + :return: bool + """ + # 禁用隐式等待 + original_timeout = self._current_implicit_timeout + try: + self.implicit_wait(0) + + by = by_converter(by) + elements = self.driver.find_elements(by, value) + + if elements: + return elements[0].is_displayed() + # 2. 元素存在于 DOM 中,还需要判断它在 UI 上是否真正可见(宽/高 > 0 且未隐藏) + return False + except (StaleElementReferenceException, NoSuchElementException): + # 这些属于预料中的“不可见”情况 + return False + except Exception as e: + _ = e + return False + finally: + # 恢复原来的隐式等待时间 + self.implicit_wait(original_timeout) + + def wait_until_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool: + """ + 等待元素出现 + :param by: 定位策略。 + :param value: 定位值。 :param timeout: 等待超时时间。 :return: bool """ @@ -273,6 +328,23 @@ class CoreDriver: except TimeoutException: return False + def wait_until_not_visible(self, by: str, value: str, timeout: Optional[float] = None) -> bool: + """ + 等待元素消失 + :param by: 定位策略。 + :param value: 定位值。 + :param timeout: 等待超时时间。 + :return: bool + """ + try: + by = by_converter(by) + mark = (by, value) + method = EC.invisibility_of_element_located(mark) + self.explicit_wait(method, timeout) + return True + except TimeoutException: + return False + def get_text(self, by: str, value: str, timeout: Optional[float] = None) -> str: """ 获取元素文本 @@ -305,6 +377,60 @@ class CoreDriver: logger.info(f"获取属性 {name} of {mark}: {attr_value}") return attr_value + def clear_popups(self, black_list: list = None, max_rounds: int = 5) -> bool: + """ + 显式清理弹窗函数。 + 说明: + 1. 快速扫描:使用 is_visible (不等待) 确认弹窗是否存在。 + 2. 动作处理:发现后点击,并触发 wait_until_not_visible (异步等待消失)。 + 3. 自适应退出:当整轮扫描无障碍物时,立即返回,不浪费时间。 + 4. 异常存证:若点击失败或发生错误,自动截图。 + :param black_list: 允许传入当前页面特有的弹窗定位 [(by, value), ...](如某个活动的特殊广告) + :param max_rounds: 最大扫描轮数 + :return: active: bool(清理过一个弹窗都将返回 True) + """ + if not black_list: + logger.warning("未提供黑名单列表,跳过清理动作。") + return False + + list_len = len(black_list) + active = False + + logger.info(f"开始执行显式弹窗清理,待检查项: {list_len} 个") + for round_idx in range(max_rounds): + skip_count = 0 + for by, value in black_list: + + if not self.is_visible(by, value): + skip_count += 1 + continue + + logger.info(f"当前权重{skip_count},第 {round_idx + 1} 轮:待清理弹窗 -> {value}") + try: + elements = self.find_elements(by, value, timeout=0.5) # 使用极短超时 + if not elements: + skip_count += 1 + else: + elements[0].click() + active = True + + # 消失得快返回得就快,最多等 1.5s + if self.wait_until_not_visible(by, value, timeout=1.5): + logger.info(f"弹窗已成功消失") + else: + logger.warning(f"弹窗点击后仍存在") + + except Exception as e: + safe_val = secrets.token_hex(8) + file_name = f"popup_fail_{round_idx}_{safe_val}.png" + + logger.error(f"清理弹窗尝试点击时失败[{safe_val}:{value}]: {e}") + self.full_screen_screenshot(file_name) + raise e + if skip_count == list_len: + break + return active + @property def session_id(self): """获取当前 Appium 会话的 Session ID。""" @@ -411,7 +537,8 @@ class CoreDriver: return self - def swipe_by_percent(self, start_xp:int, start_yp:int, end_xp:int, end_yp:int, duration: int = 1000) -> 'CoreDriver': + def swipe_by_percent(self, start_xp: int, start_yp: int, end_xp: int, end_yp: int, + duration: int = 1000) -> 'CoreDriver': """ 按屏幕比例滑动 (0.5 = 50%) """ @@ -426,13 +553,52 @@ class CoreDriver: duration ) + def full_screen_screenshot(self, name: str | None = None) -> str: + """ + 截取当前完整屏幕内容 (自愈逻辑、异常报错首选) + :param name: 图片文件名 + :return: 截图保存的路径 + """ + file_name = f"{name or secrets.token_hex(8)}.png" + path = (SCREENSHOT_DIR / file_name).as_posix() + + try: + # 核心:save_screenshot 是底层原生方法,不依赖任何元素定位 + self.driver.save_screenshot(path) + logger.info(f"全屏截图已保存: {path}") + return path + except Exception as e: + logger.error(f"全屏截图失败: {e}") + return "" + + def element_screenshot(self, by: str, value: str, name: str | None = None) -> str: + """ + 截取特定元素的图像 (业务校验、UI对比首选) + :param by: 定位策略 + :param value: 定位值 + :param name: 图片文件名 + :return: 截图保存的路径 + """ + file_name = f"{name or secrets.token_hex(8)}.png" + path = (SCREENSHOT_DIR / file_name).as_posix() + + try: + by = by_converter(by) + # 核心:直接调用底层 find_element, + self.driver.find_element(by, value).screenshot(path) + logger.info(f"元素截图已保存: {path}") + return path + except Exception as e: + logger.error(f"元素截图失败: {e}") + return "" + @property def is_alive(self) -> bool: """判断当前驱动会话是否仍然存活。""" return self.driver is not None and self.driver.session_id is not None # --- 断言逻辑 --- - def assert_text(self, by:str, value:str, expected_text:str, timeout: Optional[float] = None) -> 'CoreDriver': + def assert_text(self, by: str, value: str, expected_text: str, timeout: Optional[float] = None) -> 'CoreDriver': """ 断言元素的文本内容是否符合预期。 :param by: 定位策略。 diff --git a/core/settings.py b/core/settings.py index 95c8e14..c07a1ee 100644 --- a/core/settings.py +++ b/core/settings.py @@ -23,9 +23,9 @@ LOG_DIR = OUTPUT_DIR / "logs" LOG_BACKUP_DIR = LOG_DIR / "backups" ALLURE_TEMP = BASE_DIR / "temp" REPORT_DIR = BASE_DIR / "report" - +SCREENSHOT_DIR = OUTPUT_DIR / "screenshots" # 确保必要的目录存在 -for folder in [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP]: +for folder in [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]: folder.mkdir(parents=True, exist_ok=True) # --- 文件路径 --- @@ -46,4 +46,21 @@ ANDROID_CAPS = { "deviceName": "Android", "appPackage": "com.android.settings", "appActivity": ".Settings", + "noReset": False +} +# ANDROID_CAPS = { +# "platformName": "Android", +# "automationName": "uiautomator2", +# "deviceName": "Android", +# "appPackage": "com.bocionline.ibmp", +# "appActivity": "com.bocionline.ibmp.app.main.launcher.LauncherActivity", +# "noReset":False +# } +IOS_CAPS = { + "platformName": "iOS", + "automationName": "XCUITest", + "autoAcceptAlerts": True, # 自动接受系统权限请求 + "waitForQuiescence": False, # 设为 False 可加速扫描 + # 如果是某些特定的业务弹窗 autoAcceptAlerts 无效, + # 此时就会触发我们代码里的 PopupManager.solve() } diff --git a/test_cases/test_login_demo.py b/test_cases/test_login_demo.py deleted file mode 100644 index 39ef8da..0000000 --- a/test_cases/test_login_demo.py +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python -# coding=utf-8 - -""" -@desc: 模拟登录功能测试用例 -""" -import pytest -import logging -from core.driver import CoreDriver -from core.modules import AppPlatform - -# 配置日志 -logging.basicConfig(level=logging.INFO) - -class TestLogin: - - driver: CoreDriver = None - - def setup_method(self): - """ - 每个测试用例开始前执行:初始化 Driver 并连接设备 - """ - # 定义测试设备的 Capabilities - # 注意:实际使用时,appPackage 和 appActivity 需要替换为被测 App 的真实值 - caps = { - "platformName": "Android", - "automationName": "UiAutomator2", - "deviceName": "Android Emulator", - "appPackage": "com.example.android.apis", # 替换为你的 App 包名 - "appActivity": ".ApiDemos", # 替换为你的 App 启动 Activity - "noReset": True, # 不清除应用数据 - "newCommandTimeout": 60 - } - - self.driver = CoreDriver() - # 连接 Appium Server - self.driver.server_config(host="127.0.0.1", port=4723) - self.driver.connect(platform=AppPlatform.ANDROID, caps=caps) - - def teardown_method(self): - """ - 每个测试用例结束后执行:退出 Driver - """ - if self.driver: - self.driver.quit() - - def test_login_success(self): - """ - 测试场景:使用正确的用户名和密码登录成功 - """ - # 1. 定位元素信息 (建议后续抽离到 Page Object 层) - # 假设登录页面的元素 ID 如下: - input_user = "id:com.example.app:id/et_username" - input_pass = "id:com.example.app:id/et_password" - btn_login = "id:com.example.app:id/btn_login" - txt_welcome = "xpath://*[@text='登录成功']" - - # 2. 执行操作步骤 - # 显式等待并输入用户名 - self.driver.input(input_user, "", "test_user_001") - - # 输入密码 (开启敏感模式,日志中脱敏) - self.driver.input(input_pass, "", "Password123!", sensitive=True) - - # 点击登录按钮 - self.driver.click(btn_login, "") - - # 3. 断言结果 - # 方式 A: 检查特定文本是否存在 - # self.driver.assert_text(txt_welcome, "", "登录成功") - - # 方式 B: 检查跳转后的页面元素是否可见 - is_login_success = self.driver.is_visible(txt_welcome, "") - assert is_login_success, "登录失败:未检测到欢迎提示或主页元素" - -if __name__ == "__main__": - # 允许直接运行此文件进行调试 - pytest.main(["-v", "-s", __file__]) \ No newline at end of file diff --git a/utils/decorators.py b/utils/decorators.py index 7d64550..adaeb1f 100644 --- a/utils/decorators.py +++ b/utils/decorators.py @@ -2,8 +2,10 @@ import logging from functools import wraps from typing import Union, Callable + from core.custom_expected_conditions import get_condition + logger = logging.getLogger(__name__) @@ -34,4 +36,4 @@ def resolve_wait_method(func): return func(self, method, *args, **kwargs) - return wrapper + return wrapper \ No newline at end of file