【App 自动化】15 - App 自动化框架搭建

前言

第 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 上手很快。

本篇九个部分:

  1. 框架设计思路
  2. 项目结构
  3. Driver Manager
  4. BasePage(原生 App 版)
  5. 公共工具层
  6. 页面对象层
  7. conftest.py
  8. 运行配置
  9. 框架对比总结

一、框架设计思路

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。

相关推荐
XiaoZhenHua981 小时前
C#工业机器视觉软件架构设计:从能运行的Demo到能够稳定跑产线的软件
计算机视觉·系统架构·自动化
云栖梦泽1 小时前
网络设备驱动(3)
linux·运维·服务器·网络·嵌入式硬件
天远API2 小时前
零信任架构实战:基于天远公安三要素即时版构建自动化理赔合规网关
人工智能·python·架构·自动化
运维全栈笔记2 小时前
Ansible 自动化 Nginx 集群部署实战:动态 Inventory、滚动发布与 Role 工程化
nginx·自动化·ansible
云生信2 小时前
服务器上新 OmicOS,体验生信 AI 计算
运维·服务器·人工智能
DFT计算杂谈2 小时前
无图形界面服务器用 Codex 终端连接本地部署的 DeepSeek
运维·服务器·网络
跨境小彭2 小时前
Temu 广告投放实操:ROAS 底层逻辑与批量广告作业方案
大数据·人工智能·自动化·temu
吴佳浩 Alben3 小时前
走向 Memory OS:企业私有化 Agent 设计与实现
人工智能·深度学习·神经网络·语言模型·架构·自动化·ai编程
工业机器人视觉检测设备12 小时前
工序全流程自动化升级!云汇智能打造汽车天窗多机器人集成产线
机器人·自动化·汽车