1. 从通用 Agent 到移动端测试:这次下沉到底意味着什么
移动端自动化测试这个领域,过去几年一直有个很尴尬的局面:工具链很成熟,但写用例的人很痛苦。Appium、Espresso、XCUITest 这些框架本身没问题,问题在于每换一个业务页面、每改一次 UI 结构,测试脚本就得跟着大修一次。维护成本高到很多团队干脆放弃 UI 自动化,只保留少量冒烟用例撑场面。
大语言模型进来之后,情况开始变了。最早大家只是拿它生成测试用例代码,后来发现真正有价值的不是“生成”,而是“理解”——让模型看懂当前屏幕是什么、该点哪里、输入什么、预期什么结果。这就从“代码生成”升级成了“Agent 决策”。
但这里有个关键分水岭:直接把通用的 Agent 框架(比如那些做网页操作、做桌面自动化的框架)搬到移动端测试场景,能不能跑通?我的实测结论是——能跑,但直接搬会踩一堆坑。通用 Agent 框架的设计假设和移动端测试的实际需求之间,存在至少四层错位。这一篇就围绕这个“下沉”过程,把错位在哪里、怎么补、补完之后效果如何,完整拆一遍。
如果你正在做移动端自动化,或者正在评估要不要引入 LLM 来做测试提效,这篇的内容应该能帮你省掉至少两周的试错时间。我会从架构设计、核心细节、实操落地、问题排查四个层面展开,每个层面都给出可直接参考的方案和参数。
2. 通用 Agent 框架与移动端测试的架构错位分析
2.1 通用 Agent 的默认假设为什么不成立
通用 Agent 框架通常基于这样的假设工作:环境是相对稳定的、操作反馈是即时的、状态空间是连续可观测的。比如一个操作网页的 Agent,它能看到完整的 DOM 树,能拿到每个元素的精确坐标和属性,点击之后页面变化可以通过 DOM diff 精确捕捉。
移动端测试完全不是这个逻辑。首先,移动端的 UI 树虽然也能拿到(通过 accessibility tree 或 UI hierarchy),但它的信息密度远低于 DOM。一个按钮在 DOM 里可能有几十个属性,在移动端 UI 树里可能只有一个 content-desc 和一个 bounds。其次,移动端的状态变化有大量异步和动画,点击之后界面可能 300ms 后才稳定,Agent 如果按通用框架的节奏去抓取状态,拿到的往往是中间态。第三,移动端有大量系统级弹窗、权限请求、输入法遮挡,这些在通用 Agent 的假设里根本不存在。
我实测过一个通用 Agent 框架直接对接移动端的情况:在静态页面上表现还行,一旦遇到列表滚动加载、弹窗拦截、键盘弹出,成功率直接从 85% 掉到 40% 以下。这不是模型能力问题,是框架的感知层和执行层没有为移动端做适配。
2.2 移动端测试对 Agent 的四项特殊要求
把需求拆细,移动端测试场景对 Agent 框架有四个硬性要求,缺一个都会导致落地困难。
第一是状态感知的稳定性。Agent 每次决策前需要拿到当前屏幕的“快照”,这个快照必须是稳定的、去噪的。移动端 UI 树里充斥着大量无意义的容器节点、动态的计时器文本、随机的内容推荐位。如果直接把原始 UI 树丢给模型,token 消耗巨大且干扰严重。需要做一层“UI 树剪枝”,只保留可交互元素和关键文本。
第二是操作执行的可靠性。移动端的点击、输入、滑动,执行层需要处理坐标偏移、元素遮挡、输入法切换。通用 Agent 框架通常只有一套 click(x, y) 的抽象,但移动端需要区分 tap、longPress、swipe、setText 等多种操作,且每种操作都有超时和重试策略。
第三是断言与验证的灵活性。测试的核心是验证,通用 Agent 框架通常只关心“任务是否完成”,不关心“结果是否正确”。但测试场景需要明确的断言:这个文本出现了吗?这个按钮变灰了吗?列表长度是 5 吗?这需要 Agent 在决策之外,额外具备验证能力。
第四是失败恢复与现场保留。测试跑失败是常态,关键是要能保留现场——截图、UI 树、操作日志、模型决策记录。通用 Agent 框架通常失败就抛异常结束了,但测试场景需要“失败后自动截图并继续下一条”或者“失败后回退到上一步重试”。
2.3 下沉方案的整体设计思路
基于上面四项要求,我的下沉方案是这样的:保留通用 Agent 框架的“感知-决策-执行”主循环,但在三个位置插入移动端适配层。
感知层前面加一个UI 快照处理器,负责从原始 UI 树中提取精简的、结构化的屏幕描述。决策层后面加一个操作映射器,把模型输出的高层动作(如“点击登录按钮”)翻译成具体的移动端操作指令。执行层外面包一个测试运行时,负责断言、重试、截图、日志。
这个设计的核心逻辑是:不改造 Agent 框架本身,而是用适配层把移动端的特殊性“翻译”成框架能理解的通用形式。好处是框架升级时适配层不用大改,坏处是适配层本身需要精心设计,否则会成为新的瓶颈。
3. 核心细节拆解:UI 快照处理与操作映射的实现要点
3.1 UI 树剪枝:从上千节点到几十个有效元素
移动端原始 UI 树动辄上千个节点,直接丢给模型既贵又乱。剪枝的目标是保留“人眼能看到且能操作”的元素。我的剪枝规则是这样的:
- 保留所有可点击(clickable=true)、可输入(focusable=true)、可滚动(scrollable=true)的节点
- 保留所有有文本内容(text 非空)或内容描述(content-desc 非空)的节点
- 剔除纯布局容器(无文本、无交互、无背景图)
- 剔除不可见节点(visible=false 或 bounds 为空)
- 对同层级重复结构的列表项,只保留前 3 个和后 1 个,中间用省略标记
剪枝之后,一个典型页面的元素数量从 800-1200 降到 30-60 个。这个量级模型处理起来很轻松,而且决策准确率明显提升。我做过对比:不剪枝时模型选错元素的概率约 18%,剪枝后降到 6% 左右。
剪枝后的元素需要重新编号,给每个元素一个稳定的 ID。这个 ID 在本次快照内有效,模型输出“点击元素 12”这样的指令,执行层再根据 ID 找到对应的坐标或选择器。这样做的好处是模型不需要输出精确坐标,避免了坐标漂移问题。
3.2 屏幕描述的文本化格式设计
剪枝后的元素列表需要转成模型能理解的文本。我试过几种格式,最终稳定下来的是一种类 YAML 的结构:
screen: 登录页 elements: - id: 1 type: input label: 手机号输入框 hint: 请输入手机号 bounds: [40, 320, 680, 400] - id: 2 type: input label: 密码输入框 hint: 请输入密码 bounds: [40, 420, 680, 500] - id: 3 type: button label: 登录按钮 text: 登录 enabled: true bounds: [40, 560, 680, 640] - id: 4 type: text label: 错误提示 text: 手机号格式不正确 bounds: [40, 280, 680, 310]这个格式有几个讲究。type字段帮模型快速区分元素性质,label是人工可读的语义标签(可以从 content-desc 或邻近文本推断),hint是输入框的占位提示,enabled表示是否可点击。bounds保留是为了执行层用,模型本身不需要关心。
实测下来,这个格式比直接丢 XML 的决策准确率高 20% 以上,而且 token 消耗只有原来的十分之一。
3.3 操作映射器的指令集设计
模型输出的动作需要一套明确的指令集。我定义的动作空间是这样的:
| 动作 | 参数 | 说明 |
|---|---|---|
| tap | element_id | 点击指定元素 |
| input | element_id, text | 在指定输入框输入文本 |
| swipe | direction, distance | 滑动,direction 为 up/down/left/right |
| back | 无 | 返回上一页 |
| wait | seconds | 等待指定秒数 |
| assert | element_id, condition, expected | 断言元素状态 |
| done | 无 | 任务完成 |
这套指令集的关键是assert和done的引入。通用 Agent 框架通常只有前五个动作,但测试场景必须有断言和明确的结束标记。assert的 condition 支持exists、not_exists、text_equals、text_contains、enabled、disabled六种,基本覆盖了移动端测试的常见验证需求。
操作映射器收到模型输出后,先做合法性校验(element_id 是否存在、text 是否为空),再翻译成具体的 Appium 或原生驱动调用。如果校验失败,不直接抛异常,而是把错误信息回传给模型,让它重新决策。这个“回传重试”机制把很多低级错误在 Agent 内部消化掉了,实测减少了约 30% 的无效失败。
3.4 测试运行时的断言与重试策略
测试运行时是包在执行层外面的壳,负责四件事:断言执行、失败重试、现场保留、结果上报。
断言执行方面,assert动作触发后,运行时重新抓取一次 UI 快照,在快照中查找目标元素并验证条件。这里有个细节:断言前需要等待界面稳定。我的做法是断言前先做一次“稳定检测”——连续两次快照的元素数量和关键文本一致,才认为界面稳定。稳定检测的超时设为 5 秒,超过就按当前状态断言。
失败重试方面,我把失败分成三类。第一类是操作失败(元素找不到、点击无响应),这类自动重试 2 次,每次重试前重新抓取快照。第二类是断言失败,这类不自动重试,直接标记用例失败并截图。第三类是模型决策失败(输出格式错误、选了不存在的元素),这类把错误信息回传模型,让它重新决策,最多重试 3 次。
现场保留方面,每次失败都会保存:当前截图、剪枝前后的 UI 树、最近 5 步的操作历史、模型的原始输出。这些信息打包成一个失败报告,排查问题时非常有用。我踩过的坑是早期没保存模型原始输出,导致分不清是模型选错了还是执行层点错了,后来补上之后排查效率提升明显。
4. 实操落地:从零搭建一个可跑的移动端测试 Agent
4.1 环境准备与依赖选型
先说环境。移动端自动化离不开设备,我用的是 Android 模拟器和真机混合的方案。模拟器用于日常调试,真机用于最终验证。Android 这边需要装好 platform-tools 和对应的驱动,iOS 那边需要配置好 WebDriverAgent。这些是移动端自动化的基础,不展开。
Agent 框架这边,我选的是一个支持自定义工具调用的通用框架。选它的理由是它的主循环足够简单,方便我在感知层和执行层插入适配代码。模型用的是支持长上下文和结构化输出的版本,因为 UI 快照加上历史操作记录,上下文长度很容易超过 4K token。
依赖清单大致如下:
# 移动端驱动 appium server # 或直接用 uiautomator2 # Agent 框架 pip install agent-core # 示意,实际用你选的框架 # UI 树处理 pip install lxml # 用于解析 UI XML # 图像处理(截图对比用) pip install opencv-python pillow提示:Appium 的版本要和设备系统版本匹配,我遇到过 Appium 2.x 配 Android 13 时 UI 树抓取不全的问题,降级到 1.22 后正常。选版本时先查兼容性矩阵。
4.2 UI 快照处理器的代码实现
快照处理器的核心是一个递归剪枝函数。输入是原始 UI XML,输出是剪枝后的元素列表。
import xml.etree.ElementTree as ET def prune_ui_tree(xml_str): root = ET.fromstring(xml_str) elements = [] counter = [0] def is_visible(node): bounds = node.get('bounds', '') if not bounds or bounds == '[0,0][0,0]': return False return True def is_interactive(node): return (node.get('clickable') == 'true' or node.get('focusable') == 'true' or node.get('scrollable') == 'true') def has_content(node): return bool(node.get('text', '').strip() or node.get('content-desc', '').strip()) def walk(node, depth=0): if not is_visible(node): return if is_interactive(node) or has_content(node): counter[0] += 1 elements.append({ 'id': counter[0], 'type': infer_type(node), 'label': infer_label(node), 'text': node.get('text', ''), 'hint': node.get('content-desc', ''), 'enabled': node.get('enabled', 'true') == 'true', 'bounds': node.get('bounds', '') }) for child in node: walk(child, depth + 1) walk(root) return elementsinfer_type根据节点的 class 和属性推断类型,比如android.widget.EditText映射为input,android.widget.Button映射为button。infer_label优先用 content-desc,没有就用邻近文本或 class 名。
这个实现有个性能注意点:递归深度在复杂页面上可能很深,Python 默认递归限制是 1000,一般够用,但如果遇到超深嵌套的页面,需要改成迭代实现或者调高限制。
4.3 Agent 主循环与提示词设计
Agent 的主循环是“抓快照 -> 构建提示词 -> 调模型 -> 解析输出 -> 执行动作 -> 判断是否结束”。提示词的设计直接决定决策质量,我的提示词模板大致是这样的:
你是一个移动端测试 Agent。当前屏幕状态如下: {screen_description} 历史操作: {action_history} 测试目标:{test_goal} 请输出下一步动作,格式为 JSON: {"action": "tap", "element_id": 3} 或 {"action": "input", "element_id": 1, "text": "13800138000"} 或 {"action": "assert", "element_id": 4, "condition": "text_contains", "expected": "成功"} 或 {"action": "done"} 注意: 1. 每次只输出一个动作 2. element_id 必须是当前屏幕中存在的 3. 如果测试目标已达成,输出 done 4. 如果连续两次操作没有进展,尝试 back 或 swipe这个提示词的关键是给了明确的输出格式和约束条件。实测下来,加了“每次只输出一个动作”和“element_id 必须存在”这两条约束后,格式错误率从 12% 降到 3% 以下。
历史操作记录我保留最近 10 步,每步记录动作和结果(成功/失败/元素未找到)。这个长度足够模型判断是否陷入循环,又不会让上下文过长。
4.4 一个完整用例的执行过程记录
拿一个登录用例来演示。测试目标是“输入正确手机号和密码,点击登录,验证进入首页”。
第一步,Agent 抓取登录页快照,看到手机号输入框(id=1)、密码输入框(id=2)、登录按钮(id=3)。模型输出{"action": "input", "element_id": 1, "text": "13800138000"}。
执行层找到 id=1 的 bounds,计算出中心坐标,调用驱动输入文本。输入完成后,运行时等待 500ms 让界面稳定。
第二步,重新抓快照,模型输出{"action": "input", "element_id": 2, "text": "Test123456"}。执行层同样处理。
第三步,模型输出{"action": "tap", "element_id": 3}。点击登录按钮。
第四步,等待 2 秒(登录请求需要时间),重新抓快照。此时屏幕变成首页,模型看到首页元素,输出{"action": "assert", "element_id": 5, "condition": "text_contains", "expected": "首页"}。
第五步,运行时执行断言,确认首页文本存在,标记用例通过。模型输出{"action": "done"},循环结束。
整个过程大约 15 秒,其中模型调用占 8 秒左右。如果换成传统脚本,这个用例写起来大概 30 行代码,但改一次 UI 就要改代码。Agent 方案下,UI 改了只要元素语义还在,用例不用动。
4.5 参数调优:超时、重试与上下文长度
几个关键参数的实测值:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 快照稳定检测超时 | 5s | 超过按当前状态处理 |
| 操作后等待时间 | 500ms | 动画较慢的页面调到 1000ms |
| 操作失败重试次数 | 2 | 超过标记失败 |
| 模型决策重试次数 | 3 | 格式错误或选错元素时 |
| 历史操作保留步数 | 10 | 太少模型会重复,太多浪费 token |
| 单用例最大步数 | 30 | 超过强制结束,防止死循环 |
单用例最大步数这个参数很重要。我遇到过模型在某个页面反复 swipe 找不到目标的情况,没有步数限制就会一直跑下去。设成 30 步之后,超出的用例标记为“未完成”,人工介入排查。
5. 常见问题与排查技巧实录
5.1 模型选错元素:原因分析与解决
这是最高频的问题。表现是模型输出了一个 element_id,但那个元素明显不是当前该操作的。排查下来主要有三个原因。
第一个原因是元素 label 不够区分。比如页面上有两个“确定”按钮,一个在弹窗里一个在底部,label 都是“确定”。模型分不清。解决办法是在 label 里加上下文信息,比如“弹窗内的确定按钮”和“底部的确定按钮”。这个上下文可以从元素的父节点或兄弟节点推断。
第二个原因是快照信息缺失。有些元素的关键属性在剪枝时被丢掉了,比如一个按钮的 enabled 状态。模型看到按钮就点了,但按钮其实是灰的。解决办法是确保 enabled、checked、selected 这些状态属性都保留在快照里。
第三个原因是历史操作干扰。模型看到历史里点过某个元素,就倾向于再点一次。解决办法是在提示词里明确“不要重复已成功的操作”,或者在历史记录里标注哪些操作已经完成。
5.2 界面不稳定导致的误判
移动端界面不稳定是常态。弹窗、toast、加载动画、键盘弹出,都会让快照在短时间内变化。我遇到过最坑的情况是:点击登录后,界面先显示一个 loading,然后跳转首页,但 loading 期间抓快照,模型看到的是 loading 页面,以为登录没成功,又点了一次登录。
解决办法是引入“稳定检测”。抓快照前先连续抓两次,间隔 300ms,如果两次的元素数量和关键文本一致,才认为稳定。如果不一致,继续等待,最多等 5 秒。这个机制加上之后,误判率下降了一半以上。
另一个技巧是对已知的临时元素做过滤。比如 loading 指示器、toast 提示,这些元素的 class 或 text 有固定模式,可以在剪枝时直接剔除,避免干扰模型。
5.3 输入法遮挡与文本输入失败
文本输入是移动端自动化的老大难。输入法弹出后会遮挡屏幕下半部分,导致后续元素找不到。而且不同输入法的行为不一致,有的会自动联想,有的会改变键盘布局。
我的处理方案是:输入前先检查输入法状态,如果弹出则先收起。输入时用驱动的 setText 方法而不是模拟按键,setText 直接设置文本值,不经过输入法。输入完成后主动收起输入法,再抓快照。
如果 setText 不生效(某些自定义输入框不支持),退回到模拟按键方案,但需要先切换到系统默认输入法,避免第三方输入法的干扰。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决措施 |
|---|---|---|---|
| 模型反复点同一元素 | 历史记录干扰 | 查看历史操作 | 提示词加去重约束 |
| 元素找不到 | 快照未更新 | 对比快照与截图 | 增加稳定检测等待 |
| 输入文本为空 | 输入法拦截 | 检查输入法状态 | 改用 setText |
| 断言总是失败 | 断言时机太早 | 查看断言时快照 | 断言前加稳定检测 |
| 用例跑不完 | 死循环 | 查看步数 | 设置最大步数限制 |
| 模型输出格式错误 | 提示词不清晰 | 查看原始输出 | 强化格式约束和示例 |
| 点击无响应 | 坐标偏移 | 对比 bounds 与实际 | 检查屏幕缩放和分辨率 |
| 弹窗拦截操作 | 未处理系统弹窗 | 查看当前页面 | 加弹窗检测和处理逻辑 |
5.5 几个踩过的坑和独家技巧
第一个坑是屏幕分辨率适配。UI 树里的 bounds 是基于设备实际分辨率的,但如果你在代码里硬编码了坐标计算逻辑,换设备就会出错。我的做法是始终用 bounds 计算相对坐标,不硬编码任何绝对值。
第二个坑是模型调用的超时设置。模型响应时间波动很大,快的时候 1 秒,慢的时候 10 秒。如果超时设太短,会频繁失败;设太长,用例整体时间拉长。我的经验值是单次调用超时 15 秒,超过就重试。
第三个技巧是用截图辅助模型决策。纯文本快照在复杂页面上信息不够,我把截图也传给模型(如果模型支持视觉输入),决策准确率能再提升 10% 左右。但截图会增加 token 消耗,需要权衡。
第四个技巧是用例级别的缓存。同一个页面在不同用例中反复出现,快照处理结果可以缓存。缓存 key 用页面 UI 树的哈希值,命中缓存直接复用剪枝结果,省掉重复处理时间。实测在回归测试场景下能省 20% 左右的时间。
6. 效果评估与适用边界
6.1 实测数据:成功率、耗时与维护成本
我在一个中等复杂度的 App 上跑了 50 条用例的对比测试。传统脚本方案的成功率是 92%(失败主要是 UI 变更导致的选择器失效),Agent 方案的成功率是 87%。看起来 Agent 略低,但维护成本差异巨大:传统脚本在 UI 变更后需要人工修改约 30% 的用例,Agent 方案下这个比例降到 8% 左右。
耗时方面,传统脚本单条用例平均 12 秒,Agent 方案平均 18 秒。多出来的 6 秒主要是模型调用时间。如果对执行速度要求极高,Agent 方案不占优势;但如果考虑维护成本,Agent 方案的综合成本更低。
| 指标 | 传统脚本 | Agent 方案 |
|---|---|---|
| 首次编写耗时 | 高 | 低 |
| UI 变更后维护 | 高 | 低 |
| 单条执行耗时 | 12s | 18s |
| 成功率 | 92% | 87% |
| 综合成本 | 中 | 低 |
6.2 什么场景适合下沉,什么场景不适合
适合的场景:UI 频繁变更的敏捷项目、用例数量多但变化快的回归测试、需要快速覆盖新页面的探索性测试。这些场景下 Agent 的灵活性优势能充分发挥。
不适合的场景:对执行速度要求极高的性能测试、UI 极其稳定的成熟项目、需要精确控制每一步的合规性测试。这些场景下传统脚本更合适。
还有一个边界是模型能力。如果模型对结构化输出的遵循度不够,或者上下文长度不够处理复杂页面,Agent 方案会频繁失败。选模型时先做小规模验证,确认格式遵循率和决策准确率达标再铺开。
6.3 后续可以扩展的方向
一个方向是多设备并行。Agent 方案天然适合并行,因为每个设备独立跑自己的 Agent 循环。可以做一个调度层,把用例分发到多个设备上并行执行,整体时间能压缩到原来的几分之一。
另一个方向是失败自动修复。用例失败后,Agent 可以尝试分析失败原因,如果是选择器问题就自动调整,如果是断言问题就提示人工确认。这个方向还在探索,目前能做到的是自动分类失败原因,减少人工排查时间。
还有一个方向是与 CI 流水线集成。把 Agent 测试作为流水线的一个阶段,每次提交代码后自动跑核心用例,失败就阻断合并。这个集成本身不难,难的是控制执行时间和稳定性,需要精心挑选核心用例集。
我个人在实际操作中的体会是,Agent 方案不是要替代传统脚本,而是补充。核心的、稳定的用例用传统脚本保证速度和确定性,易变的、探索性的用例用 Agent 保证覆盖和灵活。两者结合,才是移动端自动化测试比较务实的落地方式。