From fd6f4fbcbe4268216f7b56d5b6f0521f789469b9 Mon Sep 17 00:00:00 2001 From: CNWei Date: Fri, 30 Jan 2026 18:06:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(base=5Fpage):=20=E6=96=B0=E5=A2=9EBasePage?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化 is_visible,支持快速状态检查。 - 新增 log_screenshot/log_screenshot_bytes 截图。 - 更新 README.md。 - 其他优化。 --- README.md | 17 +++++++- conftest.py | 4 +- core/base_page.py | 73 ++++++++++++++++++++++++++++++++- core/driver.py | 20 +-------- page_objects/api_demod_home.py | 53 ++++++++++++++++++++++++ page_objects/api_demos_views.py | 33 +++++++++++++++ test_cases/conftest.py | 43 ++++++++++++++++++- test_cases/test_api_demos.py | 53 ++++++++++++++++++++++++ utils/decorators.py | 19 ++++++++- 9 files changed, 288 insertions(+), 27 deletions(-) create mode 100644 page_objects/api_demod_home.py create mode 100644 page_objects/api_demos_views.py create mode 100644 test_cases/test_api_demos.py diff --git a/README.md b/README.md index c497f7a..df27fbc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AppAutoTest -设备能力配置 (Capabilities) +## 设备能力配置 (Capabilities) ```python ANDROID_CAPS = { @@ -49,4 +49,17 @@ platformVersion:指定设备的Android系统版本(如 "11.0")。虽然不 unicodeKeyboard 和 resetKeyboard:用于处理中文输入等特殊字符输入。 -newCommandTimeout:设置Appium服务器等待客户端发送新命令的超时时间(秒),默认为60秒。在长时间操作中可能需要增加。 \ No newline at end of file +newCommandTimeout:设置Appium服务器等待客户端发送新命令的超时时间(秒),默认为60秒。在长时间操作中可能需要增加。 + +## allure核心属性表 + +| 属性 | 说明 | 用法示例 | +|---------------------|--------------------------------------|-------------| +| @allure.epic | 顶层分类(如:APP项目名称) | 定义在测试类/项目上 | +| @allure.feature | 功能模块(如:登录模块、交易模块) | 定义在测试类上 | +| @allure.story | 用户场景(如:成功登录、账号锁定) | 定义在测试方法上 | +| @allure.title | 测试用例标题(支持动态显示) | 替换方法名显示在报告中 | +| @allure.severity | "严重程度(BLOCKER, CRITICAL, NORMAL...)" | 用于筛选高优先级用例 | +| @allure.description | 详细描述(支持 Markdown) | 解释测试背景或前提条件 | +| @allure.link | 外部链接(Bug系统、需求文档) | 快速点击跳转 | +| @allure.issue | 缺陷链接(通常会自动带上 ISSUE 前缀) | 追踪已知 Bug | \ No newline at end of file diff --git a/conftest.py b/conftest.py index 01a6461..d2f8e4b 100644 --- a/conftest.py +++ b/conftest.py @@ -42,8 +42,8 @@ def driver(app_server): "platformName": "Android", "automationName": "uiautomator2", "deviceName": "Android", - "appPackage": "com.bocionline.ibmp", - "appActivity": "com.bocionline.ibmp.app.main.launcher.LauncherActivity", + "appPackage": "io.appium.android.apis", + "appActivity": "io.appium.android.apis.ApiDemos", "noReset": False, # 不清除应用数据 "newCommandTimeout": 60 } diff --git a/core/base_page.py b/core/base_page.py index aa4f5eb..09c3b7f 100644 --- a/core/base_page.py +++ b/core/base_page.py @@ -11,12 +11,14 @@ """ import logging import secrets -from typing import Type, TypeVar, List, Tuple - +from typing import Type, TypeVar, List, Tuple, Optional +import allure +from pathlib import Path from appium import webdriver from selenium.common import TimeoutException from core.driver import CoreDriver +from utils.decorators import exception_capture # 定义一个泛型,用于类型推断(IDE 依然会有补全提示) T = TypeVar('T', bound='BasePage') @@ -29,7 +31,74 @@ class BasePage(CoreDriver): super().__init__(driver) # 定义常见弹窗的关闭按钮定位 + def log_screenshot(self, label: str = "步骤截图"): + """ + 业务级截图:执行截图并附加到 Allure 报告。 + 用户可自由手动调用此方法。 + """ + path_str = self.full_screen_screenshot(name=label) + + if path_str: + img_path = Path(path_str) + if img_path.exists(): + allure.attach.file( + img_path, + name=label, + attachment_type=allure.attachment_type.PNG + ) + + def log_screenshot_bytes(self, label: str = "步骤截图"): + """ + 业务级截图:执行截图并附加到 Allure 报告。 + 用户可自由手动调用此方法。 + """ + _img: bytes = self.driver.get_screenshot_as_png() + + allure.attach( + _img, + name=label, + attachment_type=allure.attachment_type.PNG + ) + + # --- 常用断言逻辑 --- + def assert_text(self, by: str, value: str, expected_text: str, timeout: Optional[float] = None) -> 'BasePage': + """ + 断言元素的文本内容是否符合预期。 + :param by: 定位策略。 + :param value: 定位值。 + :param expected_text: 期望的文本。 + :param timeout: 等待元素可见的超时时间。 + :return: self,支持链式调用。 + :raises AssertionError: 如果文本不匹配。 + """ + # 1. 增强报告展示:将断言动作包装为一个清晰的步骤 + step_name = f"断言校验 | 预期结果: '{expected_text}'" + with allure.step(step_name): + actual = self.get_text(by, value, timeout) + # 2. 动态附件:在报告中直观对比,方便后期排查 + allure.attach( + f"预期值: {expected_text}\n实际值: {actual}", + name="文本对比结果", + attachment_type=allure.attachment_type.TEXT + ) + # 3. 执行核心断言 + # 如果断言失败,抛出的 AssertionError 会被 conftest.py 中的 Hook 捕获并截图 + assert actual == expected_text, f"断言失败: 期望 {expected_text}, 实际 {actual}" + logger.info(f"断言通过: 文本匹配 '{actual}'") + return self + # 这里放全局通用的 Page 属性和逻辑 + def assert_visible(self, by: str, value: str, msg: str = "元素可见性校验"): + """ + 增强版断言:成功/失败均截图 + """ + with allure.step(f"断言检查: {msg}"): + try: + element = self.find_element(by, value) + assert element.is_displayed() + # 成功存证 + except Exception as e: + raise e # 封装一些所有页面通用的元动作 def clear_permission_popups(self): diff --git a/core/driver.py b/core/driver.py index 448ac1d..83a542e 100644 --- a/core/driver.py +++ b/core/driver.py @@ -58,7 +58,7 @@ class CoreDriver: """ return f"http://{self._host}:{self._port}" - def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT)-> 'CoreDriver': + def server_config(self, host: str = APPIUM_HOST, port: int = APPIUM_PORT) -> 'CoreDriver': """ 配置服务器信息。支持链式调用。 :param host: ip @@ -358,7 +358,7 @@ class CoreDriver: method = EC.visibility_of_element_located(mark) text = self.explicit_wait(method, timeout).text - logger.info(f"获取到的文本{text}") + logger.info(f"获取到的文本: {text}") return text def get_attribute(self, by: str, value: str, name: str, timeout: Optional[float] = None) -> str: @@ -597,22 +597,6 @@ class CoreDriver: """判断当前驱动会话是否仍然存活。""" 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': - """ - 断言元素的文本内容是否符合预期。 - :param by: 定位策略。 - :param value: 定位值。 - :param expected_text: 期望的文本。 - :param timeout: 等待元素可见的超时时间。 - :return: self,支持链式调用。 - :raises AssertionError: 如果文本不匹配。 - """ - actual = self.get_text(by, value, timeout) - assert actual == expected_text, f"断言失败: 期望 {expected_text}, 实际 {actual}" - logger.info(f"断言通过: 文本匹配 '{actual}'") - return self - def quit(self): """安全关闭 Appium 驱动并断开连接。""" if self.driver: diff --git a/page_objects/api_demod_home.py b/page_objects/api_demod_home.py new file mode 100644 index 0000000..a746b82 --- /dev/null +++ b/page_objects/api_demod_home.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# coding=utf-8 + +""" +@author: CNWei,ChenWei +@Software: PyCharm +@contact: t6g888@163.com +@file: test_home +@date: 2026/1/30 17:18 +@desc: +""" +import logging + +import allure +from appium import webdriver + +from core.base_page import BasePage + +logger = logging.getLogger(__name__) + + +class HomePage(BasePage): + # 定位参数 + text = ("accessibility id", "Text") + unicode = ("accessibility id", "Unicode") + + def __init__(self, driver: webdriver.Remote): + super().__init__(driver) + + @allure.step("点击 “Text ”") + def click_text(self): + if self.wait_until_visible(*self.text, timeout=1): + with allure.step("发现Text,开始执行点击"): + # self.log_screenshot_bytes("Text截图").click(*self.text) + self.log_screenshot_bytes("Text截图") + self.click(*self.text) + + @allure.step("点击 “Unicode ”:{1}") + def click_unicode(self, taget): + """执行登录业务逻辑""" + # 调用继承自 CoreDriver 的方法(假设你的 CoreDriver 已经被注入或组合) + + if self.wait_until_visible(*self.unicode): + self.swipe("left") + + self.click(*self.unicode).log_screenshot() + + @allure.step("获取 “Text ”文本") + def get_home_text(self): + """执行登录业务逻辑""" + # 调用继承自 CoreDriver 的方法(假设你的 CoreDriver 已经被注入或组合) + + return self.get_text(*self.text) diff --git a/page_objects/api_demos_views.py b/page_objects/api_demos_views.py new file mode 100644 index 0000000..847f950 --- /dev/null +++ b/page_objects/api_demos_views.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# coding=utf-8 + +""" +@author: CNWei,ChenWei +@Software: PyCharm +@contact: t6g888@163.com +@file: test_views +@date: 2026/1/30 17:37 +@desc: +""" +import logging + +import allure +from appium import webdriver + +from core.base_page import BasePage + +logger = logging.getLogger(__name__) + + +class ViewsPage(BasePage): + # 定位参数 + views = ("accessibility id","Views") + + def __init__(self, driver: webdriver.Remote): + super().__init__(driver) + + @allure.step("截图 “Views ”") + def screenshot_views(self): + if self.wait_until_visible(*self.views): + with allure.step("发现Views,开始执行点击"): + self.log_screenshot_bytes("Text截图") diff --git a/test_cases/conftest.py b/test_cases/conftest.py index 1637105..6d1a552 100644 --- a/test_cases/conftest.py +++ b/test_cases/conftest.py @@ -8,4 +8,45 @@ @file: conftest @date: 2026/1/19 14:08 @desc: -""" \ No newline at end of file +""" +import pytest +import allure +from pathlib import Path + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport(item, call): + """ + 本钩子函数会在每个测试阶段(setup, call, teardown)执行后被调用。 + item: 测试用例对象 + call: 测试执行阶段的信息 + """ + # 1. 先执行常规的用例报告生成逻辑 + outcome = yield + report = outcome.get_result() + + # 2. 我们只关注测试执行阶段 ("call") + # 如果该阶段失败了(failed),则触发截图 + if report.when == "call" and report.failed: + # 3. 从测试用例中获取 driver 实例 + # 假设你在 fixture 中注入的参数名为 'driver' + driver_instance = item.funcargs.get("driver") + + if driver_instance: + try: + # 4. 调用你在 CoreDriver 中实现的底层截图方法 + # 这里的 name 我们可以动态取测试用例的名字 + case_name = item.name + file_path = driver_instance.full_screen_screenshot(name=f"CRASH_{case_name}") + + # 5. 如果路径存在,将其关联到 Allure 报告 + if file_path: + p = Path(file_path) + if p.exists(): + allure.attach.file( + source=p, + name="【故障现场自动截图】", + attachment_type=allure.attachment_type.PNG + ) + except Exception as e: + print(f"故障自动截图执行失败: {e}") \ No newline at end of file diff --git a/test_cases/test_api_demos.py b/test_cases/test_api_demos.py new file mode 100644 index 0000000..0062c91 --- /dev/null +++ b/test_cases/test_api_demos.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python +# coding=utf-8 + +""" +@author: CNWei,ChenWei +@Software: PyCharm +@contact: t6g888@163.com +@file: test_api_demos +@date: 2026/1/30 17:42 +@desc: +""" +import logging + +import allure +import pytest + +from page_objects.api_demod_home import HomePage +from page_objects.api_demos_views import ViewsPage + +# 配置日志 +logger = logging.getLogger(__name__) + +@allure.epic("ApiDemos") +@allure.feature("登录认证模块") +class TestApiDemos: + # @allure.story("常规登录场景") + @allure.title("使用合法账号登录成功") + @allure.severity(allure.severity_level.BLOCKER) + @allure.description(""" + 验证用户在正常网络环境下: + 1. 处理初始化弹窗和广告 + 2. 选择证券登录类型 + 3. 输入正确凭证后成功跳转至‘交易’首页 + """) + @allure.link("https://docs.example.com/login_spec", name="登录业务说明文档") + @allure.issue("BUG-1001", "已知偶发:部分机型广告Banner无法滑动") + def test_api_demos_success(self, driver,user): + """ + 测试场景:使用正确的用户名和密码登录成功 + """ + api_demos = HomePage(driver) + + # 执行业务逻辑 + api_demos.click_text() + api_demos.click_unicode() + # 断言部分使用 allure.step 包装,使其在报告中也是一个可读的步骤 + with allure.step("最终校验:检查是否进入首页并显示‘交易’标题"): + actual_text = api_demos.get_home_text() + assert actual_text == "Text" + + # 页面跳转 + api_demos.go_to(ViewsPage).screenshot_views() + api_demos.delay(5) \ No newline at end of file diff --git a/utils/decorators.py b/utils/decorators.py index adaeb1f..c23fbe7 100644 --- a/utils/decorators.py +++ b/utils/decorators.py @@ -5,7 +5,6 @@ from typing import Union, Callable from core.custom_expected_conditions import get_condition - logger = logging.getLogger(__name__) @@ -36,4 +35,20 @@ def resolve_wait_method(func): return func(self, method, *args, **kwargs) - return wrapper \ No newline at end of file + return wrapper + + +def exception_capture(func): + """ + 仅在原子动作失败时,触发 BasePage 层的业务截图逻辑 + """ + @wraps(func) + def wrapper(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except Exception as e: + # 自动捕获:调用 BasePage 层的 log_screenshot + if hasattr(self, "log_screenshot"): + self.log_screenshot(f"自动异常捕获{func.__name__}") + raise e + return wrapper