先问一个绝大多数测试同学都经历过的问题:App 已经迭代了好几个版本,业务回归还靠手工一遍一遍点;加一个新需求,要先花半天理清楚会影响哪些旧页面;等到晚上发版,群里的测试负责人开始催“核心链路再回归一次”。这种场景如果每周重复一次,不是测试能力问题,而是工程方法问题。Python + Appium 的价值,就是给这种重复工作搭建一条可复用、可继续生长的自动化回归体系。
2026 年再谈 Appium,它显然不是“最年轻”“最智能”的方案,但它依然是企业级 APP 自动化测试绕不开的基础设施。原因并不复杂:Appium 支持 Android 与 iOS,兼容真实设备和模拟器,底层通过 WebDriver 协议与设备控件树交互,这个思路已经被大量企业测试框架验证过。本文选择“网易严选”作为被测业务载体,不是因为它特殊,而是它具备电商类 App 最典型的关键链路:启动、搜索、商品详情、加入购物车、购物车核对、个人中心。只要能把这个闭环跑通,把脚本组织成可维护的框架,迁移到团队自己的业务 App 上就会顺畅很多。
这篇文章会从一个相对真实的项目视角展开,而不是只贴命令。读完你会得到三样东西:第一,搞懂 Python + Appium 的底层运行原理,知道报错时该从哪里查;第二,完整搭出一套企业级可用的 APP UI 自动化项目结构,包括 Page Object、pytest、测试报告;第三,拿到一份“3 天落地计划”,用于快速验证自己业务的第一个自动化用例。
1. 这篇文章真正要解决的问题
很多人对 APP UI 自动化的第一反应是“录制回放”。录制回放看起来省事,也确实适合 Demo,但进入企业级项目后问题会出现:页面改版后脚本大面积失效、用例之间依赖登录态、执行不稳定、报告可读性差。真正让自动化在团队内产生价值的,不是录制工具,而是“谁在维护代码”和“用例怎么设计”。
Python + Appium 解决的核心问题可以拆成四个层面。
第一,跨平台与兼容层面。Appium 的底层设计是同一套 WebDriver 思路,Android 走 UiAutomator2,iOS 走 XCUITest。对测试团队来说,业务脚本里的 Page 层可以抽象出来,只把设备能力层做区分。即使公司只有 Android 测试需求,这种分层也能让以后接 iOS 时少返工。
第二,脚本成本与表达层面。Python 语言本身容易阅读理解,pytest 对小规模用例到大规模测试体系的支持都够用。Java 也能写 Appium,但 Python 的迭代效率更适合测试团队从 0 到 1 搭建时快速试错。这也是很多测试开发岗位把 Python 作为默认语言的原因。
第三,工程协同层面。企业级自动化不是一个人写脚本,而是多人维护用例。如果脚本没有分层,所有定位符散落在测试函数里,每次改版都要全局搜索替换,这种维护成本最终会让用例库变成坑。Appium 体系配合 Page Object 模式,可以把“页面元素”和“业务动作”封装成对象,测试函数只描述“用户要做什么”。
第四,可持续运行层面。Appium 启动 session、连接设备的机制是标准化的,可以接入本地模拟器、真机群控设备,也能接入云测平台。让测试用例在固定环境里定时执行,配合 HTML 报告和失败截图,才是企业级自动化的完整闭环。
这里也要给出一个明确判断:Python + Appium 最适合的业务场景,是“核心链路回归”和“兼容性冒烟测试”,不适合做所有功能的全量验证。UI 自动化脚本对页面结构和业务文案很敏感,如果团队想要的是接口级、数据层的稳定保障,应该优先做 API 自动化,而不是让 UI 自动化承担一切。
2. Python + Appium 自动化测试的核心原理
很多新手容易把 Appium 理解成“一个能控制手机的桌面软件”,这个比喻大方向没错,但容易忽略它的工程本质。Appium 由三部分协作:测试脚本、Appium Server、移动设备上的自动化引擎。测试脚本运行在自己电脑上,通过 HTTP 请求告诉 Appium Server 要做什么;Appium Server 把命令转换成对应平台能识别的指令;Android 端的 UiAutomator2 拿到指令后,直接在设备上执行点击、输入、滑动等动作。
可以用一个容易理解的类比:Appium Server 像遥控器的信号转发基站,测试脚本是遥控器按钮,手机里的 UiAutomator2 是接收器。真正完成“点击”的不是 Appium Server,而是设备端的自动化引擎。所以命令行里看到UiAutomator2相关日志,是设备端已经开始工作的信号。
这套机制里有一个容易被忽略的关键概念:Session,中文一般叫“会话”。每一次测试启动,Appium 都会创建一个 Session,相当于给“被测 App”建立一条专属连接。Session 创建时,脚本要传一批 Desired Capabilities,也就是自动化启动的“配置参数”。Appium Server 根据这些参数决定启动哪个平台的驱动、打开哪个 App、进入哪个页面。
核心参数一般包含下列几项:
| Capability | 作用 | 示例 |
|---|---|---|
| platformName | 设备平台 | Android / iOS |
| appium:deviceName | 设备名或 id | emulator-5554 |
| appium:appPackage | 被测 App 包名 | 实际要测的包名 |
| appium:appActivity | 被测 App 启动 Activity | 具体入口 |
| appium:automationName | 自动化引擎 | UiAutomator2 |
| appium:noReset | 是否不重置应用数据 | true / false |
| appium:newCommandTimeout | 命令超时时间 | 180 |
Session 建立后,测试脚本的每一个 API 调用,比如element.click()、element.send_keys(),实际上都会走一遍“客户端 -> Appium Server -> 设备端 -> 控件树”的往返。这也是 UI 自动化天生比接口自动化慢的本质原因。慢不是问题,问题是脚本不能过度依赖固定 sleep,否则执行一次要白白多等几十秒。
在元素定位上,Appium 支持多种方式。常见的有id、class name、xpath、accessibility id,以及 Android 平台特有的 UiAutomator 表达式。实际项目中,UI 组件越稳定,脚本越稳定。resource-id 通常比 text 稳定,但国内 App 改版频率高,text 和 content-desc 可能会有中文场景的限制,所以更推荐的做法是把定位策略封装成一层。
关于等待,常见的错误是不加等待或乱加固定时间。Appium 底层处理控件查找时,如果元素还没出现在界面上,findElement 会立刻返回找不到。企业级脚本必须使用显式等待,也就是“轮询控件直到超时或成功”,而不是写死time.sleep(5)。这既能减少误报,也能提高运行速度。
3. 企业级 APP 自动化测试环境搭建
环境搭建是很多学习者第一天最容易卡住的地方。经常出现的状态是:教程里的截图环境从安装 Android Studio 开始,最后跑起来时又冒出几十个报错。为了避免这种混乱,环境准备应该遵循“先装运行时,再装工具,最后验证设备链路”的顺序。
3.1 需要准备的工具清单
以 Android 方向为例,建议准备以下工具:
| 工具 | 用途 | 安装优先级 |
|---|---|---|
| Python 3.10+ | 编写自动化脚本 | 必须 |
| Appium Server | 提供自动化服务端 | 必须 |
| Android SDK Platform Tools | adb、uiautomator 支持 | 必须 |
| Java JDK | Android 工具链依赖 | 按系统需要 |
| Appium Inspector | 页面控件树查看器 | 强烈建议 |
| Android 模拟器或真机 | 被测设备 | 必须 |
Node.js 也需要安装,因为 Appium 2.x 通过 npm 分发和启动。安装完 Node.js 后,Appium Server 可以直接从 npm 安装。下面是完整的安装命令示例。
# 安装 Appium 服务器 npm install -g appium # 查看 Appium 版本 appium --version # 安装 Android 平台驱动 appium driver install uiautomator2 # 查看已安装的驱动 appium driver listPython 侧主要安装 Appium 的 Python Client,以及后面要用到的 pytest 和 pytest-html 报告库。
python -m pip install --upgrade pip python -m pip install Appium-Python-Client pytest pytest-html如果下载慢,可以临时切换国内镜像源,这里以清华源为例:
python -m pip install Appium-Python-Client pytest pytest-html -i https://pypi.tuna.tsinghua.edu.cn/simple3.2 环境变量配置
Android SDK 配置是否正确,是很多新手跑不起来 adb 的常见原因。无论是 Windows 还是 macOS,核心都是让命令行能识别adb。Android Studio 安装后,SDK 默认路径通常是:
Windows 系统检查C:\Users\你的用户名\AppData\Local\Android\Sdk,macOS 系统检查~/Library/Android/sdk。
# macOS / Linux 临时配置,写入 ~/.zshrc 或 ~/.bashrc 更稳妥 export ANDROID_HOME=$HOME/Library/Android/sdk export PATH=$PATH:$ANDROID_HOME/platform-tools export PATH=$PATH:$ANDROID_HOME/emulatorWindows 上可以直接在环境变量界面新增ANDROID_HOME,把值设为 SDK 路径,并把platform-tools目录追加到Path。
验证环境是否就绪的关键命令:
# 查看 Android 设备列表 adb devices # 查看 Appium 是否安装成功 appium --version如果adb devices能看到设备,并且状态是device,说明设备链路已经打通。如果状态是offline或unauthorized,先检查设备是否解锁,是否点击了允许 USB 调试的弹窗。状态正常后再启动 Appium Server。
# 默认 4723 端口启动 appium看到类似Appium REST http interface listener started的日志,并且端口号是 4723,表示服务已经准备好接受请求。此时不要急着写用例,先用 Appium Inspector 连接设备,查看网易严选真实的页面控件结构。
3.3 获取被测 App 的包名与启动页面
每个 Android App 都有包名和启动 Activity。如果使用内置的真实包名会不准确,因为不同版本开发者可能调整入口页面。稳妥的方式是用 adb 现查。
在设备上打开网易严选 App,然后在命令行执行:
adb shell dumpsys window | grep mCurrentFocus输出结果中,斜杠前面是包名,斜杠后面是当前 Activity。比如输出格式类似:
mCurrentFocus=Window{xxx com.example.shop/com.example.shop.activity.MainActivity}这里com.example.shop只是示例。实际操作时,要把它替换成你从 dumpsys 命令里看到的真实包名和 Activity。不要把网上复制来的包名直接写死在代码里,尤其是企业内测版本,签名环境不同会直接影响启动。
4. 被测应用分析与用例设计
网易严选这个业务对象,本质上是一个标准电商 App。电商 App 的 UI 自动化用例设计有一条通用思路:先识别核心转化链路,再控制用例数量,然后逐层拆分动作。
4.1 核心业务链路
从用户视角看,严选的日常高频路径可以抽象为:
首页 -> 搜索栏 -> 搜索结果 -> 商品详情 -> 加入购物车 -> 购物车页 -> 结账入口
这条链路覆盖了搜索、列表、详情、购物袋、结算页等核心场景,是任何一次版本迭代都不应该回归出问题的路径。对于 UI 自动化来说,第一次落地不建议做一长串完整的登录支付流程,因为支付环节涉及真实资金与环境依赖,稳定性很差。企业实践里一般把自动化边界控制在“提交订单前”,真正的支付验证交给沙箱环境或配置了测试支付的专用设备完成。
从这条链路可以拆出下面的测试用例表:
| 用例编号 | 用例名称 | 核心操作 | 预期结果 |
|---|---|---|---|
| TC-001 | App 启动与首页冒烟 | 启动 App,等待首页加载 | 底部导航出现“首页”“购物车”“我的” |
| TC-002 | 首页搜索商品 | 点击搜索入口,输入关键词,提交搜索 | 搜索列表展示相关商品 |
| TC-003 | 搜索列表进入商品详情 | 点击搜索结果列表中的第一个商品 | 页面展示商品详情信息 |
| TC-004 | 商品详情加入购物车 | 点击加入购物袋/购物车按钮 | 出现加入成功提示 |
| TC-005 | 购物车核对 | 从首页切换到购物车 Tab | 购物车内存在刚加入的商品 |
| TC-006 | 我的页面边界 | 点击底部“我的” Tab | 展示个人中心入口或登录提示 |
第一次做自动化,建议把 TC-001 到 TC-005 定义为 P0 级用例。TC-006 可以放到下一期,因为它往往与登录态强相关。
4.2 页面对象模式设计
企业级 Appium 项目很少把元素定位直接写在测试函数中。更合理的做法是引入 Page Object 模式:每个页面对应一个类,页面里的元素定位和操作方法都封装在类里,测试函数只表达业务动作。
比如首页有“搜索入口”和“点击搜索并输入关键词”两个行为,那么 HomePage 类就负责封装这些行为。ProductPage 负责商品详情页的操作。这样设计之后,如果商品详情页的“加入购物袋”按钮文案改了,只需要修改 ProductPage,不需要一个用例一个用例地改,维护成本会低很多。
项目结构可以这样设计:
app_demo/ ├── pages/ │ ├── __init__.py │ ├── base_page.py │ ├── home_page.py │ └── product_page.py ├── tests/ │ ├── __init__.py │ ├── conftest.py │ └── test_yanxuan_flow.py ├── report/ └── requirements.txt这个结构看起来很简单,但已经具备向大型项目演进的基础。后面可以继续增加 config、data、utils、logger 等模块,但骨架职责是清晰的。
4.3 定位符的工程管理
国内 App 的页面结构有两个特点:第一,Android 原生控件与自绘控件混用;第二,不同版本之间 resource-id 很可能不一致。所以定位符不应该裸写在测试用例里,建议统一放在 config 文件或页面类顶部。
定位符的稳定性优先级可以参考:
| 定位方式 | 稳定性 | 说明 |
|---|---|---|
| resource-id | 高 | 尽量多用 |
| content-desc | 中高 | 无障碍语义稳定时可用 |
| text | 中 | 文案改版会挂 |
| xpath 绝对路径 | 低 | 不要用于主流程 |
| UIAutomator 表达式 | 中高 | 适合复合条件 |
5. 完整代码实现:从启动到加购全流程
下面代码的项目根目录假设为app_demo,所有模块从根目录调用。代码中的定位符属于演示通用写法,正式接入时,请先在 Appium Inspector 中查看当前版本网易严选的真实控件属性,再替换成自己的值。
5.1 设备与 App 启动配置
conftest.py 是 pytest 的全局固定装置文件,负责创建和释放 Appium Session。
# 文件路径:app_demo/tests/conftest.py import os import sys from pathlib import Path import pytest from appium import webdriver # 把项目根目录加入 sys.path,便于 tests 中的用例 import pages ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) @pytest.fixture(scope="session") def driver(): """创建 Appium 连接,测试结束时关闭驱动。""" caps = { "platformName": "Android", "appium:automationName": "UiAutomator2", # 真机或模拟器设备 id,使用 adb devices 查看 "appium:deviceName": os.getenv("ANDROID_DEVICE", "emulator-5554"), # 请使用 adb shell dumpsys window | grep mCurrentFocus 查到的真实包名 "appium:appPackage": os.getenv("APP_PACKAGE", "com.example.shop"), "appium:appActivity": os.getenv("APP_ACTIVITY", ".activity.MainActivity"), # 输入中文时需要开启这两个配置 "appium:unicodeKeyboard": True, "appium:resetKeyboard": True, # 每次启动是否保留 App 之前的数据 "appium:noReset": False, "appium:newCommandTimeout": 180, } # Appium Server 默认在 4723 端口监听 driver = webdriver.Remote( "http://127.0.0.1:4723/wd/hub", caps ) driver.implicitly_wait(10) yield driver driver.quit()这段代码里有几个细节需要注意。scope="session"表示整个测试会话只创建一个 Appium 连接,所有用例共享同一个 driver,这样执行速度更快。但如果用例之间相互影响登录态,可能需要改成 function 级别,并在每个用例前通过appium:noReset: True配合“回到首页”的动作来复位状态。
unicodeKeyboard和resetKeyboard这两个配置在输入中文关键字时非常关键。如果不开启,很多模拟器上send_keys会丢失字符或无法输入中文。
5.2 基础页面类
基础页面类封装通用等待和点击行为,后续所有页面类都继承它。
# 文件路径:app_demo/pages/base_page.py from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.support.ui import WebDriverWait class BasePage: def __init__(self, driver): self.driver = driver def find_android(self, ui_selector, timeout=10): """按 UiSelector 表达式查找元素,并做显式等待。""" locator = (AppiumBy.ANDROID_UIAUTOMATOR, f"new UiSelector().{ui_selector}") return WebDriverWait(self.driver, timeout).until( lambda d: d.find_element(*locator) ) def find_by_text(self, text, timeout=10): """按 TextView 文案定位。""" return self.find_android(f'text("{text}")', timeout) def click_by_text(self, text, timeout=10): """点击可见文本。""" self.find_by_text(text, timeout).click() def click_android(self, ui_selector, timeout=10): """点击 UiSelector 表达式匹配的元素。""" self.find_android(ui_selector, timeout).click() def is_text_visible(self, text, timeout=5): """判断文本是否在预期时间内出现。""" try: self.find_by_text(text, timeout) return True except Exception: return False这里使用WebDriverWait做显式等待,如果元素在 10 秒内出现,就立即返回;如果一直不出现,等到超时后抛出异常。相比time.sleep,这种方式既能保证稳定的运行速度,又能减少偶发失败。
5.3 首页与商品页
首页类负责搜索动作,商品列表页负责打开第一个搜索结果,商品详情页负责加入购物车并跳转。
# 文件路径:app_demo/pages/home_page.py from time import sleep from appium.webdriver.common.appiumby import AppiumBy from pages.base_page import BasePage class HomePage(BasePage): def search(self, keyword): """从首页进入搜索页并输入关键词,执行搜索。""" # 点击首页搜索入口 self.click_by_text("搜索") # 进入搜索页后,输入框通常是页面第一个 EditText sleep(1) edit_box = self.driver.find_element( AppiumBy.ANDROID_UIAUTOMATOR, 'new UiSelector().className("android.widget.EditText")' ) edit_box.send_keys(keyword) # 模拟键盘上的“搜索”按键 self.driver.press_keycode(66) # 返回搜索结果页,由外层传入的 Page 继续处理 return ProductListPage(self.driver)为了防止模块循环引用,建议把页面类之间的 import 放在方法内部,或者单独维护一个页面入口文件。业务逻辑上,搜索词建议从外部参数读入,而不是写死在类里,这样便于同一页面扩展不同搜索词用例。
# 文件路径:app_demo/pages/product_page.py from time import sleep from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.support.ui import WebDriverWait from pages.base_page import BasePage class ProductListPage(BasePage): def open_first_product_by_keyword(self, keyword): """搜索列表中点击第一个包含关键词结果的商品。""" # 等待搜索结果的商品标题出现 WebDriverWait(self.driver, 15).until( lambda d: len( d.find_elements( AppiumBy.ANDROID_UIAUTOMATOR, f'new UiSelector().textContains("{keyword}")' ) ) > 0 ) # 点击第一个匹配结果的父级容器,避免只点到标题文字 self.click_android(f'textContains("{keyword}")') return ProductDetailPage(self.driver) class ProductDetailPage(BasePage): def add_to_cart(self): """加入购物袋/购物车,按文案包含匹配以提高容错率。""" self.click_android('textContains("加入购物")', timeout=15) sleep(1) def go_to_cart(self): """从详情页返回,再进入底部购物车 Tab。""" self.driver.press_keycode(4) # Android Back sleep(1) self.click_by_text("购物车")5.4 测试用例
全流程用例按“启动首页 -> 搜索 -> 打开商品 -> 加购 -> 购物车验证”的顺序执行。
# 文件路径:app_demo/tests/test_yanxuan_flow.py from pages.home_page import HomePage from pages.product_page import ProductDetailPage, ProductListPage def test_app_home_smoke(driver): """TC-001 App 启动后应展示首页底部导航。""" home_page = HomePage(driver) assert home_page.is_text_visible("首页") assert home_page.is_text_visible("购物车") def test_search_to_cart_flow(driver): """ TC-002 到 TC-005: 搜索指定商品 -> 进入商品详情 -> 加入购物车 -> 购物车中可见商品。 这里使用独立关键词执行,避免依赖上一个用例的页面状态。 """ keyword = "旅行箱" home_page = HomePage(driver) product_list_page = home_page.search(keyword) product_detail_page = product_list_page.open_first_product_by_keyword(keyword) product_detail_page.add_to_cart() # 进入购物车并校验商品存在 cart_page = ProductDetailPage(driver) cart_page.go_to_cart() # 购物车页面检查:文案匹配即可视为加购成功。 # 实际项目中可以在购物车里查找具体商品标题或数量控件。 assert cart_page.is_text_visible(keyword), "购物车没有出现目标商品,加购流程失败"这段代码的分层节奏可以看出 Page Object 的价值:测试函数里没有出现任何 Appium 底层 API,也没有出现定位符。如果页面结构变化,只需要修改对应 Page 类。测试函数读起来像用例描述,后续任何人接手都容易理解。
5.5 用例的依赖管理与数据隔离
上面的代码虽然写成了两个用例,但实际运行时可能会遇到一个问题:第二个用例test_search_to_cart_flow依赖 App 启动后处于首页。如果先执行 TC-001 后,首页还停留在首页,那么没有问题。但如果未来增加更多用例,页面状态会被相互污染。
更好的做法是为每个用例准备独立的启动条件。比如每个测试开始前都回到首页,必要时使用driver.reset(),或者在 conftest 里加入一个自动化的前置步骤:每次测试前先执行adb shell am force-stop再重新启动 App。这样虽然会损失