前言
第 14 篇搭好了环境,用 2 条移动 Web 用例验证了 Appium 能正常工作。但那只是"能跑通",离一个可用的框架还差很远。
本篇搭建完整的原生 App 自动化框架。设计思路跟 Web UI 自动化一样(POM 模式),但页面对象层的操作完全不同------滑动、长按、处理权限弹窗、前后台切换,这些都是手机特有的。
知识点:为什么 App 自动化也用 POM 模式?
Web UI 自动化的 POM:
BasePage(封装 Playwright 操作)
→ LoginPage(登录页的元素和操作)
→ HomePage(首页的元素和操作)
→ TestCases(调用页面对象编写用例)
App 自动化的 POM:
BasePage(封装 Appium 操作 + 手机特有操作)
→ LoginPage(登录页的元素和操作)
→ HomePage(首页的元素和操作)
→ TestCases(调用页面对象编写用例)
两者的分层结构一样,区别在 BasePage 封装的内容不同。Web UI 用 Playwright 的 API,App 用 Appium 的 API。学过 Web UI 的 POM,App 的 POM 上手很快。
本篇九个部分:
- 框架设计思路
- 项目结构
- Driver Manager
- BasePage(原生 App 版)
- 公共工具层
- 页面对象层
- conftest.py
- 运行配置
- 框架对比总结
一、框架设计思路
1.1 跟 Web UI 框架的对比
| 层级 | Web UI 框架 | App 框架 | 区别 |
|---|---|---|---|
| 驱动层 | Playwright Browser | Appium WebDriver | API 不同 |
| 页面基类 | BasePage(click、fill、expect) | BasePage(tap、swipe、handle_dialog) | 操作不同 |
| 页面对象 | LoginPage、HomePage... | LoginPage、HomePage... | 一样 |
| 公共工具 | Logger、Assertions | Logger、Assertions、Screenshot | 多了截图 |
| 测试数据 | JSON/YAML | JSON/YAML | 一样 |
| 测试用例 | test_xxx.py | test_xxx.py | 一样 |
1.2 原生 App 的元素命名规范
MallLite 没有原生 App,但为了教学完整性,我们虚构一个"MallLite App",定义它的页面结构和元素 ID。
知识点:Android 的 resource-id 命名规范
格式:包名:id/资源名
例:com.malllite.app:id/btn_login
├── com.malllite.app → 包名(App 的唯一标识)
├── :id/ → 固定格式
└── btn_login → 资源名(开发自定义)
资源名的命名惯例:
前缀表示元素类型:
btn_ → Button(按钮)
et_ → EditText(输入框)
tv_ → TextView(文本)
iv_ → ImageView(图片)
rv_ → RecyclerView(列表)
cb_ → CheckBox(复选框)
fl_ → FrameLayout(容器)
例:
btn_login → 登录按钮
et_username → 用户名输入框
tv_product_name → 商品名文本
rv_products → 商品列表
二、项目结构
powershell
# 创建目录(在 PyCharm 终端中执行)
New-Item -ItemType Directory -Path "app\pages" -Force
New-Item -ItemType Directory -Path "app\common" -Force
New-Item -ItemType Directory -Path "app\test_cases" -Force
New-Item -ItemType Directory -Path "app\test_data" -Force
New-Item -ItemType Directory -Path "app\reports" -Force
New-Item -ItemType File -Path "app\pages\__init__.py" -Force
New-Item -ItemType File -Path "app\common\__init__.py" -Force
New-Item -ItemType File -Path "app\test_cases\__init__.py" -Force
app/
├── common/ ← 公共工具层
│ ├── __init__.py
│ ├── logger.py ← 日志
│ ├── assertions.py ← 断言
│ └── screenshot.py ← 截图工具
│
├── pages/ ← 页面对象层
│ ├── __init__.py
│ ├── base_page.py ← BasePage(原生 App 操作基类)
│ ├── login_page.py ← 登录页
│ ├── home_page.py ← 首页(商品列表、搜索)
│ ├── product_detail_page.py ← 商品详情页
│ └── cart_page.py ← 购物车页
│
├── test_cases/ ← 测试用例层
│ ├── __init__.py
│ ├── test_login.py ← 登录用例
│ ├── test_browse.py ← 浏览商品用例
│ └── test_cart.py ← 购物车用例
│
├── test_data/ ← 测试数据
│
├── conftest.py ← fixture(Driver 管理)
├── pytest.ini ← 运行配置
└── requirements.txt ← 依赖
知识点:为什么 App 框架多了一个 screenshot.py?
Web UI 测试失败时,Playwright 可以自动截图。App 测试失败时,Appium 不会自动截图,需要手动调用。把截图封装成工具,用例失败时自动截取当前屏幕,方便排查。
三、Driver Manager
3.1 为什么需要 Driver Manager
Appium 的 WebDriver 创建比 Playwright 复杂------需要配置一堆 Capabilities,还要考虑连接模拟器还是真机。Driver Manager 把这些配置集中管理。
知识点:Driver Manager 的作用(对比 Web UI)
Web UI:
playwright.chromium.launch(headless=False)
→ 一行代码就能启动浏览器,配置简单
App:
webdriver.Remote("http://localhost:4723", options=...)
→ 需要配置 platformName、automationName、app_package 等一堆参数
→ Driver Manager 把这些配置封装起来
3.2 实现
【新建文件】 app/common/driver_manager.py:
python
"""
Driver Manager - 管理 Appium WebDriver 的创建
集中管理 Desired Capabilities,避免每个 fixture 或用例重复配置。
知识点:为什么用类而不是函数?
因为不同场景需要不同的 Capabilities:
- 测试原生 App → 填 app_package + app_activity
- 测试移动 Web → 填 browser_name
- 测试真机 → 填 device_name
用类封装可以按场景提供不同的创建方法。
"""
from appium import webdriver
from appium.options.android import UiAutomator2Options
from common.logger import get_logger
logger = get_logger("driver_manager")
# ===== 虚构的 MallLite App 配置 =====
# 这些值来自 ADB 命令或开发同学提供的信息
# adb shell dumpsys activity activities | findstr mResumedActivity
APP_PACKAGE = "com.malllite.app"
APP_ACTIVITY = ".MainActivity"
# Appium Server 地址
APPIUM_SERVER = "http://localhost:4723"
class DriverManager:
"""
Appium WebDriver 管理器
提供三种创建方式:
1. create_app_driver() → 原生 App
2. create_web_driver() → 移动 Web
3. create_custom_driver() → 自定义 Capabilities
"""
@staticmethod
def create_app_driver():
"""
创建原生 App 的 WebDriver
配置说明:
platform_name = "Android" → 目标平台
automation_name = "UiAutomator2" → 自动化引擎
app_package = "com.malllite.app" → App 包名
app_activity = ".MainActivity" → 启动 Activity
no_reset = False → 重置 App 状态(每次测试干净环境)
auto_grant_permissions = True → 自动授予权限(跳过权限弹窗)
new_command_timeout = 300 → 无操作超时 5 分钟
"""
options = UiAutomator2Options()
options.platform_name = "Android"
options.automation_name = "UiAutomator2"
options.app_package = APP_PACKAGE
options.app_activity = APP_ACTIVITY
options.no_reset = False
options.auto_grant_permissions = True
options.new_command_timeout = 300
logger.info(f"创建原生 App Driver:{APP_PACKAGE}")
driver = webdriver.Remote(APPIUM_SERVER, options=options)
driver.implicitly_wait(10)
return driver
@staticmethod
def create_web_driver(url=None):
"""
创建移动 Web 的 WebDriver
参数:
url: 要打开的网页地址(None 则不自动打开)
"""
options = UiAutomator2Options()
options.platform_name = "Android"
options.automation_name = "UiAutomator2"
options.browser_name = "Chrome"
options.no_reset = True
options.new_command_timeout = 300
logger.info("创建移动 Web Driver(Chrome)")
driver = webdriver.Remote(APPIUM_SERVER, options=options)
driver.implicitly_wait(10)
if url:
driver.get(url)
return driver
@staticmethod
def create_custom_driver(capabilities_dict):
"""
自定义 Capabilities 创建 Driver
参数:
capabilities_dict: 字典格式的 Capabilities
用法:
driver = DriverManager.create_custom_driver({
"platformName": "Android",
"appium:appPackage": "com.other.app",
"appium:appActivity": ".SplashActivity",
})
"""
options = UiAutomator2Options()
for key, value in capabilities_dict.items():
options.set_capability(key, value)
logger.info(f"创建自定义 Driver:{capabilities_dict}")
driver = webdriver.Remote(APPIUM_SERVER, options=options)
driver.implicitly_wait(10)
return driver
四、BasePage(原生 App 版)
4.1 设计思路
BasePage 是所有页面对象的基类,封装原生 App 的通用操作。
知识点:Web UI 的 BasePage vs App 的 BasePage
Web UI BasePage(Playwright):
click(selector) → 点击元素
fill(selector, text) → 输入文本
visible(selector) → 判断是否可见
expect(locator) → Playwright 断言
wait_for_load_state() → 等待页面加载
App BasePage(Appium):
tap(resource_id) → 点击元素(手指触摸)
input(resource_id, text)→ 输入文本
swipe_up/swipe_down() → 滑动(手机特有)
long_press(resource_id) → 长按(手机特有)
handle_permission_dialog() → 处理权限弹窗(手机特有)
go_background(seconds) → 前后台切换(手机特有)
wait_for_element() → 等待元素出现
4.2 实现
【新建文件】 app/pages/base_page.py:
python
"""
BasePage - 原生 App 页面操作基类
所有页面对象(LoginPage、HomePage 等)继承此类
封装原生 App 的通用操作,子类只需定义元素和业务操作
知识点:为什么用 resource_id 而不是 locator 对象?
Web UI 中 Playwright 用 locator 对象(page.locator("#id"))
App 中 Appium 用 find_element(AppiumBy.ID, "xxx") 查找
这里封装成 resource_id 字符串,BasePage 负责查找
"""
import time
from appium.webdriver.common.appiumby import AppiumBy
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from common.logger import get_logger
logger = get_logger("base_page")
class BasePage:
"""
原生 App 页面操作基类
子类继承后,可以:
1. 直接调用 self.tap("com.app:id/btn") 这样的封装方法
2. 覆写方法做自定义扩展
"""
def __init__(self, driver):
"""
参数:
driver: Appium WebDriver 实例(由 conftest fixture 提供)
"""
self.driver = driver
# ========================================
# 元素查找
# ========================================
def find(self, resource_id):
"""
通过 resource-id 查找元素
参数:
resource_id: 完整的 resource-id(如 "com.malllite.app:id/btn_login")
或短名称(如 "btn_login",自动补全包名前缀)
返回:
WebElement 对象
"""
full_id = self._full_id(resource_id)
logger.debug(f"查找元素:{full_id}")
return self.driver.find_element(AppiumBy.ID, full_id)
def find_all(self, resource_id):
"""查找所有匹配的元素"""
full_id = self._full_id(resource_id)
return self.driver.find_elements(AppiumBy.ID, full_id)
def find_by_text(self, text):
"""通过 text 查找元素"""
return self.driver.find_element(
AppiumBy.ANDROID_UIAUTOMATOR,
f'new UiSelector().text("{text}")'
)
def find_by_content_desc(self, desc):
"""通过 content-desc 查找元素(无障碍描述)"""
return self.driver.find_element(AppiumBy.ACCESSIBILITY_ID, desc)
def exists(self, resource_id, timeout=3):
"""
判断元素是否存在
参数:
resource_id: 资源 ID
timeout: 等待超时(秒)
返回:
True/False
知识点:为什么封装 exists?
原生 App 的页面加载不像 Web 那样有明确的 load 事件。
有时需要判断某个元素是否已经出现(如权限弹窗),
存在就处理,不存在就跳过。
"""
try:
full_id = self._full_id(resource_id)
WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((AppiumBy.ID, full_id))
)
return True
except:
return False
def _full_id(self, resource_id):
"""
补全 resource-id
支持两种写法:
完整写法:"com.malllite.app:id/btn_login" → 直接使用
短写法: "btn_login" → 补全为 "com.malllite.app:id/btn_login"
"""
if ":" in resource_id:
return resource_id
return f"com.malllite.app:id/{resource_id}"
# ========================================
# 基本操作
# ========================================
def tap(self, resource_id):
"""
点击元素
知识点:tap vs click
Web 的 click 是鼠标点击。
App 的 click 底层会转换为手指的 tap(触摸)操作。
接口名一样(element.click()),底层实现不同。
"""
logger.info(f"点击:{resource_id}")
self.find(resource_id).click()
def tap_by_text(self, text):
"""通过文本点击元素"""
logger.info(f"点击文本:{text}")
self.find_by_text(text).click()
def input_text(self, resource_id, text):
"""
在输入框中输入文本
知识点:先清空再输入
原生 App 的输入框不像 Web 那样自动覆盖。
如果输入框中已有内容,send_keys 会追加而不是替换。
所以先 clear() 再 send_keys()。
"""
logger.info(f"输入:{resource_id} = {text}")
element = self.find(resource_id)
element.clear()
element.send_keys(text)
def get_text(self, resource_id):
"""获取元素的文本"""
text = self.find(resource_id).text
logger.debug(f"获取文本:{resource_id} = {text}")
return text
def get_text_by_index(self, resource_id, index):
"""获取列表中第 N 个元素的文本"""
elements = self.find_all(resource_id)
if index < len(elements):
return elements[index].text
return None
# ========================================
# 等待操作
# ========================================
def wait_for_element(self, resource_id, timeout=10):
"""
等待元素出现
知识点:显式等待 vs 隐式等待
隐式等待(driver.implicitly_wait):全局设置,对所有 find_element 生效
显式等待(WebDriverWait):针对特定元素,更精确
推荐用显式等待处理异步加载的元素
"""
full_id = self._full_id(resource_id)
logger.debug(f"等待元素:{full_id}(最多 {timeout}s)")
WebDriverWait(self.driver, timeout).until(
EC.presence_of_element_located((AppiumBy.ID, full_id))
)
def wait_for_element_clickable(self, resource_id, timeout=10):
"""等待元素可点击"""
full_id = self._full_id(resource_id)
WebDriverWait(self.driver, timeout).until(
EC.element_to_be_clickable((AppiumBy.ID, full_id))
)
# ========================================
# 手机特有操作:滑动
# ========================================
def swipe_up(self, duration=800):
"""
向上滑动(查看更多内容)
知识点:滑动是怎么实现的?
Appium 通过 execute_script 执行移动端特有的命令。
"mobile: scroll" 是 UiAutomator2 驱动提供的滑动命令。
direction 可选:up、down、left、right
为什么用 duration 参数?
duration 控制滑动速度。值越大滑动越慢,页面滚动越少。
通常 500-1000 比较合适。
"""
logger.info("向上滑动")
self.driver.execute_script("mobile: scroll", {"direction": "up"})
def swipe_down(self, duration=800):
"""向下滑动(回到顶部)"""
logger.info("向下滑动")
self.driver.execute_script("mobile: scroll", {"direction": "down"})
def swipe_left(self):
"""向左滑动(切换 Tab、翻页等)"""
logger.info("向左滑动")
self.driver.execute_script("mobile: swipe", {"direction": "left"})
def swipe_right(self):
"""向右滑动"""
logger.info("向右滑动")
self.driver.execute_script("mobile: swipe", {"direction": "right"})
def swipe_to_find(self, resource_id, max_swipes=5):
"""
滑动查找元素
知识点:为什么需要滑动查找?
原生 App 的列表(如商品列表)通常不是分页加载,
而是无限滚动(RecyclerView)。要找的元素可能在屏幕外,
需要不断向下滑动直到找到。
UiAutomator 提供了 scrollIntoView 方法,能自动滚动到目标元素。
"""
full_id = self._full_id(resource_id)
logger.info(f"滑动查找:{resource_id}(最多滑 {max_swipes} 次)")
for i in range(max_swipes):
if self.exists(resource_id, timeout=1):
logger.info(f"第 {i+1} 次滑动后找到元素")
return True
self.swipe_up()
logger.warning(f"滑动 {max_swipes} 次后仍未找到:{resource_id}")
return False
# ========================================
# 手机特有操作:长按和拖拽
# ========================================
def long_press(self, resource_id, duration=2000):
"""
长按元素
参数:
resource_id: 资源 ID
duration: 长按时间(毫秒),默认 2 秒
知识点:长按的使用场景
- 长按商品 → 弹出"加入收藏"、"分享"等菜单
- 长按文本 → 复制/粘贴
- 长按列表项 → 进入编辑模式(可删除、排序)
"""
from selenium.webdriver.common.action_chains import ActionChains
from appium.webdriver.common.touch_action import TouchAction
logger.info(f"长按:{resource_id}({duration}ms)")
element = self.find(resource_id)
actions = TouchAction(self.driver)
actions.long_press(element, duration=duration).release().perform()
# ========================================
# 手机特有操作:系统弹窗处理
# ========================================
def handle_permission_dialog(self, allow=True):
"""
处理系统权限弹窗
知识点:Android 权限弹窗的几种形式
1. 运行时权限(相机、定位、存储)→ 有"允许"/"拒绝"按钮
2. 安装未知来源 → 有"设置"按钮
3. 电池优化 → 有"允许"/"不允许"
如果在 Capabilities 中设了 auto_grant_permissions = True,
Appium 会自动处理大部分权限弹窗。
但有些特殊弹窗(如自定义的隐私协议弹窗)需要手动处理。
"""
if allow:
# 点击"允许"按钮
allow_ids = [
"com.android.permissioncontroller:id/permission_allow_button",
"com.android.packageinstaller:id/permission_allow_button",
]
for btn_id in allow_ids:
if self.exists(btn_id, timeout=2):
self.tap(btn_id)
logger.info("已点击允许按钮")
return True
else:
deny_ids = [
"com.android.permissioncontroller:id/permission_deny_button",
"com.android.packageinstaller:id/permission_deny_button",
]
for btn_id in deny_ids:
if self.exists(btn_id, timeout=2):
self.tap(btn_id)
logger.info("已点击拒绝按钮")
return True
logger.debug("没有检测到权限弹窗")
return False
# ========================================
# 手机特有操作:前后台切换
# ========================================
def go_background(self, seconds=5):
"""
将 App 切到后台
参数:
seconds: 后台停留时间(秒),-1 表示不自动回来
知识点:为什么要测前后台切换?
用户在使用 App 时可能被电话、微信通知打断。
App 切到后台再回来后:
- 不应该崩溃
- 不应该丢失数据(如购物车内容)
- 页面状态应该保持(如正在填的表单)
"""
logger.info(f"App 切到后台 {seconds} 秒")
self.driver.background_app(seconds)
def bring_to_foreground(self):
"""将 App 切回前台"""
logger.info("App 切回前台")
self.driver.activate_app("com.malllite.app")
# ========================================
# 辅助操作
# ========================================
def get_page_source(self):
"""
获取当前页面的 XML 源码
知识点:什么时候用 page_source?
当 Appium Inspector 定位不到某个元素时,
用 page_source 获取完整的 XML,在文本中搜索元素的属性。
这是一个很有用的调试手段。
"""
return self.driver.page_source
def back(self):
"""按返回键"""
logger.info("按返回键")
self.driver.back()
def hide_keyboard(self):
"""
隐藏软键盘
知识点:为什么要隐藏软键盘?
输入文本后,软键盘会弹出挡住其他元素。
点击其他元素前需要先收起键盘。
有些设备上 Appium 会自动处理,有些不会。
"""
try:
self.driver.hide_keyboard()
except:
pass # 键盘已经隐藏,忽略错误
五、公共工具层
5.1 Logger
【新建文件】 app/common/logger.py:
python
"""
日志模块
跟 Web UI 和接口自动化的日志模块设计一样
"""
import logging
import sys
from pathlib import Path
from datetime import datetime
LOG_DIR = Path(__file__).parent.parent / "reports" / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
_loggers = {}
def get_logger(name="app"):
if name in _loggers:
return _loggers[name]
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG)
if logger.handlers:
_loggers[name] = logger
return logger
fmt = logging.Formatter(
"%(asctime)s | %(levelname)-8s | %(name)-12s | %(message)s",
datefmt="%H:%M:%S"
)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(fmt)
logger.addHandler(console)
today = datetime.now().strftime("%Y-%m-%d")
fh = logging.FileHandler(LOG_DIR / f"app_{today}.log", encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(fmt)
logger.addHandler(fh)
_loggers[name] = logger
return logger
5.2 截图工具
【新建文件】 app/common/screenshot.py:
python
"""
截图工具
App 测试失败时自动截取当前屏幕,方便排查
知识点:为什么单独封装截图?
Web UI 测试中 Playwright 可以在 conftest 的 hook 中自动截图。
App 测试中 Appium 没有类似机制,需要手动调用 driver.get_screenshot_as_png()。
封装后可以在 conftest 的 hook 中统一调用。
"""
import os
from datetime import datetime
from pathlib import Path
from common.logger import get_logger
logger = get_logger("screenshot")
SCREENSHOT_DIR = Path(__file__).parent.parent / "reports" / "screenshots"
SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
def take_screenshot(driver, name=None):
"""
截取当前屏幕
参数:
driver: Appium WebDriver
name: 截图名称(None 自动生成时间戳名称)
返回:
截图文件路径
"""
if name is None:
name = datetime.now().strftime("%Y%m%d_%H%M%S")
filepath = SCREENSHOT_DIR / f"{name}.png"
try:
driver.save_screenshot(str(filepath))
logger.info(f"截图已保存:{filepath}")
return str(filepath)
except Exception as e:
logger.error(f"截图失败:{e}")
return None
5.3 断言工具
【新建文件】 app/common/assertions.py:
python
"""
App 自动化断言工具
跟接口自动化的断言不同:
接口断言:检查 JSON 响应(code、message、data)
App 断言:检查页面元素(文本、可见性、数量)
"""
from common.logger import get_logger
logger = get_logger("assertions")
class AppAssertions:
"""
App 页面断言
用法:
page = HomePage(driver)
a = AppAssertions(page)
a.assert_text_visible("iPhone 15 Pro")
a.assert_element_exists("btn_add_cart")
"""
def __init__(self, page):
"""
参数:
page: 页面对象(BasePage 或其子类的实例)
"""
self.page = page
self.driver = page.driver
def assert_text_visible(self, text, timeout=5):
"""断言页面中包含指定文本"""
try:
self.page.find_by_text(text)
logger.info(f" ✓ 文本可见:'{text}'")
return self
except Exception:
raise AssertionError(f"页面中未找到文本:'{text}'")
def assert_element_exists(self, resource_id, timeout=5):
"""断言元素存在"""
assert self.page.exists(resource_id, timeout), \
f"元素不存在:{resource_id}"
logger.info(f" ✓ 元素存在:{resource_id}")
return self
def assert_element_not_exists(self, resource_id, timeout=3):
"""断言元素不存在"""
assert not self.page.exists(resource_id, timeout), \
f"元素不应该存在:{resource_id}"
logger.info(f" ✓ 元素不存在:{resource_id}")
return self
def assert_element_text(self, resource_id, expected_text):
"""断言元素的文本内容"""
actual = self.page.get_text(resource_id)
assert actual == expected_text, \
f"文本不匹配:期望 '{expected_text}',实际 '{actual}'"
logger.info(f" ✓ 文本正确:{resource_id} = '{actual}'")
return self
def assert_element_text_contains(self, resource_id, expected_text):
"""断言元素文本包含指定内容"""
actual = self.page.get_text(resource_id)
assert expected_text in actual, \
f"文本不包含 '{expected_text}':实际 '{actual}'"
logger.info(f" ✓ 文本包含:{resource_id} 包含 '{expected_text}'")
return self
def assert_element_count(self, resource_id, expected_count):
"""断言元素数量"""
elements = self.page.find_all(resource_id)
actual = len(elements)
assert actual == expected_count, \
f"元素数量不匹配:期望 {expected_count},实际 {actual}"
logger.info(f" ✓ 数量正确:{resource_id} = {actual}")
return self
def assert_current_activity(self, expected_activity):
"""断言当前 Activity"""
actual = self.driver.current_activity
assert expected_activity in actual, \
f"Activity 不匹配:期望 '{expected_activity}',实际 '{actual}'"
logger.info(f" ✓ Activity 正确:{actual}")
return self
六、页面对象层
6.1 虚构的 MallLite App 元素定义
后续所有页面对象都基于这套元素 ID(虚构,不实际运行):
登录页:
et_username → 用户名输入框
et_password → 密码输入框
btn_login → 登录按钮
tv_error → 错误提示文本
首页:
tv_search → 搜索框
rv_products → 商品列表(RecyclerView)
tv_product_name → 商品名(列表项中的文本)
tv_product_price → 商品价格(列表项中的文本)
iv_product_image → 商品图片
bottom_nav → 底部导航栏
nav_home → 首页 Tab
nav_cart → 购物车 Tab
nav_mine → 我的 Tab
商品详情页:
tv_name → 商品名
tv_price → 价格
tv_stock → 库存
tv_description → 描述
btn_add_cart → 加入购物车按钮
btn_buy_now → 立即购买按钮
iv_image → 商品大图
购物车页:
rv_cart_items → 购物车列表
tv_item_name → 商品名
tv_item_price → 商品价格
tv_quantity → 数量
btn_increase → 增加数量按钮
btn_decrease → 减少数量按钮
btn_delete → 删除按钮
tv_total_price → 总价
btn_checkout → 结算按钮
cb_select_all → 全选复选框
tv_empty → 空购物车提示
6.2 登录页
【新建文件】 app/pages/login_page.py:
python
"""
登录页 - 页面对象
封装登录页面的元素和操作
知识点:页面对象的设计原则
1. 元素定义和操作分离 → 属性区定义元素,方法区定义操作
2. 操作返回业务结果 → login() 返回是否成功,不是返回 WebElement
3. 不在页面对象中写断言 → 断言留给测试用例
"""
from common.logger import get_logger
from pages.base_page import BasePage
logger = get_logger("login_page")
class LoginPage(BasePage):
"""
登录页
业务操作:
- 输入用户名和密码
- 点击登录
- 获取错误提示
"""
# ===== 元素定义 =====
ET_USERNAME = "et_username"
ET_PASSWORD = "et_password"
BTN_LOGIN = "btn_login"
TV_ERROR = "tv_error"
# ===== 业务操作 =====
def login(self, username, password):
"""
执行登录操作
步骤:
1. 输入用户名
2. 输入密码
3. 点击登录
4. 隐藏键盘(防止遮挡)
返回:
None(通过后续断言判断是否成功)
知识点:为什么先隐藏键盘?
输入密码后软键盘会弹出,挡住"登录"按钮。
虽然 Appium 通常能处理这种情况,
但隐藏键盘后再点击更稳定。
"""
logger.info(f"登录:{username}")
self.input_text(self.ET_USERNAME, username)
self.input_text(self.ET_PASSWORD, password)
self.hide_keyboard()
self.tap(self.BTN_LOGIN)
def get_error_message(self):
"""
获取错误提示文本
登录失败时页面会显示错误提示(如"密码错误")
"""
if self.exists(self.TV_ERROR, timeout=3):
return self.get_text(self.TV_ERROR)
return None
def is_error_displayed(self):
"""判断是否有错误提示"""
return self.exists(self.TV_ERROR, timeout=3)
def clear_form(self):
"""清空表单"""
self.find(self.ET_USERNAME).clear()
self.find(self.ET_PASSWORD).clear()
6.3 首页
【新建文件】 app/pages/home_page.py:
python
"""
首页 - 页面对象
封装首页(商品列表、搜索、导航)的操作
知识点:RecyclerView 是什么?
Android 中的 RecyclerView 是高性能列表组件(类似 HTML 的 <ul> + <li>)。
商品列表、订单列表通常都用 RecyclerView 实现。
列表中的每一项是一个 item,包含商品图片、名称、价格等。
"""
from common.logger import get_logger
from pages.base_page import BasePage
logger = get_logger("home_page")
class HomePage(BasePage):
"""
首页
业务操作:
- 搜索商品
- 浏览商品列表
- 点击商品进入详情
- 切换底部导航 Tab
"""
# ===== 元素定义 =====
TV_SEARCH = "tv_search"
RV_PRODUCTS = "rv_products"
TV_PRODUCT_NAME = "tv_product_name"
TV_PRODUCT_PRICE = "tv_product_price"
NAV_HOME = "nav_home"
NAV_CART = "nav_cart"
NAV_MINE = "nav_mine"
# ===== 业务操作 =====
def search(self, keyword):
"""
搜索商品
步骤:
1. 点击搜索框
2. 输入关键词
3. 隐藏键盘
4. 提交搜索
知识点:原生 App 的搜索流程
原生 App 的搜索通常不是输入后实时搜索,
而是点击搜索框 → 输入 → 按回车或点击搜索按钮。
这里用 send_keys("\n") 模拟按回车。
"""
logger.info(f"搜索:{keyword}")
self.tap(self.TV_SEARCH)
self.input_text(self.TV_SEARCH, keyword)
self.hide_keyboard()
# 按回车触发搜索
self.find(self.TV_SEARCH).send_keys("\n")
def get_product_names(self):
"""
获取所有商品名称
返回:
文本列表,如 ["iPhone 15 Pro", "MacBook Pro", ...]
"""
elements = self.find_all(self.TV_PRODUCT_NAME)
names = [el.text for el in elements]
logger.info(f"商品列表:{names}")
return names
def get_product_count(self):
"""获取当前屏幕上的商品数量"""
return len(self.find_all(self.TV_PRODUCT_NAME))
def click_product(self, index=0):
"""
点击第 N 个商品进入详情
参数:
index: 商品索引(从 0 开始)
知识点:为什么按索引而不是按名称?
按名称点击需要先遍历列表找到匹配项。
按索引更简单直接。
但索引可能因为页面加载顺序变化而改变,
所以更稳定的方式是用 swipe_to_find + tap_by_text。
"""
elements = self.find_all(self.TV_PRODUCT_NAME)
if index < len(elements):
product_name = elements[index].text
logger.info(f"点击商品:{product_name}")
elements[index].click()
else:
raise IndexError(f"商品索引 {index} 超出范围(共 {len(elements)} 个)")
def click_product_by_name(self, name):
"""
按名称点击商品
先滑动查找,找到后点击
"""
logger.info(f"查找并点击商品:{name}")
if self.swipe_to_find(self.TV_PRODUCT_NAME):
self.tap_by_text(name)
def go_to_cart(self):
"""切换到购物车 Tab"""
logger.info("切换到购物车")
self.tap(self.NAV_CART)
def go_to_mine(self):
"""切换到我的 Tab"""
logger.info("切换到我的")
self.tap(self.NAV_MINE)
def scroll_to_see_more(self):
"""向下滑动查看更多商品"""
self.swipe_up()
6.4 商品详情页
【新建文件】 app/pages/product_detail_page.py:
python
"""
商品详情页 - 页面对象
知识点:页面对象之间的跳转
测试用例中,从首页点击商品后会跳转到详情页。
这时用例需要创建详情页的页面对象来操作。
页面对象之间的关系由测试用例串联,不是页面对象自己管理。
"""
from common.logger import get_logger
from pages.base_page import BasePage
logger = get_logger("product_detail")
class ProductDetailPage(BasePage):
"""
商品详情页
业务操作:
- 查看商品信息
- 加入购物车
- 立即购买
"""
# ===== 元素定义 =====
TV_NAME = "tv_name"
TV_PRICE = "tv_price"
TV_STOCK = "tv_stock"
TV_DESCRIPTION = "tv_description"
BTN_ADD_CART = "btn_add_cart"
BTN_BUY_NOW = "btn_buy_now"
IV_IMAGE = "iv_image"
# ===== 业务操作 =====
def get_product_name(self):
"""获取商品名称"""
return self.get_text(self.TV_NAME)
def get_product_price(self):
"""获取商品价格(返回文本,如 '¥8999')"""
return self.get_text(self.TV_PRICE)
def get_product_stock(self):
"""获取库存"""
return self.get_text(self.TV_STOCK)
def add_to_cart(self):
"""
点击"加入购物车"
知识点:加入购物车后可能出现 Toast 提示
Toast 是 Android 的一种短暂提示(如"已加入购物车")。
Toast 会在几秒后自动消失。
Appium 可以通过 page_source 或特定方式捕获 Toast。
但 Toast 的定位不太稳定,这里先不处理。
"""
logger.info("加入购物车")
self.tap(self.BTN_ADD_CART)
def buy_now(self):
"""点击"立即购买" """
logger.info("立即购买")
self.tap(self.BTN_BUY_NOW)
def is_out_of_stock(self):
"""判断是否缺货"""
stock_text = self.get_product_stock()
return "0" in stock_text or "缺货" in stock_text
6.5 购物车页
【新建文件】 app/pages/cart_page.py:
python
"""
购物车页 - 页面对象
知识点:购物车是 App 测试中最复杂的页面之一
原因:
1. 列表项可交互(增删改查、勾选)
2. 数量变化会影响价格
3. 可能有滑动删除操作
4. 空购物车和有商品的页面结构不同
"""
from common.logger import get_logger
from pages.base_page import BasePage
logger = get_logger("cart_page")
class CartPage(BasePage):
"""
购物车页
业务操作:
- 查看购物车商品
- 修改数量(增加/减少)
- 删除商品
- 全选/取消全选
- 结算
"""
# ===== 元素定义 =====
RV_CART_ITEMS = "rv_cart_items"
TV_ITEM_NAME = "tv_item_name"
TV_ITEM_PRICE = "tv_item_price"
TV_QUANTITY = "tv_quantity"
BTN_INCREASE = "btn_increase"
BTN_DECREASE = "btn_decrease"
BTN_DELETE = "btn_delete"
TV_TOTAL_PRICE = "tv_total_price"
BTN_CHECKOUT = "btn_checkout"
CB_SELECT_ALL = "cb_select_all"
TV_EMPTY = "tv_empty"
# ===== 业务操作 =====
def get_item_count(self):
"""获取购物车中的商品种类数"""
return len(self.find_all(self.TV_ITEM_NAME))
def get_item_names(self):
"""获取所有商品名称"""
return [el.text for el in self.find_all(self.TV_ITEM_NAME)]
def get_total_price(self):
"""获取总金额文本"""
return self.get_text(self.TV_TOTAL_PRICE)
def is_empty(self):
"""购物车是否为空"""
return self.exists(self.TV_EMPTY, timeout=3)
def increase_quantity(self, index=0):
"""
增加第 N 个商品的数量
知识点:为什么用 index?
购物车中可能有多个商品,每个商品都有"增加"按钮。
用 index 指定操作哪一个。
更稳定的方式是先找到目标商品所在的行,再点击该行的按钮。
"""
buttons = self.find_all(self.BTN_INCREASE)
if index < len(buttons):
logger.info(f"增加第 {index+1} 个商品的数量")
buttons[index].click()
def decrease_quantity(self, index=0):
"""减少第 N 个商品的数量"""
buttons = self.find_all(self.BTN_DECREASE)
if index < len(buttons):
logger.info(f"减少第 {index+1} 个商品的数量")
buttons[index].click()
def get_quantity(self, index=0):
"""获取第 N 个商品的数量"""
quantities = self.find_all(self.TV_QUANTITY)
if index < len(quantities):
return quantities[index].text
return None
def delete_item(self, index=0):
"""
删除第 N 个商品
知识点:App 中的删除操作
原生 App 的删除通常有两种方式:
1. 点击删除按钮 → 弹出确认对话框 → 确认
2. 向左滑动列表项 → 出现删除按钮 → 点击
这里用方式 1,方式 2 需要用 swipe_left
"""
buttons = self.find_all(self.BTN_DELETE)
if index < len(buttons):
logger.info(f"删除第 {index+1} 个商品")
buttons[index].click()
def swipe_to_delete(self, index=0):
"""
滑动删除第 N 个商品
向左滑动列表项,露出删除按钮
"""
items = self.find_all(self.TV_ITEM_NAME)
if index < len(items):
logger.info(f"滑动删除第 {index+1} 个商品")
# 对该元素执行向左滑动
self.swipe_left()
def select_all(self):
"""全选"""
logger.info("全选购物车商品")
self.tap(self.CB_SELECT_ALL)
def checkout(self):
"""点击结算"""
logger.info("结算")
self.tap(self.BTN_CHECKOUT)
七、conftest.py
【新建文件】 app/conftest.py:
python
"""
App 自动化 - conftest.py
管理 Driver 的创建和销毁 + 失败自动截图
知识点:跟 Web UI 的 conftest 对比
Web UI:fixture 提供 page 对象(Playwright 的 Page)
App: fixture 提供 driver 对象(Appium 的 WebDriver)
两者的作用一样:给测试用例提供"操作设备"的入口。
"""
import pytest
from common.driver_manager import DriverManager
from common.screenshot import take_screenshot
from common.logger import get_logger
logger = get_logger("conftest")
@pytest.fixture
def driver():
"""
原生 App 的 WebDriver
每个用例创建一个新的 Driver 连接,用例结束后关闭。
"""
drv = DriverManager.create_app_driver()
yield drv
drv.quit()
@pytest.fixture
def web_driver():
"""
移动 Web 的 WebDriver
用于需要在浏览器中测试的场景
"""
drv = DriverManager.create_web_driver()
yield drv
drv.quit()
@pytest.fixture
def malllite_url():
"""MallLite 移动 Web URL"""
return "http://10.0.2.2:8000"
# ===== 页面对象 fixture =====
@pytest.fixture
def login_page(driver):
"""登录页"""
from pages.login_page import LoginPage
return LoginPage(driver)
@pytest.fixture
def home_page(driver):
"""首页"""
from pages.home_page import HomePage
return HomePage(driver)
@pytest.fixture
def product_detail_page(driver):
"""商品详情页"""
from pages.product_detail_page import ProductDetailPage
return ProductDetailPage(driver)
@pytest.fixture
def cart_page(driver):
"""购物车页"""
from pages.cart_page import CartPage
return CartPage(driver)
# ===== 失败自动截图 =====
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
def pytest_runtest_makereport(item, call):
"""
用例失败时自动截图
知识点:hookwrapper 的工作方式
pytest_runtest_makereport 在每个用例的每个阶段(setup/call/teardown)都会触发。
我们只关心 call 阶段(用例执行阶段)的失败。
失败时调用截图工具保存当前屏幕状态。
"""
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
# 获取 driver fixture
drv = item.funcargs.get("driver") or item.funcargs.get("web_driver")
if drv:
take_screenshot(drv, name=f"FAIL_{item.name}")
八、运行配置
8.1 pytest.ini
【新建文件】 app/pytest.ini:
ini
[pytest]
testpaths = test_cases
addopts = -v --tb=short --strict-markers
markers =
smoke: 冒烟测试
regression: 回归测试
login: 登录
browse: 浏览商品
cart: 购物车
p0: 最高优先级
p1: 高优先级
log_cli = true
log_cli_level = INFO
log_cli_format = %(asctime)s | %(levelname)-8s | %(name)-12s | %(message)s
log_cli_date_format = %H:%M:%S
8.2 requirements.txt
【新建文件】 app/requirements.txt:
Appium-Python-Client>=4.0.0
pytest>=8.0.0
九、框架对比总结
9.1 Web UI 框架 vs App 框架
| 维度 | Web UI 框架 | App 框架 |
|---|---|---|
| 驱动 | Playwright Browser | Appium WebDriver |
| 页面基类 | BasePage(click、fill、expect) | BasePage(tap、input、swipe、handle_dialog) |
| 元素定位 | CSS Selector、XPath、text | resource-id、content-desc、text、xpath |
| 等待策略 | expect(locator).to_be_visible() |
WebDriverWait + EC |
| 手势操作 | 无(鼠标滚轮) | 滑动、长按、拖拽、多指 |
| 系统干扰 | 无 | 权限弹窗、来电、通知 |
| 截图 | Playwright 自动截图 | 手动调用 save_screenshot() |
| 前后台 | 不涉及 | 需要测试前后台切换 |
| 并发 | 每个浏览器独立 | 每台设备独立(或多设备并行) |
9.2 项目结构对比
Web UI 项目: App 项目:
web_ui/ app/
├── pages/ ├── pages/
│ ├── base_page.py │ ├── base_page.py ← 封装 Appium + 手势
│ ├── login_page.py │ ├── login_page.py
│ ├── home_page.py │ ├── home_page.py
│ └── ... │ ├── product_detail_page.py
├── common/ │ └── cart_page.py
│ ├── logger.py ├── common/
│ └── assertions.py │ ├── logger.py
├── test_cases/ │ ├── assertions.py ← App 专用断言
│ ├── test_login.py │ ├── screenshot.py ← 新增
│ └── ... │ └── driver_manager.py ← 新增
├── conftest.py ├── test_cases/
└── pytest.ini │ └── ...
├── conftest.py
└── pytest.ini
9.3 知识迁移
如果你已经掌握了 Web UI 的 POM 模式,学 App 框架只需要关注"不同点":
相同的:POM 分层、fixture 管理、测试数据、断言思路、日志
不同的:BasePage 操作、元素定位方式、手势、系统弹窗、截图
十、今日成果
- 搭建了完整的原生 App 自动化框架(common / pages / test_cases 三层)
- 实现了 Driver Manager(支持原生 App、移动 Web、自定义三种模式)
- 实现了 BasePage(封装了 tap、swipe、long_press、handle_permission_dialog、go_background 等原生操作)
- 实现了公共工具层(Logger、Screenshot、AppAssertions)
- 实现了 4 个页面对象(LoginPage、HomePage、ProductDetailPage、CartPage)
- 实现了 conftest.py(Driver 管理 + 页面对象 fixture + 失败自动截图)
- 虚构了完整的 MallLite App 元素 ID 体系
- 对比了 Web UI 框架和 App 框架的异同
十一、下篇预告
16 - App 自动化实战
下一篇用本篇搭建的框架编写完整的测试用例:登录场景、搜索场景、购物车场景、异常场景(权限弹窗、网络断开、后台切换)、数据驱动、Allure 报告集成。
App 自动化篇进度:15/18。