ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Appium移动端UI自动化测试框架从零搭建与实战经验

Appium移动端UI自动化测试框架从零搭建与实战经验 Appium这个框架在移动端测试圈子里算是老面孔了但越是用得久越发现很多人卡住的不是脚本怎么写而是环境怎么搭、架构怎么摆、真正跑起来之后稳定性怎么保证。最近我在帮团队从零搭建一套可复用的移动端UI自动化测试框架整个过程踩了不少坑也沉淀了一些可行的方案。这篇就把完整的搭建过程、设计思路、以及那些“文档里不会写”的经验一并整理出来给正在搞Appium或者准备入手的同学一个参考。1. 项目概述与核心思路拆解1.1 为什么选Appium而不是其他框架移动端UI自动化的可选方案其实不少比如Airtest、Maestro、Espresso、XCUITest但Appium至今仍然是跨平台兼容性最好、社区生态最成熟的那个。它的核心设计思路是通过WebDriver协议与各平台的自动化引擎进行通信iOS走XCUITest驱动Android走UiAutomator2驱动对外暴露统一的API。这意味着同一套测试代码逻辑只要适配好capabilities就可以在Android和iOS上跑同一套业务用例维护成本直接减半。我给团队选型时有过一段纠结期当时对比了以下几个方案方案跨平台能力社区活跃度技术门槛维护成本Appium强Android/iOS统一API很高中等较低Airtest强主打游戏/图像识别中低中Maestro中起步晚生态小中低低未知原生Espresso/XCUITest弱各写各的高高高最终选择Appium的另一个现实原因团队里大部分测试同学熟悉Python或JavaAppium对这两种语言的支持非常成熟踩坑时能找到的参考案例也是最多的。1.2 框架搭建的整体目标动手之前我先把这次搭建的目标定清楚了。这不是为了“搭个框架”而搭而是要解决实际落地中的三个痛点第一环境复现困难。测试人员换了电脑光装环境就要折腾半天需要一套标准化、可快速复现的环境准备方案。第二脚本可维护性差。如果所有代码堆在脚本里页面一改版就要全局改代码必须通过分层设计把元素定位、业务操作、用例执行解耦开。第三稳定性不达标。UI自动化最怕的是“昨天能跑今天挂了”而且报错信息千奇百怪需要一套统一的等待策略、日志归集和失败重跑机制。所以这次搭建的框架核心包含四个部分标准化的环境准备流程、分层清晰的工程结构、基于Page Object的封装模式、以及围绕稳定性和可观测性做的辅助机制。1.3 方案选型的落地考量关于Appium版本我踩过一个比较经典的坑环境变量指向的Appium老版本和驱动版本不匹配导致启动会话时报各种莫名其妙的错误。所以这次搭建我直接采用了当前比较稳妥的组合Node.js 16 Appium Desktop仅调试用 Appium Server 2.x命令行方式启动 UiAutomator2 Driver 2.xAndroid端 XCUITest DriveriOS端 Python 3.10 pytest allure有一点要特别提醒Appium 2.x 相比 1.x 变化很大最大的区别是驱动从内置变成插件化安装环境变量也改成了appium命令直接启动。网上大量老教程还停留在1.x如果你照着老教程配置2.x环境大概率会遇到各种兼容性问题。2. 环境搭建的完整实操流程2.1 基础依赖Node.js 与 Java 环境的安装Appium 服务端是 Node.js 写的所以第一步是装 Node.js。这里有个版本选择问题Appium 2.x 要求 Node.js 版本在 16 以上但我实测 18 和 20 的长期支持版都稳定建议直接上 20 LTS不要用最新的奇数版本稳定优先。安装完成后在终端确认node -v npm -v接下来是 Java 环境。Android 端的 UiAutomator2 驱动依赖 Java版本要求是 JDK 8 以上实际操作中用 JDK 11 的兼容性最好。考虑到 Android 开发工具链的自动化构建需求我建议直接安装 JDK 17。安装后需要配置JAVA_HOME环境变量并确保java -version能正常输出。这里有个很多新手会忽略的细节安装 JDK 后必须把环境变量配置到系统级别的 PATH 里如果只在当前终端临时设置重启终端或换一台电脑就全白费了。2.2 Android SDK 与 adb 的配置移动端自动化绝对绕不开 adb 工具。Appium 本身不会帮你管理设备连接所有设备列表、安装应用、端口转发、日志抓取底层全靠 adb 来操作。安装 Android SDK 有几个选择安装完整的 Android Studio或者只装命令行工具command line tools。对于测试同学来说我不建议装完整的 Android Studio体积大且用不到大部分功能只需要下载 command line tools 然后通过 sdkmanager 安装需要的组件即可。需要安装的组件包括sdkmanager --install platform-tools sdkmanager --install platforms;android-33 sdkmanager --install build-tools;33.0.0配置环境变量时除了 ANDROID_HOME还需要把 platform-tools 目录加入 PATH否则 adb 命令找不到。验证是否安装成功adb devices这个命令输出的列表如果能看到设备或模拟器说明环境基本就绪。注意执行adb devices时如果设备显示未授权需要在手机上允许 USB 调试授权。这个问题非常常见但不是环境问题是权限问题后面细说。2.3 Appium 服务端与驱动的安装环境变量配好之后开始装 Appium 服务端。这里的原则是全局安装 appium驱动按需安装。npm install -g appium appium -vAppium 2.x 安装完成后默认是没有驱动的需要单独安装。这一步很多人会卡住因为国内网络访问 npm 官方源比较慢建议切换为国内镜像源npm config set registry https://registry.npmmirror.com接下来安装驱动appium driver install uiautomator2 appium driver install xcuitest如果只需要测 Android安装 uiautomator2 就够了如果还要测 iOS安装 xcuitest。驱动装完后用以下命令验证驱动列表appium driver list这里补充一个我踩过的坑Appium 2.x 安装驱动时如果 Node.js 版本太低或 npm 缓存有问题驱动安装会报权限错误或半途中断。稳妥的做法是安装前先清理 npm 缓存npm cache clean --force再执行驱动安装命令。2.4 设备连接与 adb 调试模式设置环境配置好后设备接入这一步是事故高发区。Android 真机连接时需要注意几个关键点首先手机必须开启开发者选项和 USB 调试。不同厂商手机的开启方式不一样但大致都是设置 - 关于手机 - 连点版本号7次然后返回设置就能看到开发者选项。接着插入 USB 线后手机上会弹出 “允许USB调试吗” 的对话框勾选 “始终允许”点击允许。连接后执行adb devices正常情况下可以看到List of devices attached emulator-5554 device如果显示unauthorized就是权限没允许如果显示offline通常是驱动问题或USB模式不对可以尝试重新拔插或换USB口。对于模拟器市面上主流的选择是Android Studio 自带模拟器、Genymotion、以及第三方模拟器。我个人建议优先用 Android Studio 自带模拟器因为兼容性最好与 adb 集成也最顺畅。3. 框架核心架构设计与封装实践3.1 工程目录结构与分层思想环境问题解决后重点来了怎么组织代码才能让框架不“跑一次就废”这是整个项目里决定长期维护效率的部分。我采用的目录结构如下auto_test/ ├── config/ │ ├── android_caps.py │ └── ios_caps.py ├── pages/ │ ├── base_page.py │ ├── login_page.py │ └── home_page.py ├── test_cases/ │ ├── conftest.py │ ├── test_login.py │ └── test_home.py ├── common/ │ ├── driver.py │ ├── logger.py │ └── read_yaml.py ├── screenshots/ ├── reports/ ├── requirements.txt └── pytest.ini分层的逻辑是这样的config 层负责保存设备信息和不同平台的 capabilities 配置。common 层封装驱动初始化、日志记录、公共操作方法。pages 层页面对象层一个页面一个类页面上的元素定位和操作逻辑都封装在这里。test_cases 层测试用例层只负责组织用例步骤和执行断言不直接操作元素。这种分层的直观收益是当产品页面 UI 发生变化时大多数情况下只需要修改 pages 层对应页面的定位器test_cases 层的业务逻辑几乎不需要动维护范围被大幅收窄。3.2 基于 Page Object 模型的封装实践Page Object 的核心思想是把页面当作对象来封装。一个页面对应一个类类里面定义该页面所有需要用到的元素定位以及封装好的操作方法。在写 pages/base_page.py 时我定义了一个基类把所有基于 of 的操作方法抽出来class BasePage: def __init__(self, driver): self.driver driver def find_element(self, locator): return self.driver.find_element(*locator) def click(self, locator): self.find_element(locator).click() def input_text(self, locator, text): self.find_element(locator).send_keys(text) def get_text(self, locator): return self.find_element(locator).text def wait_for_element(self, locator, timeout10): from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC return WebDriverWait(self.driver, timeout).until( EC.presence_of_element_located(locator) )各个页面的类继承 BasePage只需要写业务操作逻辑class LoginPage(BasePage): def __init__(self, driver): super().__init__(driver) self.username_input (MobileBy.ID, com.example:id/username) self.password_input (MobileBy.ID, com.example:id/password) self.login_button (MobileBy.ID, com.example:id/login_btn) def login(self, username, password): self.input_text(self.username_input, username) self.input_text(self.password_input, password) self.click(self.login_button)这样在写测试用例时代码读起来更像自然语言login_page LoginPage(driver) login_page.login(test_user, 123456)这套模式的好处不需要多解释代码复用率极高而且用例完全可读非专业人员看测试报告也能看懂用例执行了什么。3.3 数据驱动与关键字驱动的落地框架不能只做页面对象封装还要考虑用例数据的管理。我在这个项目里同时引入了数据驱动思路把测试数据放在 YAML 文件中# config/test_data.yaml login_valid: username: test_user password: 123456 expect_result: 欢迎回来 login_invalid: username: test_user password: wrong_password expect_result: 用户名或密码错误配合 pytest 的参数化数据与脚本实现解耦import pytest import yaml with open(config/test_data.yaml, r, encodingutf-8) as f: test_data yaml.safe_load(f) pytest.mark.parametrize(data, test_data.values()) def test_login(data): login_page.login(data[username], data[password]) assert login_page.get_text(...) data[expect_result]这样新增一条测试数据不需要改任何代码只需要在 YAML 文件中增加一组字典。对于测试团队来说把数据抽出去以后写用例变成拼数据的过程效率提升明显。3.4 统一驱动会话管理驱动初始化其实是一个单独的函数但很多框架会把 driver 在用例里反复创建这会导致资源浪费且容易出问题。我推荐的做法是在 conftest.py 里统一管理 driver 的生命周期pytest.fixture(scopefunction) def driver(): caps read_capabilities(config/android_caps.yaml) driver webdriver.Remote(http://127.0.0.1:4723/wd/hub, optionscaps) yield driver driver.quit()有一点经验之谈scope 的粒度选择要根据被测应用的启动耗时间来定。如果被测 App 启动很慢每次重新启动用例就会很耗时这时可以改成scopemodule甚至scopesession但必须在用例之间保证页面状态没有污染或者通过代码显式回到首页。4. 核心脚本的编写与执行4.1 capabilities 参数的逐项拆解capabilities 是启动 Appium 会话的核心参数相当于告诉 Appium “我要测试哪个设备上的哪个应用”。我拆解一份实际用到的配置desired_caps { platformName: Android, appium:platformVersion: 13, appium:deviceName: emulator-5554, appium:appPackage: com.example.app, appium:appActivity: .MainActivity, appium:noReset: True, appium:ensureWebviewsArePages: True, appium:automationName: UiAutomator2, appium:newCommandTimeout: 300, appium:uiautomator2ServerInstallTimeout: 60000, }这几个参数的对应含义platformName必填指明目标平台Android 或 iOS。appium:platformVersion系统版本注意要和设备实际系统版本匹配不匹配时容易报错。appium:deviceName设备标识。对于 Android 来说这个值可以不严格等于 adb devices 显示的序列号但建议保持一致。appium:appPackage / appium:appActivityAndroid 应用包名和启动 Activity。获取方式先把 App 安装到设备执行adb shell dumpsys window | grep mCurrentFocus可以拿到当前前台应用的包名和 Activity。appium:noReset是否在测试前重置应用数据。设置为 True 可以保留登录状态大幅缩短脚本执行时间但在需要验证干净的首次启动场景时要设为 False。appium:automationNameAndroid 平台用 UiAutomator2。appium:newCommandTimeout会话空闲超时时间。默认 60 秒如果脚本执行过程中有较长时间等待比如等待网络请求建议调大。appium:uiautomator2ServerInstallTimeout安装 UiAutomator2 服务到设备时的超时时间。首次连接慢设备时这个参数非常关键默认值偏小会直接导致会话启动失败。这些参数看着简单但每一项出问题都会导致会话起不来所以建议在框架中把 caps 配置抽到独立 YAML 文件方便多设备、多平台复用。4.2 元素定位的实战要点Appium 的元素定位方式继承自 Selenium但移动端有一些独有的方式。实际使用中我按优先级依次推荐resource-idID定位在 Android 中resource-id 是最稳定、最可读的定位方式。格式通常是包名:id/控件id。accessibility id内容描述通过控件的内容描述属性定位适合有明确语义的控件。XPath 定位灵活性最高但性能最差且在 Android WebView 中兼容性不稳定。能用前两种的情况不要优先用 XPath。举个 XPath 的高级用法例子当需要定位“包含某个文本的控件”时self.driver.find_element(MobileBy.XPATH, //android.widget.TextView[contains(text, 设置)])关于控件定位一个非常实用的工具是Appium Inspector它可以连接到 Appium 服务器并实时展示界面的控件树。调试定位表达式时先扫描页面结构找到目标控件直接复制它的属性。用熟了之后定位器写错的概率大幅下降。4.3 等待机制与同步问题UI 自动化里“找不到元素”是最常见的报错但根因往往不是定位器写错了而是等待时机不对。元素还没渲染出来代码就去点击自然找不到。等待机制一般有三种写法固定等待time.sleep不推荐无论网络快慢都死等固定时间慢的时候不够用快的时候浪费时间。隐式等待implicitly_waitdriver 级别的全局等待每次查找元素时会自动等待一段时间。问题在于它只管元素是否存在无法处理元素可见、可点击等状态。显式等待WebDriverWait expected_conditions最推荐的方式可以精确指定等待条件和超时时间。我常用的显式等待写法from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC wait WebDriverWait(driver, 20) element wait.until(EC.element_to_be_clickable((AppiumBy.ID, com.example:id/button)))在 BasePage 中封装好 wait 方法所有页面都调用统一的等待逻辑避免每个页面自定义等待时间。经验准则是优先使用显示等待 可点击状态不用固定 sleep。这里还有一个 WebView 场景的特殊问题如果测的是混合应用WebView 中的元素要先切换 context 才能操作contexts driver.contexts driver.switch_to.context(contexts[-1])网页加载是异步的切换 context 之后页面不一定马上就绪所以切完 context 后也要做显式等待。4.4 测试执行与报告输出框架的执行入口基于 pytest配置 pytest.ini[pytest] addopts -v -s --alluredir./reports/allure-results testpaths ./test_cases执行测试pytest -m smoke生成报告allure generate ./reports/allure-results -o ./reports/allure-report --clean allure open ./reports/allure-reportAllure 报告的效果是直观的直观到什么程度业务方直接看报告里每个用例对应的截图就能判断问题出在前端还是后端。截图这件事千万要放到框架里去如果只是在用例里手动写截图逻辑一定会漏。在 conftest.py 中这样安排pytest.hookimpl(tryfirstTrue) def pytest_runtest_makereport(item, call): if call.when call and call.excinfo is not None: driver item.funcargs[driver] timestamp time.strftime(%Y%m%d-%H%M%S) driver.save_screenshot(f./screenshots/{item.name}_{timestamp}.png)这样任何一条用例失败时报告会自动附带失败瞬间的截图不需要在每个用例里重复处理异常。5. 常见问题与排查技巧实录5.1 会话启动失败与环境类问题速查Appium 框架搭建过程中会话启动失败是碰到过最多的问题。这里把高频问题整理成了一张速查表方便直接对应排查报错信息根因解决方案Could not find a connected Android deviceadb 没有检测到设备检查 USB 连接、驱动、调试授权执行adb devices验证Failed to create session. An unknown errorcapabilities 配置不匹配核对设备名、系统版本、自动化引擎是否一致io.appium.uiautomator2.server...超时UiAutomator2 服务安装超时在 caps 中调大 uiautomator2ServerInstallTimeout 参数Appium was not started on the...Appium 服务器没有启动使用appium命令启动服务确认端口未被占用Error: The requested driver is not installed驱动未安装执行appium driver install uiautomator2adb is not recognized环境变量未配置把 platform-tools 路径加入 PATH这类环境问题占了全体新手踩坑的一半解决思路也简单按顺序检查“设备连接 - capabilities 配置 - 驱动安装 - Appium 服务”四层链路大概率能找到问题。5.2 脚本稳定性问题排查框架搭好了用例能跑了紧接着进入“跑十次挂三次”的稳定期。这个阶段的问题更隐蔽我记录了几个最典型的第一个问题元素偶发找不到。这个问题的根因通常是元素定位的时机不对。网络慢的时候页面渲染时间长显式等待的超时设置不够。解决方法是把等待超时从默认 10 秒调高到 20 秒同时把定位方式从 XPath 改回 resource-id因为 XPath 的解析过程本身也耗时。第二个问题点击事件不生效。界面元素被遮挡或者位置偏移时点击坐标可能落在错误位置。这是 Appium 点击操作的常见缺陷。解决方案是优先使用 element.click() 而非按坐标 tap如果实在需要坐标点击务必在点击前做元素可见性校验。第三个问题应用弹窗干扰。测试过程中突然弹出系统权限请求、升级提示、广告弹窗导致下一个元素被遮挡。这个问题要用兜底方案在每步操作前检查是否有弹窗如果有就关闭。我把这个逻辑写到 BasePage 的点击方法里def safe_click(self, locator): self.close_popups_if_any() self.wait_for_element(locator) self.click(locator)所谓“框架稳定”很多都是这种兜底逻辑堆出来的。5.3 性能优化与执行效率提升第一个优化点是并行执行。如果框架只支持单设备跑用例几十条用例跑完可能需要一个多小时。我的方案是引入 appium 的多设备并发每台设备启动一个 Appium 服务监听不同端口测试用例通过 pytest-xdist 分配到不同设备上执行。pytest -n 3 --distloadgroup配合多设备并发时需要把 capabilities 中的设备名和端口号做成变量从配置文件中读取避免所有设备抢占同一个服务。第二个优化点是等待策略。把隐式等待和显式等待混用会导致每次元素查找都白白等待。我的配置是隐式等待设置为 5 秒作为兜底显式等待按具体的业务操作灵活设置。不要在全局设置一个很长的隐式等待那会让每个定位失败的操作都等满时间降低整套用例的执行速度。第三个优化点是应用状态保持。通过 noResetTrue 和用例之间复用 driver 会话登录状态可以被跨用例保留省掉重复登录的时间。对大型 App 来说登录过程的耗时通常在 10-30 秒这个优化能显著缩短整体执行时间。5.4 真机与模拟器的差异处理真机和模拟器的行为差异是一个比较隐性的问题。模拟器上动画默认是关闭的而真机上动画存在这会导致元素可能出现的时间点不同。建议在能力配置中显式关闭系统动画或者在测试设备上统一关闭。命令行关闭动画的方式adb shell settings put global window_animation_scale 0 adb shell settings put global transition_animation_scale 0 adb shell settings put global animator_duration_scale 0这条命令在测试环境的 Android 设备上执行一次即可。它能把动画时间归零让元素在最短时间内出现在布局树中减少等待时间。还有一个小细节真机上输入中文时send_keys 可能会失效因为某些输入法不支持自动提交。解决办法是安装 ADB 键盘并在 caps 中指定键盘的包名appium:unicodeKeyboard: True, appium:resetKeyboard: True,6. 框架落地的补充思路最后分享几个框架搭建到现在我在实际操作中沉淀下来的经验。第一点框架的配置一定要外置。设备信息、测试账号、测试数据、服务器地址全部外置到配置文件里。这样换一台手机、换一个测试环境只需要改配置不需要改代码。第二点日志体系从第一天就要建好。Appium 自带的日志信息量很大但很杂。在框架内部封装统一的 logger记录用例执行的步骤、耗时和关键结果。排查问题的时候好的日志会让效率翻倍。日志里至少要有用例名、操作步骤描述、参数信息和执行时间。第三点利用 git hooks 或 CI 流程做回归触发。框架搭建完成后不要只停留在本地执行。接入 CI 之后每次代码提交自动触发冒烟测试问题在开发阶段就能暴露。哪怕一开始只在本地跑也要预留好 CI 的入口别等团队扩大后再重构。第四点元素定位器要建立 review 机制。在代码审查时强制检查能使用 resource-id 的不允许使用 XPath尽量避免使用绝对路径的 XPath。这一点坚持执行下来框架的稳定性会有一个明显的提升因为绝对路径 XPath 在页面结构稍有变化时就会失效。这套框架从上手到稳定运行整体节奏大概是环境搭建一到两天分层框架搭建两天核心用例编写三天稳定性调优一周。过了这个阶段框架能带来的收益是长线的回归成本降低、版本迭代信心增强、测试人员的重复劳动大幅减少。UI 自动化不能解决所有问题但一套设计合理的框架能让测试团队把精力放到更有价值的业务探索和质量分析上而不是每天重复点点点。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进