Appium自动化测试工程化:稳定性、多设备并行与故障排查
本地脚本跑得稳稳的一放进持续集成环境就十个用例挂八个这种翻车现场我见得太多了。如果你已经能写出Appium脚本但总觉得自己的自动化“不够稳”——元素有时点得到有时点不到换台设备就歇菜用例一多就互相干扰——那这篇就是给你写的。这是Appium自动化测试系列指南的第三篇。前两篇聊了环境搭建、第一个脚本、基础API这些入门内容这篇我们不再重复基础全部围绕一件事展开怎么让写好的自动化脚本从“能跑”变成“跑得稳、跑得快、坏了能查、能上并发”。内容全部来自我在多个实际项目里折腾出来的经验偏工程化、偏踩坑希望能帮你的Appium测试少走几个月的弯路。1. 本地脚本跑得欢上了持续集成就翻车环境一致性的三个隐藏变量1.1 设备状态与App包版本先说最隐蔽的一个坑本地环境里的App包和CI环境里的App包根本不是一个东西。本地开发时你测的往往是开发同学热更新的debug包页面元素、启动速度、还有各种调试开关都跟正式构建有差异。CI里如果重新打包构建出来的release包页面可能多了隐私弹窗、更新提示、甚至网络请求方式都不一样。两边元素长的不一样脚本自然是“本地能过CI就挂”。我后来养成了一个习惯不管脚本在哪里跑启动前先校验App版本号。做法很简单在脚本的公共部分加一个“预期版本号”参数启动App后读取当前版本跟预期比对不一致就直接抛错并提示“App包版本不符请检查构建产物”而不是让脚本继续跑下去在一个错误的状态里浪费20分钟。这个校验还能防另一种情况CI上多个任务共用一台机器前一个任务的App包还没卸载干净后一个任务启动时打开了旧版本。版本号校验一加上所有这些“跑错了包”的问题都会第一时间暴露。1.2 capability的硬编码陷阱不少人的代码里是这么写的desired_caps { platformName: Android, deviceName: emulator-5554, platformVersion: 12, appPackage: com.example.app, appActivity: .MainActivity }deviceName、platformVersion、udid全部写死。本地只有这一台模拟器当然没事一旦换设备、上真机、接进CI的设备和云测平台脚本立刻罢工。正确的做法是把这些设备相关参数从代码里剥离出来放到配置文件脚本里动态读取import json def load_caps(device_id): with open(devices.json, r, encodingutf-8) as f: config json.load(f) return config.get(device_id, config[default]) caps load_caps(device_id)devices.json里按设备维护各自的udid、platformVersion、systemPort等参数。新增一台设备只需要改配置不需要动代码。这点看似基础但确实是我见过最多团队栽跟头的地方——连udid都写死真机一插上就报“Multiple devices match”第一反应居然是去重启电脑而不是看代码。1.3 隐藏的时间因素与网络因素第三个变量是时间。App冷启动和热启动的耗时差距非常大平均可能差到3到5秒。本地你手动点开过App再跑脚本时是热启动快CI刚装完包是冷启动慢。于是又一个“本地过、CI挂”。处理思路不是把等待时间盲目拉长而是分场景管理冷启动场景下先用显式等待盯住首屏关键元素后面第3章会细说等它出现再做后续操作热启动场景可以适当缩短等待。不要一个超时时间从头用到尾。网络因素更不用说了。CI机器网络环境不稳定、后端接口响应慢直接导致页面元素迟迟加载不出来。这种情况我会在测试数据准备层面做隔离比如让测试账号指向测试环境的稳定数据或者提前预置好网络缓存尽量减少后端波动对前端元素加载的干扰。2. 元素定位不能只会xpath从定位策略到动态页面的应对方案2.1 定位方式的优先级排序新人写Appium最容易犯的一个毛病是一上来就右击元素复制xpath。复制粘贴确实爽但这种做法维护成本最高执行效率还低。我一般会按下面的优先级选择定位方式Androidresource-id accessibility id / content-desc text 相对定位 xpath兜底iOSaccessibility id / name label 坐标/相对位置 xpath兜底为什么把xpath放到最后两个原因。第一xpath表达式可读性差里面经常带一堆层级和属性页面前端稍微改一下样式表达式就可能失效维护成本极高第二xpath的执行性能比其他定位方式差尤其在iOS上。2.2 xpath慢在哪儿什么时候还得用它很多人会说我知道xpath慢但也没办法啊有的元素没有id。先说说它慢的机制。Appium在Android上走UiAutomator2xpath查询其实还好但iOS上走XCUITestxpath的查询是在远端执行的需要跨进程通信一次复杂xpath查询可能耗时几百毫秒甚至几秒。你写个自动化用例里面二三十个xpath调用光定位就浪费了十几秒整个套件的执行时间自然水涨船高。不过有些场景确实绕不开xpath我总结有三类元素没有任何可用的属性只有层级和文本可以描述需要根据父节点或兄弟节点来定位某个子元素属性值本身是动态生成的需要做模糊匹配遇到这些情况时用xpath但尽量写“短”而“稳”的表达式。比如用多属性组合ele driver.find_element( AppiumBy.XPATH, //android.widget.Button[contains(text, 确认) and enabledtrue] )用contains做模糊匹配比写死完整文案要稳定得多。我见过太多因为UI文案从“确认登录”改成“登录”就整个脚本报废的案例了。2.3 动态内容的三种替代思路除了xpath处理动态内容其实还有更好的思路。第一种思路用稳定账号制造确定性。很多App页面元素之所以“动态”是因为测试数据太随意。建立一个固定测试账号提前准备好该账号下的项目、列表、消息数据这样登录后页面的内容就是可预期的元素自然也就稳定了。第二种思路利用相对位置关系。有些控件虽然本身没有稳定属性但它旁边的某个控件是稳定的。比如搜索框旁边那个搜索按钮按钮自身可能有id那搜索框就可以通过“父容器下搜索按钮前面的那个输入框”来定位。这种思路能绕开大量动态id的坑。第三种思路先等多值匹配再取下标。页面上有多个同类元素时不要害怕用find_elements拿数组然后按索引取。比如“我的任务列表”里第2个任务卡片直接用driver.find_elements(AppiumBy.ID, task_card)[1]。这种写法在列表顺序已知且稳定的前提下比xpath简单可靠得多。我见过一个实际案例某个App的首页banner是运营后台配置的banner的id每天都变脚本里用xpath定位banner始终不稳定。最后不用id和xpath改成了“先定位banner下方的列表容器再往上一层拿banner”彻底解决了问题。这就是相对定位的威力。3. 把隐式等待和sleep请出代码库显式等待的封装与失效场景3.1 为什么不要用sleep和隐式等待先看这三种等待方式的差异对比这是我带测试团队时必讲的一张表等待方式机制优点致命缺点time.sleep(固定秒)无条件挂起N秒简单粗暴想起就起时间短了不够弹长了白白拖慢全套件隐式等待(implicitly_wait)每次find操作如果没有立刻找到元素轮询一段时间一次设置全局生效只对“元素存在”有效对“元素可点击”“元素不可见”“旧元素消失”完全无能为力显式等待(WebDriverWait)轮询等待某个条件成立超时则抛异常灵活、精准、可控需要写代码要求测试同学有一定的编程功底很多团队喜欢在setUp里写一句driver.implicitly_wait(10)以为万事大吉。实际上这个等待只解决“元素出现得慢”的问题。一个元素出现了但还在转圈动画中、或者被遮罩层盖住、或者属性还没更新隐式等待是不会帮你等的click照样失败。sleep就更不用说了——那是“固定时间赌运气”。网络快的时候白等网络慢的时候等不够一换网速就挂而且是整个套件全挂不是单个脚本的问题。3.2 显式等待的正确姿势我在项目里直接把常见的等待场景封装成一组工具函数需要的时候调用代码又干净又可控from appium.webdriver.common.appiumby import AppiumBy from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def wait_clickable(driver, locator, timeout10): 等待元素可点击并返回元素 return WebDriverWait(driver, timeout).until( EC.element_to_be_clickable(locator) ) def wait_visible(driver, locator, timeout10): 等待元素可见 return WebDriverWait(driver, timeout).until( EC.visibility_of_element_located(locator) ) def wait_invisible(driver, locator, timeout10): 等待元素消失用于弹窗关闭、加载完成等场景 WebDriverWait(driver, timeout).until( EC.invisibility_of_element_located(locator) ) def wait_exists(driver, locator, timeout10): 等待元素出现在DOM/视图树中不关心是否可见 WebDriverWait(driver, timeout).until( EC.presence_of_element_located(locator) )调用的时候这样写login_btn wait_clickable(driver, (AppiumBy.ID, com.example.app:id/btn_login)) login_btn.click()注意两点第一没一个wait都要带明确的timeout不同场景用不同超时。启动首屏可以15秒普通的按钮交互给5秒就够了全用10秒反而会让失败用例花更长时间才暴露问题。第二不要在一个用例里频繁创建多个WebDriverWait实例统一用封装好的工具函数出问题也好排查。3.3 等待条件失效的经典场景显式等待也不是万能的有几个场景是常见的“等待条件失效”的坑专门说一下。场景一元素存在且可见但点击无效。这时元素已经满足visibility条件但上面可能盖着一个半透明的遮罩层或正在播放的动画。这时候element_to_be_clickable其实能判断一部分但判断逻辑在不同设备上并不完全一致。我的处理方式是先等“遮罩层消失”或“动画结束”——通常是等一个特定元素的不可见wait_invisible再执行点击。场景二页面跳转导致驱动短暂失焦。Android的Activity切换、iOS的页面push过程中有几十到几百毫秒的状态不稳定窗口期此时去查元素偶尔会报“stale element reference”。出现这种报错大概率不是元素真的没了而是页面正在换挡。对策是捕获这个异常后重试一次查询不要立刻Fail。场景三列表滚动加载时的元素重绘。用driver.swipe或scroll滚动列表后原有元素引用会失效同时新元素还没完全渲染好。这时候滚动完先等一个“新内容占位元素”出现再继续操作。我自己踩过这个坑滚动后用旧引用的element去click结果报“element is not attached to the page document”白白折腾了一个下午其实就是在滚动后没有重新查找元素。4. 多设备并行不是复制粘贴端口分配与用例隔离的正确姿势4.1 Appium的多实例架构有人问Appium不是默认支持多设备吗把测试代码在10台机器上跑不就行了没那么简单。单个Appium server默认监听4723端口只能串行服务一个session你要真正并行跑多台设备必须启动多个Appium server实例每个实例绑定一组独立端口并且跟具体设备绑定。以Android为例每台设备需要的端口至少有三个端口类型用途默认值并行时分配建议Appium server端口脚本连接每个server4723从4723开始递增如4723、4725、4727systemPort设备上的UiAutomator2服务端口8200从8200开始按设备递增mjpegServerPort屏幕投影流端口7890从7890开始按设备递增iOS设备还需要额外配置wdaLocalPort默认8100来管理WebDriverAgent。启动多实例时可以用脚本批量拉起appium --port 4723 --system-port 8200 --mjpeg-server-port 7890 --udid emulator-5554 appium --port 4725 --system-port 8201 --mjpeg-server-port 7891 --udid emulator-5556 4.2 用例隔离的三件事光有端口还不够真正让并行测试稳定跑的是用例之间的隔离不然你的10台设备跑到后面状态互相污染失败率直接起飞。隔离主要做三件事数据隔离。每台设备用独立的测试账号账号里的数据互不干扰。如果多台设备共用同一个账号登录态互相挤掉线、数据互相覆盖脚本跑着跑着就发现页面内容对不上了。账号分配要做到“一设备一账号”至少也要做到“一并发批次一账号池”。状态隔离。每个用例开始前重置App状态。Android可以driver.terminate_app(package)再重新启动iOS可以用driver.activate_app(bundle_id)做类似处理。有人图省事直接加noResetTrue这确实省了登录态校验但也意味着上一个用例遗留下来的弹窗、缓存、页面停留位置会原样带到下一个用例大概率跑完第一个用例后面全崩。资源隔离。截图、日志、page_source文件要按设备时间戳分目录存。我见过一个项目并行4台设备跑截图全往同一个目录里放后跑完的用例会把先跑完的同名截图覆盖掉查问题的时候啥都没有。这个教训让我从此严格按“设备名/用例名/时间戳”三层结构保存测试产出。4.3 并行数量的合理选择最后说一个很反直觉的结论并行数量不是越多越好。我在某项目上做过一个简单实验单设备跑完整套用例耗时40分钟并行加到2台变成24分钟加到4台变成16分钟但加到6台反而变成19分钟失败率还高了30%。原因很现实——机器CPU、内存、IO都有瓶颈Appium server和模拟器/真机服务同时跑太多设备本身就开始卡顿这时候脚本报的各种找不到元素、点击无响应本质上是机器性能撑不住了。所以我的建议是先把单设备跑稳再逐步增加并发量每加一批都观察失败率曲线。如果加了并发后失败率明显上升优先检查是不是资源瓶颈而不是急着调脚本。5. 真机才有的那些坑和iOS/Android的差异化处理5.1 iOS真机测试的签名与WebDriverAgent模拟器上跑iOS测试通常很顺真机就不一样了。第一次连真机跑的时候你大概率会遇到几个跟“签名”相关的问题。开发证书或描述文件过期直接导致App安装失败或启动崩溃测试机上的开发者模式未开启App和WebDriverAgent都无法安装首次连接真机时手机上会弹出“信任此电脑”或“信任开发者”的对话框你没点掉脚本就一直卡在“Waiting for quiescence”处理办法是用自动化脚本预先处理好这些前置步骤。收包时先检查证书有效期连接真机后通过命令检查开发者模式状态必要时在跑测试前用一个专门的“准备用例”先把所有弹窗点掉。很多团队把iOS真机测试搞不定的原因不是Appium本身而是这些iOS系统层面的准备工作没做到位。另一个细节iOS真机的安装包参数通常传的是.app的zip压缩包路径或.ipa文件同时必须配bundleId。不要传成一个裸文件夹Appium会直接报“unable to find the app”。这个坑我帮同事排查了整整一下午。5.2 Android真机与厂商ROM差异Android真机的坑主要来自不同厂商的ROM。第一不同ROM对UiAutomator2的兼容性差异明显。我在一些国产厂商的设备上遇到过点击坐标触发了但页面没反应元素明明存在get_attribute取到的值为空。这类问题很难从代码层面完全绕开只能尽量做兼容判断比如发现元素状态异常时先拿page_source打印出来核对。第二系统权限弹窗干扰。定位、通知、悬浮窗、安装未知应用等弹窗会在App启动后随机弹出直接盖住目标元素。处理方式分两步一是在测试准备阶段用命令统一授权比如Android上可以用adb shell pm grant package permission预授权二是准备一个“弹窗清扫函数”每次App启动后先尝试点掉可能出现的系统弹窗再去跑正式步骤。第三输入法拦路。很多测试同学遇到过脚本在输入框send_keys结果键盘弹出来把提交按钮挡住了或者中文输入法有联想候选词导致输入内容不对。解决思路是配置里加unicodeKeyboard和resetKeyboard两个参数caps[unicodeKeyboard] True caps[resetKeyboard] True前者让输入通过编码方式注入而不是模拟键盘敲击后者在测试结束后重置输入法避免污染其他用例。怀疑是键盘问题时还可以用driver.hide_keyboard()主动收起键盘再执行下一步点击。5.3 键盘遮挡与屏幕方向带来的定位偏差这里单独说一个很“隐晦”的问题键盘弹起或屏幕旋转之后坐标定位全乱。有些测试同学为了省事直接基于坐标点点击driver.tap([(520, 1600)])这在固定屏幕方向、固定分辨率、且没有键盘弹出的情况下没问题。一旦键盘弹起整个可视区域被压缩底部元素整体上移你写死的坐标就全部偏了。同理屏幕旋转后坐标体系直接翻转不崩才怪。正确处理是尽量用元素属性定位而不是坐标如果某些场景实在需要坐标比如某些游戏类App的Canvas元素也要先算“相对于当前可视区域”的比例坐标而不是写死绝对值。6. 日志、截图与重试故障排查链路的三个支点6.1 从Appium日志里找线索脚本挂了第一步不是改代码是看日志。这句话我说过很多遍但依然有很多人一上来就各种怀疑定位写错了然后对着UI瞎猜。Appium server的日志里最有价值的有三部分session信息session创建成功与否、设备连接状态、使用的capabilities的最终生效值元素操作记录每次find、click对应的元素描述、xpath查询耗时错误堆栈报错时的详细堆栈能直接看出是定位失败、超时、还是App崩溃实际操作中如果你发现“元素找不到”的报错先别急着改定位器先翻日志里session创建时用的capabilities确认udid、appPackage、bundleId这些基础参数是不是对的。我统计过团队里“找不到元素”的问题相当大比例是连错了设备或连错了App路径而不是定位表达式写错了。6.2 失败自动截图与页面源码采集脚本失败时光有报错文本远远不够。移动端很多问题是“状态性”的——弹窗、网络异常、页面错乱错完就恢复正常了事后光看文字根本还原不了现场。所以必须在用例失败时做两件事截图 抓取page_source。推荐在teardown里统一处理而不是每个用例单独写import pytest pytest.hookimpl(tryfirstTrue, hookwrapperTrue) 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: timestamp time.strftime(%Y%m%d_%H%M%S) device_name driver.capabilities.get(udid, unknown) driver.save_screenshot(freports/{device_name}_{item.name}_{timestamp}.png) page_source driver.page_source with open(freports/{device_name}_{item.name}_{timestamp}.xml, w, encodingutf-8) as f: f.write(page_source)截图还原视觉现场page_source还原元素层级结构。有了这两样东西离线排查一个用例失败通常几分钟就搞定没这两样你可能要重新跑十几分钟才能复现一次问题。这个投入产出比非常高强烈建议所有项目都配上。6.3 重试机制的两种实现重试是最容易被误解的机制。很多人一遇到不稳定用例就套个重试其实重试是兜底不是解药。如果同一个用例失败率超过30%你要做的是排查根因而不是让它靠运气跑三次。我常用的重试方式有两种看场景选择第一种用例级重试。用pytest-rerunfailures这类插件给整个用例加1到2次重试。适合偶尔因为网络抖动、后端超时导致的偶发失败。注意别设置成3次以上——重试次数一多本来10分钟能跑完的套件可能因为反复重试拖到30分钟而且失败的真实原因被掩盖了。第二种步骤级重试。针对某段“不确定高”的操作比如进入某个加载缓慢的二级页面后点击按钮写一个小重试装饰器def retry_on_failure(max_retries2, exceptions(Exception,)): def decorator(func): functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries 1): try: return func(*args, **kwargs) except exceptions: if attempt max_retries: raise time.sleep(1) return None return wrapper return decorator这样可以在不污染整体用例逻辑的前提下让某一步在偶发失败时自动重来一次而不是整个用例重新跑。倒计时页、广告加载页这类场景的用例我很喜欢加这种步骤级重试实测能把大量“明明只是页面慢了半秒”导致的失败消掉。最后说一个我长期坚持的原则脚本里同一个失败如果出现第三次我就会停下来思考是不是该改框架、改机制而不是继续给具体用例打补丁。比如反复出现的“元素没找到”大概率不是定位写错了而是等待策略不合适反复出现的“App崩溃”可能不是测试的问题而是App本身在这个设备上有bug应该去推动提缺陷。自动化测试不是写脚本而是搭一套让脚本稳定运转的系统。先把环境一致性、定位策略、等待机制这些底座打牢再谈用例数量才有意义。