1. 为什么我最终选择了 Appium 做移动端自动化
移动端自动化测试这个领域,工具链换了一茬又一茬。早些年大家用 Monkey 跑随机事件,后来 Robotium 在 Android 圈子里火了一阵,再后来 UI Automator 出来,iOS 那边则有 UIAutomation 和后来的 XCUITest。但真正让我把 Appium 作为主力方案定下来的,是三个很现实的原因:跨平台、不挑语言、以及不用碰被测应用的源码。
跨平台这件事,听起来像是宣传话术,但实际用起来差别很大。我手上同时有 Android 和 iOS 两条业务线,如果各维护一套测试脚本,人力成本直接翻倍。Appium 基于 WebDriver 协议做了一层抽象,Android 走 UiAutomator2 或 Espresso 驱动,iOS 走 XCUITest 驱动,对上层脚本来说,大部分 API 是统一的。这意味着我写一套 Page Object,两端能复用七八成,剩下的差异用平台判断包一下就行。
不挑语言这点也很关键。团队里有人写 Java,有人写 Python,还有人习惯 JavaScript。Appium 的客户端库覆盖了主流语言,我用 Python 写脚本,同事用 Java 写,大家共享同一套 Appium Server,互不干扰。这种灵活性在团队协作里太重要了,你不可能要求所有人都换语言。
至于不碰源码,这是 Appium 的架构决定的。它通过系统层面的自动化框架去驱动应用,不需要你在业务代码里埋点或者引入测试 SDK。对于已经上线的、或者第三方提供的应用,这一点几乎是刚需。我做过一个项目,被测应用是外部团队交付的,源码根本拿不到,Appium 直接黑盒操作,省了无数扯皮。
注意:Appium 的跨平台是“协议层统一”,不是“脚本完全不用改”。两端控件定位方式、系统弹窗处理、权限模型都有差异,别指望一套脚本原封不动跑两端。
适合谁来学这个?我的判断是:有一定编程基础(Python 或 Java 至少会一门)、了解基本的测试概念、手上有真实的移动端项目需要回归。如果你只是想跑个 Demo 玩玩,Appium 的环境搭建成本会让你觉得不值。但如果你面对的是每周都要回归的核心业务流程,那这套东西的投入产出比非常高。
2. 环境搭建:那些文档里不会写的坑
2.1 核心组件与版本匹配逻辑
Appium 的环境搭建是劝退新人的第一道坎。不是因为它难,而是因为组件之间的版本依赖太敏感。我先把核心组件列清楚:
| 组件 | 作用 | 常见坑点 |
|---|---|---|
| Appium Server | 核心服务,接收脚本指令并转发 | 版本与驱动不匹配 |
| Appium Inspector | 元素定位工具 | 与 Server 版本需对应 |
| Node.js | Appium Server 的运行环境 | 版本过低导致安装失败 |
| JDK | Android 自动化依赖 | 版本过高可能不兼容 |
| Android SDK | 提供 adb、uiautomator 等工具 | 环境变量配置遗漏 |
| Xcode | iOS 自动化依赖 | 仅 macOS 可用 |
我踩过最深的坑是 Node.js 版本。早期 Appium 对 Node 版本要求不严,后来 Appium 2.x 出来之后,驱动变成了独立安装的插件模式,Node 版本太低直接装不上。我的建议是直接用 Node 18 或 20 的 LTS 版本,别追最新,也别用太老的。
JDK 这块,Android 自动化用 JDK 8 或 11 都比较稳。我试过 JDK 17,某些老版本的 UiAutomator2 驱动会报奇怪的反射错误。如果你不是非要新特性,JDK 11 是甜点版本。
2.2 一步步把环境跑通
先说 Android 侧。装完 JDK 和 Android SDK 之后,环境变量必须配全:
export ANDROID_HOME=/path/to/android-sdk export PATH=$PATH:$ANDROID_HOME/platform-tools export PATH=$PATH:$ANDROID_HOME/tools export PATH=$PATH:$ANDROID_HOME/tools/binplatform-tools里有 adb,这是后面一切操作的基础。配完之后终端敲adb devices,能列出设备就说明通了。如果列表是空的,检查手机是否开了 USB 调试,以及数据线是不是只能充电的那种。
Appium Server 的安装,Appium 2.x 之后推荐用:
npm install -g appium appium driver install uiautomator2 appium driver install xcuitest注意appium driver install这一步,Appium 2.x 把驱动拆成了独立包,不装驱动的话启动会话会直接报错。我第一次升级到 2.x 的时候没装驱动,排查了半天才发现问题。
启动 Server 就是一行命令:
appium默认监听 4723 端口。如果你想改端口或者开日志,可以加参数:
appium --port 4725 --log-level debug--log-level debug在排查问题时非常有用,能看到每一条 WebDriver 指令的收发内容。
2.3 客户端库与第一个会话
Python 侧装客户端库:
pip install Appium-Python-Client然后写一个最小的启动脚本:
from appium import webdriver from appium.options.android import UiAutomator2Options options = UiAutomator2Options() options.platform_name = "Android" options.device_name = "your_device_name" options.app_package = "com.example.app" options.app_activity = ".MainActivity" options.automation_name = "UiAutomator2" driver = webdriver.Remote("http://127.0.0.1:4723", options=options) driver.quit()device_name用adb devices里显示的设备名。app_package和app_activity可以通过adb shell dumpsys window | grep mCurrentFocus拿到当前前台应用的包名和 Activity。
提示:第一次跑会话时,Appium 会在设备上安装两个辅助应用(Appium Settings 和 UiAutomator2 Server)。如果安装失败,多半是设备存储权限或者安装来源限制的问题,手动去设置里放开即可。
3. 元素定位:自动化测试的命门
3.1 定位方式的优先级与取舍
元素定位是 Appium 脚本里最花时间的部分。定位方式选得好,脚本稳定;选得不好,三天两头挂。我把常用定位方式按稳定性排个序:
- Accessibility ID:对应 Android 的
content-desc和 iOS 的accessibilityIdentifier。这是最稳的,因为它不随 UI 结构变化。 - ID:Android 的
resource-id,iOS 的name。也比较稳,但要注意有些动态生成的 ID 会带随机后缀。 - XPath:万能但脆弱。UI 层级一变就失效,而且执行速度慢。
- Class Name:通常一个页面有多个同类控件,单独用没法唯一定位。
- 坐标点击:最不推荐,屏幕分辨率一变就废。
我的原则是:能用 Accessibility ID 就不用 ID,能用 ID 就不用 XPath。XPath 只在实在没有其他属性可用时才上,而且要尽量写短,别写那种从根节点一路下来的长路径。
3.2 用 Inspector 高效定位
Appium Inspector 是官方提供的元素查看工具,能实时看到页面结构和每个控件的属性。启动 Inspector 后,填入和脚本里一样的 capabilities,点 Start Session 就能连上设备。
连上之后,点击页面上的任意元素,右侧会显示它的所有属性。我通常直接看accessibility id和resource-id这两个字段,有就直接用。
有个小技巧:Inspector 里可以录制操作并生成脚本代码,但生成的代码质量一般,我一般只用来参考定位表达式,操作逻辑还是自己写。
3.3 显式等待:别再用 sleep 了
新手最容易犯的错就是到处写time.sleep(3)。这种硬等待的问题是:网络快的时候浪费三秒,网络慢的时候三秒不够照样挂。正确做法是显式等待:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from appium.webdriver.common.appiumby import AppiumBy wait = WebDriverWait(driver, 10) element = wait.until( EC.presence_of_element_located((AppiumBy.ACCESSIBILITY_ID, "登录按钮")) ) element.click()WebDriverWait会每隔 0.5 秒轮询一次,直到元素出现或者超时。10 秒是超时上限,元素提前出现就提前返回,不会浪费时间。
注意:
presence_of_element_located只保证元素存在于 DOM 里,不保证可点击。如果元素被遮挡或者还没渲染完,要用element_to_be_clickable。
4. 测试框架设计:从脚本到工程
4.1 Page Object 模式的落地
单个脚本能跑通不代表能维护。当用例数量涨到几十上百条,如果每个脚本都直接写定位表达式,改一个控件属性就要改几十个文件。Page Object 模式就是来解决这个问题的。
核心思路是:每个页面封装成一个类,页面上的元素作为类的属性,操作作为类的方法。测试用例只调用方法,不直接碰定位表达式。
class LoginPage: def __init__(self, driver): self.driver = driver self.username_input = (AppiumBy.ID, "com.example.app:id/username") self.password_input = (AppiumBy.ID, "com.example.app:id/password") self.login_button = (AppiumBy.ID, "com.example.app:id/login_btn") def enter_username(self, username): wait = WebDriverWait(self.driver, 10) wait.until(EC.presence_of_element_located(self.username_input)).send_keys(username) def enter_password(self, password): wait = WebDriverWait(self.driver, 10) wait.until(EC.presence_of_element_located(self.password_input)).send_keys(password) def tap_login(self): wait = WebDriverWait(self.driver, 10) wait.until(EC.element_to_be_clickable(self.login_button)).click()这样用例写起来就是:
def test_login_success(driver): login_page = LoginPage(driver) login_page.enter_username("testuser") login_page.enter_password("password123") login_page.tap_login() # 断言跳转到首页控件 ID 变了,只改 LoginPage 一个文件,所有用例自动生效。这就是工程化的价值。
4.2 用例组织与数据驱动
用例多了之后,还要考虑组织方式。我用 pytest 做测试运行器,配合pytest.mark做分组:
import pytest @pytest.mark.smoke def test_login_success(driver): ... @pytest.mark.regression def test_login_wrong_password(driver): ...跑的时候可以只跑冒烟:pytest -m smoke。
数据驱动用pytest.mark.parametrize:
@pytest.mark.parametrize("username,password,expected", [ ("user1", "pass1", True), ("user2", "wrong", False), ("", "pass3", False), ]) def test_login_cases(driver, username, password, expected): ...这样一份代码覆盖多组数据,维护成本低很多。
4.3 失败重试与截图
移动端测试的不稳定性比 Web 高,网络抖动、系统弹窗、应用冷启动慢都可能导致偶发失败。我一般会加失败重试机制:
@pytest.fixture def driver(): d = create_driver() yield d d.quit()配合pytest-rerunfailures插件:
pip install pytest-rerunfailures pytest --reruns 2 --reruns-delay 1失败时自动重跑两次,每次间隔一秒。很多偶发问题重跑就过了。
截图这块,我在 conftest 里加一个钩子,用例失败时自动截图:
@pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: driver = item.funcargs.get("driver") if driver: driver.save_screenshot(f"failures/{item.name}.png")截图存下来,排查问题时能直观看到失败瞬间的页面状态。
5. 常见问题与排查实录
5.1 会话启动失败
这是最高频的问题。表现是脚本一跑就报错,会话根本建不起来。排查顺序:
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| ConnectionRefused | Appium Server 没启动 | 检查 4723 端口是否监听 |
| 设备未找到 | adb 没识别到设备 | adb devices确认,重插数据线 |
| 应用未安装 | 包名或路径错误 | 确认 app_package 和 app_activity |
| 权限拒绝 | 设备未授权调试 | 手机上确认 USB 调试授权弹窗 |
我遇到过一次特别隐蔽的:设备连着但adb devices显示unauthorized。原因是手机上那个“允许 USB 调试”的弹窗被误点了拒绝。解决办法是去开发者选项里撤销 USB 调试授权,然后重新插拔。
5.2 元素定位不到
元素明明在屏幕上,脚本就是找不到。常见原因有几个:
- WebView 里的元素:需要切换 context。用
driver.contexts看有哪些上下文,然后driver.switch_to.context("WEBVIEW_xxx")。 - 元素在 iframe 里:需要先切 frame。
- 元素还没渲染完:加显式等待。
- 定位表达式写错:用 Inspector 复制一份对比。
WebView 这个问题我踩过好几次。混合应用里,原生控件和 H5 页面混在一起,不切 context 的话,H5 里的元素一个都定位不到。切换之后记得切回原生:driver.switch_to.context("NATIVE_APP")。
5.3 脚本跑着跑着就断了
长时间运行的脚本容易断,原因通常是:
- 应用被系统杀掉:内存不足时系统会回收后台应用。解决方式是保持应用在前台,或者加
noReset参数避免每次重启。 - 网络超时:加超时配置,
options.new_command_timeout = 300。 - 设备锁屏:跑之前先解锁,或者设置屏幕常亮。
new_command_timeout这个参数很关键,默认是 60 秒,如果某个操作超过 60 秒没响应,会话会被服务端主动断开。跑长流程的时候我一般设成 300 秒。
5.4 跨平台脚本的差异处理
前面说了 Appium 跨平台是协议层统一,实际写的时候还是要处理差异。我的做法是抽一层平台判断:
import platform def get_locator(self, android_locator, ios_locator): if self.driver.capabilities["platformName"].lower() == "android": return android_locator return ios_locator然后在 Page Object 里:
self.login_button = self.get_locator( (AppiumBy.ID, "com.example.app:id/login_btn"), (AppiumBy.ACCESSIBILITY_ID, "loginButton") )这样两端共用一套操作逻辑,只有定位表达式分开维护。
6. 持续集成与报告输出
6.1 接入 CI 的关键配置
自动化测试只有跑在 CI 上才有持续价值。本地手动跑,跑几次就没人跑了。接入 CI 的核心是让测试能无人值守地执行。
关键配置有几个:
- 设备连接:CI 机器上要能稳定识别设备。真机的话用 USB Hub,模拟器的话提前启动好。
- Appium Server 常驻:CI 里可以用
appium &后台启动,或者用 pm2 之类的进程管理工具。 - 环境变量:ANDROID_HOME、JAVA_HOME 这些在 CI 环境里要配好。
我用过的一个方案是:CI 触发后,先启动模拟器,等模拟器就绪,再启动 Appium Server,然后跑 pytest,最后收集报告和截图。
6.2 测试报告的选择
报告这块我用过 Allure,效果比较好。安装:
pip install allure-pytest跑测试时加参数:
pytest --alluredir=./allure-results然后生成 HTML 报告:
allure generate ./allure-results -o ./allure-reportAllure 报告的好处是能看到每个用例的步骤、截图、耗时,失败用例还能直接看堆栈。对于定位问题非常方便。
如果不想引入额外依赖,pytest 自带的--html也能用:
pip install pytest-html pytest --html=report.html --self-contained-html--self-contained-html会把 CSS 和图片都内联进去,方便分享。
6.3 稳定性治理的长期经验
自动化测试最大的敌人不是写不出来,而是维护成本。我总结了几个降低维护成本的做法:
第一,定位表达式集中管理。所有定位都放在 Page Object 里,不要散落在用例中。
第二,用例之间保持独立。每个用例自己负责准备数据和清理环境,不要依赖前一个用例的状态。
第三,定期清理失效用例。业务变了,有些用例已经没意义了,该删就删,别留着占运行时间。
第四,监控通过率。如果某个用例连续失败,要么是 bug,要么是用例本身有问题,及时处理。
提示:我一般会统计每个用例最近 20 次的通过率,低于 80% 的标记出来重点排查。偶发失败和稳定失败要区别对待。
7. 我个人的一些实操体会
Appium 这套东西,入门曲线确实陡,但过了那个坎之后,效率提升是实打实的。我印象最深的是一个回归周期从两天压缩到两小时的案例,核心流程全部自动化,人工只负责探索性测试和边界场景。
有几个经验我觉得值得单独拎出来说。一是别追求 100% 自动化,投入产出比不划算,核心流程覆盖到 70% 到 80% 就够了。二是脚本要当代码来维护,用 Git 管理,做 Code Review,别当成一次性脚本。三是设备管理要提前规划,真机多了之后,USB 口不够、供电不足、设备掉线都是问题,该上设备管理平台就上。
最后分享一个定位小技巧:如果某个元素实在定位不到,可以用driver.page_source把当前页面结构 dump 出来,直接搜关键字。有时候 Inspector 显示的和实际 dump 出来的不完全一致,以 dump 为准。这个办法帮我解决过好几次疑难定位问题。