2 Commits

Author SHA1 Message Date
6ad6b7ff84 fix(conftest,config_loader): 修复 get_caps 的 Capabilities 加载逻辑
- 新增 pytest_addoption 增加 "--caps_name" 获取配置文件中的设备/平台名称
- 修复 get_caps 的 Capabilities 加载逻辑
- 优化 其他优化 enums.py
- 删除 移除部分文档,代码,第三方包(loguru)
2026-02-28 16:08:14 +08:00
332deb3666 feat(docs,page_objects): 完善文档以及测试用例演示
- 新增 AndroidSDK环境配置指南.md, 常用参数.md
- 更新 README.md
- 优化 wan_android_home.py, wan_android_project.py
- 其他优化
2026-02-27 16:44:00 +08:00
33 changed files with 722 additions and 552 deletions

5
.gitignore vendored
View File

@@ -12,10 +12,9 @@ wheels/
venv/ venv/
node_modules/ node_modules/
uv.lock uv.lock
# --- 屏蔽outputs --- # --- 屏蔽outputs ---
outputs/logs/*.log outputs/
outputs/logs/backups/*
outputs/screenshots/
# --- Allure 报告 --- # --- Allure 报告 ---
temp/ temp/

222
README.md
View File

@@ -1,79 +1,177 @@
# AppAutoTest # AppAutoTest - 基于 Python 的 App 自动化测试框架
## 设备能力配置 (Capabilities) ## 1. 简介
```python `AppAutoTest` 是一个基于 Python 构建的健壮且可扩展的移动应用自动化测试框架。它利用 Appium、Pytest 和 Allure 等行业标准工具,为测试
ANDROID_CAPS = { Android 和 iOS 应用提供了全面的解决方案。该框架采用页面对象模型 (POM) 设计,以提高可维护性和可扩展性,并包含一套实用工具以简化测试开发和执行。
"platformName": "Android",
"automationName": "uiautomator2", ## 2. 特性
"deviceName": "Android",
"appPackage": "com.android.settings", - **跨平台支持**: 原生支持 Android (通过 `UiAutomator2`) 和 iOS (通过 `XCUITest`)。
"appActivity": ".Settings", - **页面对象模型 (POM)**: 强制分离 UI 元素定位器和测试逻辑,增强代码可读性和可维护性。
"noReset": False - **丰富的测试报告**: 与 Allure 框架无缝集成,生成包含测试步骤、截图、日志和环境信息的详细交互式报告。
} - **自动服务管理**: 智能管理 Appium 服务器生命周期。如果本地服务器未运行,它可以自动启动,或者连接到现有的外部服务器。
- **强大的驱动引擎**: 包含核心驱动封装 (`CoreDriver`),通过内置显式等待、流式 API、高级日志记录和健壮的错误处理简化 Appium
操作。
- **灵活配置**: 轻松管理设备能力 (`config/caps.yaml`)、特定环境参数和敏感数据 (`.env`)。
- **可自定义等待条件**: 扩展 Selenium 的标准预期条件,提供用于复杂场景(如等待 Toast 消息或特定元素计数)的自定义可重用等待。
- **高级日志与追踪**: 利用自定义装饰器 (`@step_trace`) 自动生成测试步骤的分层日志,包括执行时间、输入参数和状态。
- **自动截图**: 在测试失败时自动捕获截图,并允许在任何步骤轻松将截图附加到 Allure 报告中以便快速调试。
- **现代依赖管理**: 使用 `uv` (通过 `pyproject.toml`) 进行快速 Python 依赖解析,使用 `npm` (通过 `package.json`) 管理
Node.js 依赖。
## 3. 项目结构
框架遵循逻辑清晰且模块化的结构,以促进可扩展性和易于导航。
```text
AppAutoTest/
├── config/
│ └── caps.yaml # 不同平台的 Appium capabilities 配置。
├── core/
│ ├── base_page.py # 所有页面对象的抽象基类。
│ ├── config_loader.py # 加载配置文件 (caps, 环境设置)。
│ ├── custom_expected_conditions.py # 定义复杂 UI 状态的自定义等待条件。
│ ├── driver.py # 核心 Appium 驱动封装,增强了操作和等待。
│ ├── modules.py # 通用枚举定义 (如 AppPlatform, Locator)。
│ ├── run_appium.py # 处理 Appium 服务器的生命周期。
│ └── settings.py # 全局框架设置 (路径, 超时等)。
├── data/ # 数据驱动测试的示例测试数据。
├── docs/ # 文档和说明。
├── outputs/
│ ├── logs/ # 存储测试运行的日志文件。
│ └── screenshots/ # 存储测试期间捕获的截图。
├── page_objects/ # 对象类。
├── reports/ # 存储生成的 Allure HTML 报告。
├── temp/ # 原始 Allure 结果的临时目录。
├── test_cases/ # 测试脚本。
├── utils/
│ ├── decorators.py # 用于日志、截图等的自定义装饰器。
│ ├── dirs_manager.py # 确保所需目录存在的工具。
│ ├── finder.py # 定位策略转换工具。
│ └── report_handler.py # Allure 报告生成工具。
├── .env # 存储环境变量 (如凭据)。Git 忽略此文件。
├── conftest.py # Pytest 的核心配置文件,用于 fixtures 和 hooks。
├── main.py # 执行测试套件的主入口点。
├── package.json # 定义 Node.js 项目元数据和依赖 (Appium)。
├── pytest.ini # Pytest 配置文件 (日志, 标记, 路径)。
├── pyproject.toml # Python 项目元数据和依赖定义 (PEP 621)。
└── README.md # 项目说明。
``` ```
| 字段名称 | 字段含义解释 | 示例值说明 | ## 4. 前置条件
|----------------|---------------------------------------------|---------------------------------------------------------------------------------------|
| 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 有几种常用方法 - **Node.js**: 版本 20+ (如 `package.json` 中指定)。
- **Python**: 版本 3.11+ (如 `pyproject.toml` 中指定)。
- **Java JDK**: Appium 和 Android SDK 需要。
- **Android SDK**: 构建和与 Android 应用交互需要。
- **Allure Commandline**: 生成和查看 HTML 报告需要。
> 注意:[Android SDK 环境配置指南](docs/AndroidSDK环境配置指南.md)
## 5. 安装与运行指南
```shell 按照以下步骤搭建测试环境。
adb shell pm list packages | findstr your_package_name
# adb shell pm list packages -3 仅列出用户安装的第三方应用的包名 1. **克隆仓库:**
```bash
git clone <your-repository-url>
cd AppAutoTest
``` ```
获取应用的 appActivity 有几种常用方法 2. **安装 Node.js 依赖:**
此命令将在项目目录中本地安装 Appium 及其相关驱动,如 `package.json` 中所定义。
```shell ```bash
# 1使用 aapt 工具分析 APK 文件(需有安装包)/build-tools/{version}/aapt npm install
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
``` ```
常用补充字段: 3. **安装 Python 依赖:**
noResetTrue/False。是否在会话开始前重置应用状态例如清除应用数据。设置为 True 可以避免每次测试都重新登录 使用 `uv` 进行 Python 依赖管理
```bash
# 如果没有安装 uv请先安装
# 使用 uv 安装项目依赖
uv sync
```
不使用 `uv`进行 Python 依赖管理。
```bash
#使用 pip 安装项目依赖
# 以可编辑模式安装(开发时推荐)
pip install -e .
platformVersion指定设备的Android系统版本如 "11.0")。虽然不是必须,但指定后能增强兼容性。 # 或以普通模式安装
pip install .
```
unicodeKeyboard 和 resetKeyboard用于处理中文输入等特殊字符输入。 ## 6. 如何运行测试
newCommandTimeout设置Appium服务器等待客户端发送新命令的超时时间默认为60秒。在长时间操作中可能需要增加 框架提供了两种主要的测试执行方式
## allure核心属性表 ### 方法 1: 使用主入口点 (推荐)
| 属性 | 说明 | 用法示例 | 执行 `main.py` 脚本来运行整个测试套件。此脚本会自动处理所有运行前和运行后的任务。
|---------------------|--------------------------------------|-------------|
| @allure.epic | 顶层分类APP项目名称 | 定义在测试类/项目上 |
| @allure.feature | 功能模块(如:登录模块、交易模块) | 定义在测试类上 |
| @allure.story | 用户场景(如:成功登录、账号锁定) | 定义在测试方法上 |
| @allure.title | 测试用例标题(支持动态显示) | 替换方法名显示在报告中 |
| @allure.severity | "严重程度BLOCKER, CRITICAL, NORMAL..." | 用于筛选高优先级用例 |
| @allure.description | 详细描述(支持 Markdown | 解释测试背景或前提条件 |
| @allure.link | 外部链接Bug系统、需求文档 | 快速点击跳转 |
| @allure.issue | 缺陷链接(通常会自动带上 ISSUE 前缀) | 追踪已知 Bug |
Pytest 原生高频参数 和 你的自定义参数。 ```bash
python main.py
```
| **分类** | **长参数** | **短参数/别名** | **作用说明** | 此命令将:
|-----------|------------------|------------|------------------------------------|
| **基础运行** | `--verbose` | `-v` | 打印详细运行过程(显示用例名称) | 1. 确保所有必要的输出目录已创建。
| | (无) | `-s` | 允许在控制台打印代码里的 `print` 内容 | 2. 归档上次运行的日志文件。
| **调试控制** | `--exitfirst` | `-x` | 遇到第一个失败的用例就立即停止测试 | 3. 启动 Appium 服务器 (或连接到现有的)。
| | `--maxfail=n` | (无) | 累计失败 `n` 个用例后停止运行 | 4. 通过 Pytest 运行 `test_cases/` 目录下的所有测试。
| | `--last-failed` | `--lf` | 只运行上次运行失败的用例 | 5. 在 `reports/` 目录生成新的 Allure 报告。
| **自定义参数** | **`--platform`** | **`-P`** | 指定运行平台 \[Android\/IOS\/别名\]\(可自定义) |
| | **`--udid`** | **`-U`** | 指定手机唯一标识 (可自定义) | ### 方法 2: 直接使用 Pytest
| | **`--host`** | **`-H`** | 指定 Appium 服务器地址 (可自定义) |
| **报告相关** | `--alluredir` | (无) | 指定 Allure 原始数据的存放路径 | 为了更精细的控制,您可以直接从命令行调用 `pytest`。`conftest.py` 中定义的 fixtures 仍将管理 Appium 服务器和驱动会话。
```bash
# 运行特定的测试文件并显示详细输出
pytest -v test_cases/test_wan_android_home.py
# 运行测试并在第一个失败处停止
pytest -x
# 覆盖默认平台并指定设备 UDID
pytest --platform IOS --udid <your_iphone_udid>
```
**可用的自定义命令行参数:**
- `--platform`: 目标平台 (`Android` 或 `IOS`)。默认为 `Android`。
- `--caps_name`: 设备/平台名称。
- `--udid`: 目标设备的唯一设备标识符 (UDID)。
- `--host`: Appium 服务器的主机地址。默认为 `127.0.0.1`。
- `--port`: Appium 服务器的端口。默认为 `4723`。
> 注意:[其他常用参数](./docs/常用参数.md)
## 7. 测试报告
测试运行后,原始 Allure 结果存储在 `temp/` 目录中。`main.py` 脚本会自动根据这些结果生成 HTML 报告。
要查看最新报告,可以使用 Allure 命令行工具启动服务:
```bash
# 此命令将在默认 Web 浏览器中打开报告
allure serve temp/
```
## 8. 核心概念解释
#### 页面对象模型 (POM)
- **`page_objects/`**: 此目录包含 POM 设计的核心。每个类 (例如 `HomePage`)
代表应用程序的一个屏幕或重要组件。它负责封装元素定位器和对这些元素执行操作的方法 (例如 `login(user, pwd)`)。
- **`core/base_page.py`**: 这是所有页面对象的父类。它提供共享功能,如页面间导航 (`go_to`)、将截图附加到报告 (
`attach_screenshot_bytes`) 以及实现通用断言 (`assert_text`)。
#### 驱动引擎
- **`core/driver.py`**: 此类是标准 Appium `webdriver.Remote` 的强大封装。它通过集成内置的显式等待增强了基本操作,使测试更稳定,更不容易出现竞争条件。它还为高级移动手势(如
`swipe()`、`long_press()` 和 `smart_scroll()`)提供了流畅的接口。
#### 自定义装饰器
- **`utils/decorators.py`**: 此模块包含装饰器,可在不干扰逻辑的情况下向测试和页面方法添加强大的横切关注点。
- `@step_trace()`: 自动记录任何被装饰函数的进入、退出、参数和执行时间,创建一个清晰的分层日志,这对调试非常有价值。
- `@action_screenshot()`: 一个简单但有效的装饰器,在方法成功执行后自动截图,在测试报告中提供可视化的审计跟踪。

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,13 +173,16 @@ 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)
# 收集环境信息 # 收集环境信息
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 "未指定", "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 可执行文件的绝对路径。

View File

@@ -22,7 +22,7 @@ SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
LOG_DIR = OUTPUT_DIR / "logs" LOG_DIR = OUTPUT_DIR / "logs"
LOG_BACKUP_DIR = LOG_DIR / "backups" LOG_BACKUP_DIR = LOG_DIR / "backups"
ALLURE_TEMP = BASE_DIR / "temp" ALLURE_TEMP = BASE_DIR / "temp"
REPORT_DIR = BASE_DIR / "report" REPORT_DIR = BASE_DIR / "reports"
CONFIG_DIR = BASE_DIR / "config" CONFIG_DIR = BASE_DIR / "config"
DATA_DIR = BASE_DIR / "data" DATA_DIR = BASE_DIR / "data"

View File

@@ -0,0 +1,106 @@
# Android SDK 环境配置指南
本指南将引导您完成 Appium for Android 测试所需的基础环境——Android SDK 的安装与配置。
## 1. 创建 SDK 目录
首先,在您的电脑上选择一个合适的位置(例如 `D:\`),创建一个新目录作为 Android SDK 的根目录。
```
D:\android_sdk
```
## 2. 安装命令行工具 (Command Line Tools)
1. 访问 Android Studio [官方下载页面](https://developer.android.google.cn/studio?hl=zh-cn#command-line-tools-only)
,在页面底部找到 **"仅命令行工具"** 部分,下载适用于您操作系统的软件包(例如 `commandlinetools-win-xxxxxxx_latest.zip`)。
![image-20260110142023709.png](assets/image-20260110142023709.png)
2. 解压下载的 `zip` 文件。
3. 将解压后得到的 `cmdline-tools` 目录移动到您在第一步创建的 `android_sdk` 目录中。
4. 进入 `android_sdk/cmdline-tools` 目录,在内部创建一个名为 `latest` 的新目录。
5.`cmdline-tools` 目录内的所有内容(包括 `bin` 目录、`lib` 目录、`NOTICE.txt``source.properties` 文件)移动到刚刚创建的
`latest` 目录中。
完成后的目录结构应如下所示:
```
android_sdk/
├── cmdline-tools/
│ └── latest/ ← 必须有这个 'latest' 目录
│ ├── bin/
│ │ └── sdkmanager
│ ├── lib/
│ └── ...
```
## 3. 安装平台工具 (Platform-Tools)
1. 访问 SDK
平台工具版本说明页面,下载最新的[平台工具包](https://developer.android.google.cn/tools/releases/platform-tools?hl=zh-cn#downloads.html)
(例如 `platform-tools-latest-windows.zip`)。
![image-20260110141432210.png](assets/image-20260110141432210.png)
2. 解压下载的 `zip` 文件。
3. 将解压后得到的 `platform-tools` 目录整个移动到 `android_sdk` 根目录下。
此时,您的 `android_sdk` 目录结构应如下:
```
android_sdk/
├── cmdline-tools/
├── platform-tools/
└── ...
```
## 4. 配置环境变量
为了让系统能够识别 `adb`、`sdkmanager` 等命令,需要配置以下环境变量:
1. **新建 `ANDROID_HOME`**:
* 变量名: `ANDROID_HOME`
* 变量值: `D:\android_sdk` (替换为您的实际路径)
2. **编辑 `Path` 变量**:
在系统变量的 `Path` 中,追加以下两项:
```
%ANDROID_HOME%/tools
%ANDROID_HOME%/tools/bin
%ANDROID_HOME%/platform-tools
%ANDROID_HOME%/cmdline-tools/latest/bin
```
![image-20260110144326458.png](assets/image-20260110144326458.png)
![image-20260110144556568.png](assets/image-20260110144556568.png)
> **提示**: 关于环境变量的更多详细设置,请参阅
> Android [官方文档](https://developer.android.google.cn/tools/variables?hl=zh-cn)。
## 5. 验证安装
打开一个新的命令行窗口CMD 或 PowerShell输入以下命令
```sh
adb --version
```
如果成功显示 Android Debug Bridge 的版本信息,则说明 `platform-tools` 已配置成功。
## 6. (可选) 配置 Bundletool
`bundletool` 是 Google 用于处理 Android App Bundles (`.aab` 文件) 的工具。如果 `appium-doctor` 提示
`bundletool.jar cannot be found` 警告,您可以按以下步骤处理:
1. **下载**: 从 bundletool GitHub Releases 页面下载最新的 `bundletool-all-x.x.x.jar` 文件。
2. **创建目录**: 在您的 `ANDROID_HOME` 目录下(例如 `D:\android_sdk`)创建一个名为 `bundle-tool` 的新文件夹。
3. **重命名并移动**: 将下载的 `.jar` 文件重命名为 `bundletool.jar`,然后将其移动到 `bundle-tool` 文件夹中。
4. **配置 PATH**: 将 `bundle-tool` 目录也添加到系统的 `Path` 环境变量中:
```
%ANDROID_HOME%\bundle-tool
```
完成以上步骤后,重新运行 `appium-doctor`,相关警告应会消失。

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

79
docs/常用参数.md Normal file
View File

@@ -0,0 +1,79 @@
# 常用参数说明
## 设备能力配置 (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
```
常用补充字段:
noResetTrue/False。是否在会话开始前重置应用状态例如清除应用数据。设置为 True 可以避免每次测试都重新登录。
platformVersion指定设备的Android系统版本如 "11.0")。虽然不是必须,但指定后能增强兼容性。
unicodeKeyboard 和 resetKeyboard用于处理中文输入等特殊字符输入。
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 |
Pytest 原生高频参数 和 你的自定义参数。
| **分类** | **长参数** | **短参数/别名** | **作用说明** |
|-----------|------------------|------------|------------------------------------|
| **基础运行** | `--verbose` | `-v` | 打印详细运行过程(显示用例名称) |
| | (无) | `-s` | 允许在控制台打印代码里的 `print` 内容 |
| **调试控制** | `--exitfirst` | `-x` | 遇到第一个失败的用例就立即停止测试 |
| | `--maxfail=n` | (无) | 累计失败 `n` 个用例后停止运行 |
| | `--last-failed` | `--lf` | 只运行上次运行失败的用例 |
| **自定义参数** | **`--platform`** | **`-P`** | 指定运行平台 \[Android\/IOS\/别名\]\(可自定义) |
| | **`--udid`** | **`-U`** | 指定手机唯一标识 (可自定义) |
| | **`--host`** | **`-H`** | 指定 Appium 服务器地址 (可自定义) |
| **报告相关** | `--alluredir` | (无) | 指定 Allure 原始数据的存放路径 |

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

@@ -1,25 +0,0 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: home_page
@date: 2026/1/26 17:37
@desc:
"""
from core.base_page import BasePage
class HomePage(BasePage):
_LOGOUT_BTN = ("text", "退出登录")
_NICKNAME = ("id", "user_nickname")
def get_nickname(self):
return self.driver.get_text(*self._NICKNAME)
def logout(self):
self.driver.click(*self._LOGOUT_BTN)
# 【核心:链式跳转】
# 退出后回到登录页
return self.login_page

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: login_page
@date: 2026/1/26 17:34
@desc:
"""
from core.base_page import BasePage
from page_objects.home_page import HomePage
class LoginPage(BasePage):
# 定位参数私有化,不暴露给外面
_USER_FIELD = ("id", "com.app:id/username")
_PWD_FIELD = ("id", "com.app:id/password")
_LOGIN_BTN = ("id", "com.app:id/btn_login")
def login_as(self, username, password):
"""执行登录业务逻辑"""
# 调用继承自 CoreDriver 的方法(假设你的 CoreDriver 已经被注入或组合)
self.driver.input(*self._USER_FIELD, text=username)
self.driver.input(*self._PWD_FIELD, text=password, sensitive=True)
self.driver.click(*self._LOGIN_BTN)
# 【核心:链式跳转】
# 登录成功后,逻辑上应该进入首页,所以返回首页实例
return self.to_page(HomePage)

View File

@@ -5,12 +5,11 @@
@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:
""" """
import logging import logging
import os
import allure import allure
from appium import webdriver from appium import webdriver
@@ -32,7 +31,6 @@ class HomePage(BasePage):
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", '登录')
def __init__(self, driver: webdriver.Remote): def __init__(self, driver: webdriver.Remote):
@@ -49,21 +47,18 @@ class HomePage(BasePage):
@allure.step("登录账号:{1}") @allure.step("登录账号:{1}")
def login(self, username, password): def login(self, username, password):
"""执行登录业务逻辑""" """执行登录业务逻辑"""
account_input = {"elementId": '00000000-0000-0ecb-0000-0017000002d8', "text": username} account_element_id = self.find_element(*self.account).id
pass_word_input = {"elementId": '00000000-0000-0ecb-0000-0018000002d8', "text": password} account_input = {"elementId": account_element_id, "text": username}
pwd_element_id = self.find_element(*self.pass_word).id
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):
self.click(*self.account).driver.execute_script('mobile: type', account_input) self.click(*self.account).driver.execute_script('mobile: type', account_input)
self.click(*self.pass_word).driver.execute_script('mobile: type', pass_word_input) self.click(*self.pass_word).driver.execute_script('mobile: type', pass_word_input)
self.click(*self.login_button).full_screen_screenshot() self.click(*self.login_button)
# @allure.step("获取 “Text ”文本") if self.wait_until_visible(*self.tv_name):
# def get_home_text(self): self.full_screen_screenshot("登陆成功")
# """执行登录业务逻辑"""os.getenv("USER_NAME") os.getenv("PASS_WORD") self.long_press(x=636, y=117, duration=300)
# # 调用继承自 CoreDriver 的方法(假设你的 CoreDriver 已经被注入或组合)
# """
# { "elementId": "00000000-0000-0ecb-0000-0017000002d8", "text": "admintest123456" }
# { "elementId": "00000000-0000-0ecb-0000-0018000002d8", "text": "admin123456" }
# """
#
# return self.get_text(*self.text)

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: wan_android_project
@date: 2026/1/30 17:37
@desc:
"""
import logging
import allure
from appium import webdriver
from core.base_page import BasePage
from utils.decorators import StepTracer
logger = logging.getLogger(__name__)
class ProjectPage(BasePage):
# 定位参数
project_title = ("-android uiautomator", 'new UiSelector().text("项目")')
pro_table_title = ("-android uiautomator", 'new UiSelector().text("完整项目")')
def __init__(self, driver: webdriver.Remote):
super().__init__(driver)
@allure.step("切换到“项目”页面")
def switch_to_project(self):
self.click(*self.project_title).attach_screenshot_bytes()
@allure.step("滑动切换“项目”内容")
@StepTracer("页面滑动")
def slide_views(self):
with allure.step("向左滑动3次"):
with StepTracer("开始划了"):
for _ in range(3):
self.swipe("left")

View File

@@ -1,33 +0,0 @@
#!/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截图")

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

@@ -1,40 +0,0 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: test_settings
@date: 2026/1/16 15:56
@desc:
"""
import logging
import allure
from utils.decorators import step_trace
logger = logging.getLogger(__name__)
@allure.epic("中银国际移动端重构项目")
@allure.feature("登认证模块")
@step_trace("验证失败",)
def test_settings_page_display(driver):
"""
测试设置页面是否成功加载
"""
# 此时 driver 已经通过 fixture 完成了初始化
current_act = driver.current_activity
logger.info(f"捕获到当前 Activity: {current_act}")
assert ".app.main.launcher.LauncherActivity" in current_act
def test_wifi_entry_exists(driver):
"""
简单的元素查找示例
"""
# 这里的 driver 就是 appium.webdriver.Remote 实例
# 假设我们要查找“网络”相关的 ID
# el = driver.find_element(by='id', value='android:id/title')
assert driver.session_id is not None

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_api_demos @file: test_wan_android_home
@date: 2026/1/30 17:42 @date: 2026/1/30 17:42
@desc: @desc:
""" """
@@ -13,45 +13,46 @@ import logging
import os import os
import allure import allure
import pytest
from dotenv import load_dotenv
from page_objects.wan_android_home import HomePage
from page_objects.wan_android_sidebar import ViewsPage
load_dotenv() from page_objects.wan_android_home import HomePage
from page_objects.wan_android_project import ProjectPage
# 配置日志 # 配置日志
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@allure.epic("ApiDemos") @allure.epic("测试用例示例")
@allure.feature("登录认证模块") @allure.feature("登录模块")
class TestApiDemos: class TestWanAndroidHome:
@allure.story("常规登录场景") @allure.story("常规登录场景")
@allure.title("使用合法账号登录成功") @allure.title("使用合法账号登录成功")
@allure.severity(allure.severity_level.BLOCKER) @allure.severity(allure.severity_level.BLOCKER)
@allure.description(""" @allure.description("""
验证用户在正常网络环境下: 验证用户在正常网络环境下:
1. 处理初始化弹窗和广告 1. 处理初始化弹窗和广告
2. 选择证券登录类型 2. 输入正确账号密码后成功登录
3. 输入正确凭证后成功跳转至‘交易’首页
""") """)
@allure.link("https://docs.example.com/login_spec", name="登录业务说明文档") @allure.link("https://docs.example.com/login_spec", name="登录业务说明文档")
@allure.issue("BUG-1001", "已知偶发:部分机型广告Banner无法滑动") @allure.issue("BUG-1001", "已知偶发:我是一个bug")
def test_api_demos_success(self, driver): def test_api_demos_success(self, driver):
""" """
测试场景:使用正确的用户名和密码登录成功 测试场景:使用正确的用户名和密码登录成功
""" """
wan = HomePage(driver) home = HomePage(driver)
# 执行业务逻辑 # 执行业务逻辑
wan.click_open() home.click_open()
wan.login(os.getenv("USER_NAME"),os.getenv("PASS_WORD"))
home.login(os.getenv("USER_NAME"), os.getenv("PASS_WORD"))
# 断言部分使用 allure.step 包装,使其在报告中也是一个可读的步骤 # 断言部分使用 allure.step 包装,使其在报告中也是一个可读的步骤
with allure.step("最终校验:检查是否进入首页并显示‘交易’标题"): with allure.step("断言"):
# actual_text = api_demos.get_home_text() assert os.getenv("USER_NAME") == 'admintest123456'
# assert actual_text == "Text"
print("开发中。。。")
# 页面跳转 # 页面跳转
# wan.go_to(ViewsPage).screenshot_views() with allure.step("验证页面跳转"):
wan.delay(5) project = home.go_to(ProjectPage)
project.switch_to_project()
project.assert_text(*project.pro_table_title, expected_text='完整项目')
project.delay(5)

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: test_wan_android_project
@date: 2026/1/30 17:42
@desc:
"""
import logging
import allure
from page_objects.wan_android_project import ProjectPage
# 配置日志
logger = logging.getLogger(__name__)
@allure.epic("测试用例示例")
@allure.feature("项目模块")
class TestWanAndroidProject:
@allure.story("项目切换场景")
@allure.title("切换项目页面")
@allure.severity(allure.severity_level.NORMAL)
@allure.description("""
验证滑动切换项目页面展示:
1. XXXXX
2. XXXXXXXXXXX
""")
@allure.link("https://docs.example.com/login_spec", name="项目业务说明文档")
@allure.issue("BUG-1002", "已知偶发我是一个bug")
def test_project_slide_success(self, driver):
"""
测试场景:验证页面滑动
"""
project = ProjectPage(driver)
# 执行业务逻辑
project.switch_to_project()
project.slide_views()
# 断言部分使用 allure.step 包装,使其在报告中也是一个可读的步骤
with allure.step("断言"):
assert 1 == 1
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):
@@ -83,5 +84,6 @@ class TestFinderConverter:
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
@@ -29,17 +28,36 @@ indent_var = ContextVar("indent_level", default=0)
class StepTracer(ContextDecorator): class StepTracer(ContextDecorator):
""" """
既是装饰器也是上下文管理器 一个结合了上下文管理器和装饰器功能的追踪器。
职责:负责计时、日志格式化和异常记录
主要职责是记录一个代码块或函数的执行情况,包括:
- 开始和结束的日志,并根据上下文进行缩进,形成层级结构。
- 计算并记录执行耗时。
- 捕获并记录执行期间发生的异常。
可作为上下文管理器使用:
with StepTracer("处理数据"):
...
也可作为装饰器的一部分(通过 step_trace 工厂函数)。
""" """
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
self.start_t = None self.start_t = None
def __enter__(self): def __enter__(self):
"""
进入上下文,记录步骤开始,并增加日志缩进层级。
"""
# 1. 获取当前层级并计算前缀 # 1. 获取当前层级并计算前缀
level = indent_var.get() level = indent_var.get()
# 使用 " " (空格) 或 "│ " 作为缩进符号 # 使用 " " (空格) 或 "│ " 作为缩进符号
@@ -55,6 +73,10 @@ class StepTracer(ContextDecorator):
return self return self
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
"""
退出上下文,记录步骤结束(成功或失败)、耗时,并恢复日志缩进层级。
如果发生异常,会记录异常信息但不会抑制它,异常会继续向上传播。
"""
# 3. 恢复层级,层级 -1 # 3. 恢复层级,层级 -1
level = indent_var.get() - 1 level = indent_var.get() - 1
indent_var.set(level) indent_var.set(level)
@@ -83,7 +105,23 @@ class StepTracer(ContextDecorator):
def resolve_wait_method(func): def resolve_wait_method(func):
""" """
装饰器:将字符串形式的等待条件解析为可调用的 EC 对象 装饰器:将字符串形式的等待条件解析为可调用的 Expected Condition (EC) 对象
这个装饰器用于修饰那些接受一个 `method` 参数的方法(通常是等待方法)。
如果 `method` 是一个字符串,它会尝试将其解析为一个预定义的 `expected_conditions`。
字符串格式支持:
- "key": 直接映射到一个无参数的 EC。
- "key:arg1,arg2": 映射到一个需要参数的 EC参数以逗号分隔。
解析逻辑委托给 `core.custom_expected_conditions.get_condition` 函数。
如果解析失败,会记录错误并重新抛出异常。
Args:
func (Callable): 被装饰的函数。
Returns:
Callable: 包装后的函数。
""" """
@wraps(func) @wraps(func)
@@ -113,8 +151,21 @@ def resolve_wait_method(func):
def action_screenshot(func): def action_screenshot(func):
""" """
显式截图装饰器:在方法执行前(或后)立即触发 BasePage 的截图逻辑 装饰器:在被装饰方法成功执行后,自动触发截图
用于记录关键操作后的页面状态。
主要用于UI自动化测试中记录关键业务操作执行后的页面状态。
它会调用被装饰对象(通常是 Page Object的 `attach_screenshot_bytes` 方法。
注意:
- 截图操作在原方法成功返回后执行。
- 被装饰的实例 (`self`) 必须拥有 `attach_screenshot_bytes` 方法。
- 如果截图失败,会记录一个警告日志,但不会影响主流程。
Args:
func (Callable): 被装饰的函数。
Returns:
Callable: 包装后的函数。
""" """
@wraps(func) @wraps(func)
@@ -139,7 +190,21 @@ def action_screenshot(func):
def _format_params(func, *args, **kwargs): def _format_params(func, *args, **kwargs):
"""辅助函数:专门处理参数过滤和格式化""" """
辅助函数:格式化函数调用的参数,以便清晰地记录日志。
它会检查函数的签名,并执行以下操作:
1. 过滤掉实例方法或类方法中的 `self` 或 `cls` 参数。
2. 将位置参数和关键字参数格式化为一个可读的字符串。
Args:
func (Callable): 目标函数。
*args: 传递给函数的位置参数。
**kwargs: 传递给函数的关键字参数。
Returns:
str: 格式化后的参数字符串,例如 "arg1, kwarg='value'"
"""
sig = inspect.signature(func) sig = inspect.signature(func)
params = list(sig.parameters.values()) params = list(sig.parameters.values())
display_args = args[1:] if params and params[0].name in ('self', 'cls') else args display_args = args[1:] if params and params[0].name in ('self', 'cls') else args
@@ -153,10 +218,29 @@ def _format_params(func, *args, **kwargs):
def step_trace(step_desc="", source='wrapper'): def step_trace(step_desc="", source='wrapper'):
""" """
通用执行追踪装饰器 装饰器工厂:创建一个通用执行追踪装饰器
1. 智能识别并过滤 self/cls 参数
2. 记录入参、出参、耗时 此装饰器利用 `StepTracer` 上下文管理器来提供结构化的日志,记录
3. 异常自动捕获并记录 函数的调用、参数、执行耗时和任何发生的异常。
功能包括:
- 自动格式化并记录函数的输入参数(会智能过滤 `self` 和 `cls`)。
- 使用 `StepTracer` 生成带缩进的层级式日志,清晰展示调用栈。
- 记录每个被追踪步骤的开始、成功/失败状态以及执行耗时。
- 捕获并记录异常,但不会抑制异常,保证上层逻辑可以处理。
用法:
@step_trace("执行用户登录操作")
def login(username, password):
...
Args:
step_desc (str, optional): 对被装饰函数所执行操作的描述。
如果为空,日志中将只显示函数信息。
source (str, optional): 指定日志记录器的名称。默认为 'wrapper'
Returns:
Callable: 一个可以装饰函数的装饰器。
""" """
def decorator(func): def decorator(func):

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 源码。