1. 通用 Agent 框架下沉到移动端测试,到底在解决什么问题
移动端自动化测试这个领域,做了几年的人都有一个共同的感受:工具链其实不缺,Appium、Espresso、XCUITest、Maestro,随便挑一个都能跑起来。真正让人头疼的是用例的维护成本和场景覆盖的碎片化。一个中等规模的 App,UI 自动化用例动辄几百条,页面改一版,定位符挂掉一大片,维护的人比写的人还累。这也是为什么很多团队自动化测试覆盖率始终卡在 30% 上下上不去——不是不想做,是投入产出比算不过来。
通用 Agent 框架这两年在测试圈被反复提起,核心原因就在于它换了一个思路:不再要求你把每一步操作都写死,而是让模型去理解“我要做什么”,然后自己决定怎么点、怎么填、怎么断言。把这种能力下沉到移动端测试场景,本质上是用意图驱动替代脚本驱动。你告诉它“验证新用户注册流程能走通”,它自己去截图、识别控件、规划操作路径、执行点击输入、最后判断结果是否符合预期。
这件事的价值不在于炫技,而在于它把测试用例的表达粒度从“操作步骤”提升到了“业务意图”。页面改版了,只要业务意图没变,用例大概率还能跑;控件换了 ID,Agent 靠视觉和语义重新定位,不需要人去改代码。对于移动端这种 UI 变动频繁、机型碎片化严重的场景,这个特性几乎是刚需。
这篇文章适合两类人看:一类是已经在做移动端自动化、想了解 Agent 框架怎么落地的测试开发;另一类是手里有通用 Agent 框架、想把它用到测试场景但不知道从哪下手的工程师。我会把整个下沉过程中的设计取舍、关键实现、踩过的坑都摊开讲,尽量让你看完能直接对着做。
2. 为什么通用 Agent 框架不能直接拿来跑移动端测试
2.1 通用框架和测试场景的根本差异
通用 Agent 框架(比如那些做浏览器操作、桌面操作的)设计初衷是完成任务,不是验证任务。这两者的差别非常大。完成任务只关心结果对不对,中间怎么走的无所谓;验证任务不仅要拿到结果,还要能判断结果是否符合预期,并且在不符合的时候给出可复现的证据。
我一开始的想法很天真,觉得把通用框架的 Action Space 换成移动端的点击、滑动、输入,接上 ADB 或者 WebDriverAgent 就能跑。实际一跑就发现三个致命问题。
第一个问题是状态不可控。通用 Agent 在网页上操作,页面状态相对确定,刷新一下就是初始态。移动端不一样,App 有登录态、有缓存、有推送弹窗、有权限对话框,你永远不知道点下去会冒出什么东西。Agent 如果没有处理这些“意外状态”的能力,第一步就可能卡死。
第二个问题是断言缺失。通用框架的输出通常是“任务完成”或“任务失败”,但测试需要的是“预期结果 vs 实际结果”的结构化对比。你得让 Agent 在每一步都能产出可校验的中间状态,而不是只有一个最终结论。
第三个问题是可复现性差。模型每次规划路径可能都不一样,今天这么点,明天那么点。测试用例最忌讳的就是不确定性,同样的输入必须得到同样的输出,否则失败了都没法排查。
2.2 下沉需要补齐的三块能力
针对上面三个问题,我在实际项目里给通用框架补了三块能力,这也是整个下沉工作的核心。
状态感知层:在每次动作执行前,先做一次“环境体检”。检查当前前台 Activity 是不是目标页面、有没有系统弹窗、有没有权限请求、网络是否可用。这一步用规则引擎做,不交给模型,因为规则判断快且确定。只有环境干净了,才把控制权交给 Agent 做规划。
结构化断言层:把断言从“最后一步”拆到“每一步”。Agent 每执行一个动作,都要输出一个中间状态快照(页面元素树 + 截图 + 关键文本),然后由断言模块去比对预期。这样即使最终任务失败了,你也能知道是哪一步开始偏的。
确定性约束层:给 Agent 的规划加上约束。比如限定操作空间只能是当前页面可见的控件、限定输入内容必须来自预设的数据池、限定路径深度不超过 N 步。约束越强,可复现性越好,代价是灵活性下降。这个平衡点需要根据你的业务场景去调。
提示:不要一上来就追求全场景通用。先把一个高频、稳定的业务流程(比如登录、下单)跑通,把确定性做扎实,再逐步放开约束。我见过太多团队一上来就想做“万能测试 Agent”,最后连登录都跑不稳。
3. 移动端 Agent 测试框架的整体架构设计
3.1 分层架构与职责划分
整套框架我分成了五层,从上到下依次是:用例层、规划层、执行层、感知层、设备层。每一层的职责边界要划清楚,否则后面调试会非常痛苦。
| 层级 | 核心职责 | 关键组件 | 是否用模型 |
|---|---|---|---|
| 用例层 | 描述业务意图、管理测试数据 | YAML/DSL 解析器 | 否 |
| 规划层 | 把意图拆成可执行动作序列 | Agent Planner | 是 |
| 执行层 | 执行动作、处理异常、重试 | Action Executor | 否 |
| 感知层 | 获取页面状态、元素定位 | UI Tree + 视觉识别 | 部分 |
| 设备层 | 与真机/模拟器通信 | ADB / WDA / U2 | 否 |
这个分层的关键在于规划层和执行层分离。规划层只负责“想”,输出的是抽象动作(比如click(目标描述)),执行层负责“做”,把抽象动作翻译成具体的坐标点击或元素点击。这样做的好处是,当执行失败时,你可以选择是重新规划(规划层的问题)还是重试执行(执行层的问题),排查方向清晰。
3.2 为什么选择“意图 DSL”而不是自然语言
很多人第一反应是用自然语言写用例,比如“打开 App,点击登录,输入用户名密码,点确认,验证进入首页”。听起来很美,但实际用下来问题很多。自然语言太灵活,同一个意思有无数种表达,模型解析出来的动作序列每次可能都不一样,可复现性直接崩掉。
我最后选的是半结构化的意图 DSL。它长这样:
case: 新用户注册 steps: - intent: 打开应用并进入注册页 assert: 页面标题包含"注册" - intent: 填写注册信息 data: ${register_data} assert: 提交按钮变为可点击 - intent: 提交注册 assert: 出现"注册成功"提示intent字段是给模型看的,描述要做什么;assert字段是给断言模块看的,描述预期状态。模型只负责把 intent 翻译成动作序列,不负责判断对错。这样既保留了灵活性,又保证了断言的可控性。
3.3 设备层的选型考量
设备层我最终用的是UiAutomator2 + WebDriverAgent 双引擎。Android 走 UiAutomator2,iOS 走 WDA,通过一层适配器统一接口。为什么不直接用 Appium?因为 Appium 本身也是封装这两者,中间多一层反而增加了不确定性,而且 Agent 需要频繁获取页面元素树,直接调底层 API 延迟更低。
这里有个细节值得说:元素树的获取频率。Agent 每执行一步都要拿一次页面状态,如果每次都全量拉取,在低端机上会明显拖慢速度。我的做法是增量拉取——只拉取当前可见区域和与上一步差异的部分,实测下来单步耗时从 800ms 降到 200ms 左右。
4. 核心实现:从意图到动作的完整链路
4.1 页面状态的结构化表示
Agent 要能“看懂”页面,前提是页面状态得有一个好的表示。我用的方案是元素树 + 视觉特征 + 语义摘要三合一。
元素树来自 UiAutomator/WDA,包含控件的层级、类型、文本、ID、坐标。但光有元素树不够,因为很多控件在树里是空的(比如自定义绘制的按钮),这时候就要靠视觉特征补。视觉特征我用的是一个轻量级的图像编码模型,把截图切成网格,每个网格输出一个特征向量,用来做相似度匹配。
语义摘要是我额外加的一层,用一个小模型把当前页面的关键信息压缩成一句话,比如“这是一个登录页,有用户名输入框、密码输入框、登录按钮和一个忘记密码链接”。这层摘要主要给规划层用,让模型快速理解当前处境,不用去啃整棵元素树。
def get_page_state(device): tree = device.dump_ui_tree() screenshot = device.screenshot() visual_features = encode_image(screenshot) semantic = summarize_page(tree, screenshot) return { "tree": tree, "visual": visual_features, "semantic": semantic, "timestamp": now() }4.2 动作空间的裁剪与约束
通用 Agent 的动作空间通常很大,什么都能干。测试场景不需要这么大,我把动作空间裁到了 8 个:click、input、swipe、back、wait、assert、scroll_to、screenshot。每个动作都带约束。
以click为例,它的参数不是坐标,而是目标描述。执行层拿到描述后,先在元素树里找匹配的控件,找不到再用视觉特征匹配,还找不到就报错让规划层重新规划。这样做的目的是让动作和具体坐标解耦,页面布局变了也不影响。
def execute_click(target_desc, page_state): # 第一优先级:元素树精确匹配 element = match_by_tree(target_desc, page_state["tree"]) if element: return device.click(element.bounds) # 第二优先级:视觉特征匹配 region = match_by_visual(target_desc, page_state["visual"]) if region: return device.click(region.center) # 都失败,抛出可重规划异常 raise ElementNotFound(target_desc)4.3 规划层的 Prompt 设计与输出约束
规划层是整个框架里最需要调的地方。我试过很多版 Prompt,最后稳定下来的结构是这样的:
- 系统提示:定义角色(移动端测试规划器)、可用动作、输出格式(JSON)、约束条件。
- 上下文:当前页面语义摘要、上一步执行结果、剩余步骤预算。
- 任务:当前 intent 的描述。
- 输出要求:只输出一个动作,不要输出解释。
关键约束是一次只规划一步。早期我让模型一次输出整个动作序列,结果它经常在第三步就规划了一个基于假设的页面状态,实际页面根本不是那样。改成单步规划后,每一步都基于真实页面状态,成功率大幅提升,代价是调用次数变多。为了平衡,我加了一个缓存机制:如果当前页面状态和上次遇到的一样,直接复用上次的规划结果,不再调模型。
注意:单步规划虽然稳,但延迟高。如果你的用例步骤很多,建议对稳定流程做规划缓存,或者用一个小模型做快速规划、大模型做兜底。
4.4 断言模块的实现细节
断言模块我单独拎出来讲,因为它是测试框架和通用 Agent 最大的区别。断言分三类:
文本断言:检查页面上是否出现/不出现某段文本。这个最简单,直接遍历元素树的文本字段。
状态断言:检查某个控件的状态,比如是否可点击、是否选中、是否可见。
视觉断言:检查页面截图是否符合预期,比如某个图标是否出现、布局是否错乱。这个用视觉特征做相似度比对,阈值一般设在 0.85 左右,太低会误报,太高会漏报。
def assert_text(expected, page_state, mode="contain"): texts = extract_all_text(page_state["tree"]) if mode == "contain": return any(expected in t for t in texts) elif mode == "exact": return expected in texts return False断言失败时,框架会自动截图、保存元素树、记录当前步骤,方便后续排查。这些证据会打包成一个报告,直接附在测试结果里。
5. 实操过程:从零跑通一个注册流程用例
5.1 环境准备与依赖安装
先说环境。我用的是一台 Android 真机(Android 11)+ 一台 iOS 模拟器(iOS 16),Python 3.10 环境。核心依赖如下:
pip install uiautomator2 weditor pip install facebook-wda pip install opencv-python pillow pip install openai # 用于调用模型,实际用你手上的模型服务Android 侧需要装 UiAutomator2 的 server,iOS 侧需要装 WebDriverAgent。这两步官方文档都有,不展开。重点说一下设备连接稳定性:真机长时间跑测试容易掉线,我加了一个心跳检测,每 30 秒 ping 一次设备,掉线自动重连。
5.2 用例编写与数据准备
注册流程的用例我用 YAML 写,数据单独放一个文件:
# register_case.yaml case: 新用户注册 steps: - intent: 打开应用并进入注册页 assert: type: text value: 注册 mode: contain - intent: 在用户名输入框填写用户名 data: ${username} assert: type: state target: 提交按钮 value: enabled - intent: 在密码输入框填写密码 data: ${password} - intent: 在确认密码框再次填写密码 data: ${password} - intent: 点击提交按钮 assert: type: text value: 注册成功 mode: contain# register_data.yaml username: test_user_${random_int} password: Test@${random_int}数据里用了${random_int}占位符,每次跑生成不同的值,避免重复注册失败。这个细节很关键,我一开始用固定用户名,第二次跑就提示“用户已存在”,排查了半天才发现是数据没清理。
5.3 执行流程与关键日志
执行入口很简单:
from agent_tester import Runner runner = Runner( case_path="register_case.yaml", data_path="register_data.yaml", device="android://emulator-5554", model="your-model-endpoint" ) result = runner.run() print(result.summary())跑起来之后,日志会输出每一步的规划、执行、断言结果。我截一段实际日志:
[STEP 1] intent=打开应用并进入注册页 [PLAN] click(应用图标) -> wait(2s) -> click(注册入口) [EXEC] click 应用图标 ... OK [EXEC] wait 2s ... OK [EXEC] click 注册入口 ... OK [ASSERT] text contain "注册" ... PASS [STEP 2] intent=在用户名输入框填写用户名 [PLAN] click(用户名输入框) -> input(test_user_1234) [EXEC] click 用户名输入框 ... OK [EXEC] input test_user_1234 ... OK [ASSERT] state 提交按钮 enabled ... PASS ... [RESULT] 5 steps, 5 passed, 0 failed, total 42s整个流程跑下来 42 秒,比手写脚本慢一些(手写大概 25 秒),但胜在页面改版不用改用例。我做过一个对比,同一个注册流程,页面改版后手写脚本要改 6 处定位符,Agent 用例一行没改直接跑通。
5.4 参数选择与性能调优
几个关键参数我调了很久,分享下最终值:
| 参数 | 含义 | 最终值 | 调整理由 |
|---|---|---|---|
| max_steps | 单用例最大步数 | 20 | 超过 20 步基本是死循环了 |
| step_timeout | 单步超时 | 15s | 低于 15s 容易误杀慢加载 |
| retry_times | 执行失败重试 | 2 | 重试太多会掩盖真实问题 |
| plan_cache | 规划缓存开关 | on | 稳定流程提速明显 |
| visual_threshold | 视觉匹配阈值 | 0.85 | 实测误报漏报平衡点 |
step_timeout这个值特别重要。设太短,页面加载慢的时候会误判失败;设太长,真卡死了要等很久。我的经验是设成页面平均加载时间的 3 倍,你可以先跑几次统计一下。
6. 常见问题与排查技巧实录
6.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 第一步就卡住 | 环境不干净,有弹窗 | 看截图 | 加环境体检,先关弹窗 |
| 元素找不到 | 定位描述太模糊 | 看元素树 | 细化目标描述 |
| 规划结果每次不同 | 模型温度太高 | 看规划日志 | 温度调到 0.1 以下 |
| 断言误报 | 视觉阈值太低 | 看比对分数 | 阈值调到 0.85+ |
| 跑一半掉线 | 设备连接不稳 | 看心跳日志 | 加自动重连 |
| 用例越跑越慢 | 缓存没命中 | 看缓存日志 | 检查页面状态哈希 |
6.2 三个我踩过的坑
坑一:弹窗处理不能全靠模型。我一开始想让 Agent 自己识别并关闭弹窗,结果它经常把业务弹窗当成系统弹窗关掉,导致用例失败。后来改成规则优先:维护一个系统弹窗的特征库(权限请求、更新提示、广告),命中就直接关,没命中才交给 Agent 判断。
坑二:输入法会干扰输入操作。Android 上不同的输入法行为不一样,有的会自动联想、有的会遮挡输入框。我的做法是测试前统一切换到系统自带输入法,并且用set_text而不是模拟按键输入,绕过输入法直接设值。
坑三:模型调用成本要控制。一个用例跑下来如果每步都调模型,成本不低。我做了两件事:一是规划缓存,相同页面状态不重复调;二是把简单动作(比如back、wait)用规则处理,不走模型。实测下来模型调用次数减少了 60% 左右。
6.3 稳定性提升的几个实操技巧
技巧一:给每个用例加前置清理。跑之前先force-stop应用、清缓存、重置权限,保证每次从干净状态开始。这一步看起来多余,但能避免 80% 的“偶发失败”。
技巧二:断言要分层。关键断言(比如注册成功)必须严格,辅助断言(比如按钮颜色)可以宽松。全严格会导致误报多,全宽松会漏掉真问题。
技巧三:失败自动录屏。断言失败时自动保存最近 10 秒的录屏,比截图有用得多,很多问题看录屏一眼就明白。
技巧四:用例分级。把用例分成 P0(核心流程,每次跑)、P1(重要流程,每天跑)、P2(边缘场景,每周跑)。Agent 用例跑得慢,全量跑不现实,分级能保证核心流程的覆盖频率。
7. 这套方案适合什么场景,不适合什么场景
7.1 适合的场景
UI 变动频繁的业务:比如电商大促期间页面天天改,Agent 用例不用跟着改,省下大量维护成本。
跨机型兼容性测试:同一套意图用例可以在不同机型上跑,Agent 自己适配布局差异,比写多套定位符省事。
探索性测试:给一个业务目标,让 Agent 自己去探索路径,能发现一些人工没想到的边界情况。
长流程业务验证:比如注册-登录-下单-支付这种跨多页面的流程,Agent 的意图驱动方式比脚本更贴近真实用户行为。
7.2 不适合的场景
强性能要求的场景:Agent 每步都要感知和规划,延迟比脚本高,做性能测试不合适。
需要精确控制的场景:比如测试某个动画的帧率、某个手势的精确轨迹,Agent 的抽象动作做不到这个精度。
纯接口测试:没有 UI 的场景用 Agent 是杀鸡用牛刀,直接写接口测试更高效。
对确定性要求极高的场景:比如金融核心流程的回归测试,任何不确定性都不可接受,还是老老实实写脚本。
7.3 成本收益的实话实说
这套方案不是免费的。模型调用有成本,框架搭建有成本,调试有成本。我的经验是,用例数量超过 200 条、且 UI 月均变动超过 3 次的团队,用 Agent 方案才划算。低于这个量级,手写脚本加定期维护可能更省心。
另外,Agent 方案对团队的技术要求更高。你得有人懂模型调优、懂移动端底层、懂测试设计,三者缺一不可。如果团队里只有纯功能测试,直接上这套会很难维护。
8. 后续可以继续深挖的几个方向
这套框架我目前跑在三个业务线上,稳定运行了几个月,但还有不少可以优化的地方。
多模态感知的增强:现在视觉特征用的是轻量模型,对复杂页面的理解还不够。后面想试试用更强的多模态模型做页面理解,提升元素定位的准确率。
用例自动生成:现在是人工写意图用例,后面想试试让模型根据业务文档自动生成用例草稿,人工再审核。这样能把用例编写成本再降一个量级。
失败自动归因:现在失败还是要人工看日志。后面想做一个归因模块,自动判断失败是环境问题、用例问题还是真实 bug,直接给出结论。
跨端统一:现在 Android 和 iOS 是两套适配,后面想抽象出更统一的设备接口,让同一套用例真正跨端跑。
我在实际使用中最大的体会是,Agent 下沉到测试场景,核心不是模型有多强,而是约束设计得有多好。模型再聪明,如果没有好的状态感知、结构化断言和确定性约束,跑出来的结果就是不可信的。反过来,约束设计到位了,哪怕用中等能力的模型,也能跑出稳定的结果。这个平衡点需要你在自己的业务里反复调,没有通用答案。
最后分享一个小技巧:刚开始做的时候,别急着上模型。先用规则把流程跑通,把状态感知和断言模块做扎实,等这些基础设施稳了,再把规划层换成模型。这样出问题的时候,你能快速定位是基础设施的问题还是模型的问题,排查效率高很多。