1. 移动端自动化测试的模型拆分困局
做过移动端自动化的人都有一个共同感受:脚本写起来不难,难的是让它稳定跑下去。UI 元素定位漂移、页面加载时序不确定、不同机型渲染差异,这些问题叠加在一起,传统基于固定选择器的脚本维护成本高得离谱。大语言模型介入之后,情况有了转机——模型能理解页面语义,能根据自然语言指令推断操作意图,不再完全依赖硬编码的元素 ID。但新的问题随之而来:一个模型既要理解测试意图,又要解析页面结构,还要生成具体操作指令,任务耦合太重,效果反而不好。
DroidRun 和 Mobilerun 这两个项目在架构设计上做了一个关键决策:把单一模型拆成多个角色,每个角色只负责一件事。这个思路借鉴了软件工程里的单一职责原则,也参考了多智能体协作的范式。具体来说,系统里至少存在三类角色:规划者负责把测试用例拆解成步骤序列,执行者负责把每一步映射到具体的 UI 操作,观察者负责判断当前页面状态是否满足预期。三者通过结构化消息传递协作,而不是让一个模型从头到尾包办。
这套拆分方案解决的核心问题是:让每个模型只处理它最擅长的信息粒度。规划者看到的是测试用例文本和历史执行记录,不需要关心页面上有哪些按钮;执行者看到的是当前页面的元素树和当前步骤描述,不需要理解整个测试的业务背景;观察者看到的是操作前后的页面快照对比,不需要知道下一步要做什么。信息隔离带来的是提示词更短、推理更聚焦、输出更可控。
适合谁来参考这套方案?如果你正在做移动端自动化测试,并且已经尝试过用单个大模型驱动整个流程但效果不理想,那这篇文章就是写给你的。如果你还没接触过 LLM 驱动的测试,但手头有大量重复的移动端回归测试任务,也可以从这里了解一种新的架构思路。下面的内容会从设计动机、角色定义、通信协议、实操配置、问题排查几个维度展开,尽量把每个决策背后的“为什么”讲清楚。
2. 角色拆分的核心设计逻辑
2.1 为什么单模型方案会失败
先说说我踩过的坑。最早尝试用单个大模型驱动移动端测试时,提示词大概是这样的:你是一个移动端测试助手,当前测试用例是“登录后进入个人中心修改昵称”,当前页面元素树如下……请输出下一步操作。这个提示词看起来合理,但实际跑起来问题很多。
第一个问题是上下文长度爆炸。一个中等复杂度的 App 页面,元素树序列化之后动辄两三千个 token,加上测试用例描述、历史操作记录、系统提示词,很容易突破模型的上下文窗口。一旦超限,模型就开始丢信息,要么忘记测试目标,要么忽略页面上的关键元素。
第二个问题是注意力分散。模型同时要理解业务目标、解析页面结构、判断当前进度、生成操作指令,这四个任务需要的注意力模式完全不同。业务目标需要宏观理解,页面解析需要细节捕捉,进度判断需要时序推理,操作生成需要精确输出。让一个模型同时做这四件事,结果就是每件事都做得不够好。
第三个问题是错误传播。单模型方案里,如果模型在某一步误解了页面元素,这个错误会直接影响下一步的决策,而且很难定位到底是哪个环节出了问题。是页面解析错了?还是业务理解偏了?还是操作生成格式不对?排查起来非常痛苦。
2.2 角色拆分的三个原则
DroidRun 和 Mobilerun 的拆分方案遵循了三个原则,我觉得值得单独拎出来说。
原则一:信息隔离。每个角色只接收它完成任务所必需的信息,多余的信息一律不传。规划者不需要知道页面上有哪些按钮,执行者不需要知道整个测试的业务背景。这样做的好处是提示词短、推理快、输出稳定。我实测下来,拆分之后每个角色的提示词长度平均只有单模型方案的 30% 左右,推理延迟降低了差不多一半。
原则二:接口标准化。角色之间的通信必须用结构化格式,不能是自由文本。比如规划者输出的是一个步骤列表,每个步骤包含步骤编号、操作类型、目标描述、预期结果四个字段。执行者接收这个结构,输出的是具体的操作指令,包含操作类型、目标元素定位符、输入内容等字段。标准化接口的好处是每个角色的输出可以被程序解析和校验,不依赖模型输出的自然语言格式。
原则三:可替换性。每个角色背后的模型可以独立替换。规划者可以用推理能力强的模型,执行者可以用响应速度快的模型,观察者可以用多模态能力好的模型。这种灵活性在实际部署时非常重要,因为不同角色的性能瓶颈不一样,统一用一个模型往往会造成资源浪费。
2.3 角色划分的粒度选择
角色拆分的粒度是个需要权衡的问题。拆得太粗,等于没拆;拆得太细,通信开销会吃掉大部分收益。DroidRun 和 Mobilerun 选择的是三到四个角色的粒度,我觉得这个选择很务实。
具体来说,规划者负责把测试用例拆成原子步骤。比如“登录后修改昵称”这个用例,规划者会拆成:打开应用、输入用户名、输入密码、点击登录、等待首页加载、点击个人中心入口、点击昵称字段、清空原昵称、输入新昵称、点击保存、验证昵称已更新。一共十一步,每一步都是一个原子操作。
执行者负责把原子步骤映射到具体操作。比如“输入用户名”这一步,执行者需要根据当前页面元素树找到用户名输入框的定位符,然后生成输入操作。如果页面上有多个输入框,执行者需要根据步骤描述里的语义信息判断哪个是用户名输入框。
观察者负责判断操作结果。比如点击登录之后,观察者需要判断页面是否跳转到了首页,还是弹出了错误提示。观察者的输入是操作前后的页面截图或元素树对比,输出是一个布尔值加置信度。
有些方案还会加一个协调者角色,负责处理异常情况。比如执行者找不到目标元素时,协调者决定是重试、跳过还是终止测试。这个角色在复杂场景下很有用,但也会增加系统复杂度。我的建议是先用三角色方案跑通主流程,遇到频繁异常再考虑加协调者。
3. 各角色的提示词设计与实操要点
3.1 规划者角色的提示词模板
规划者的核心任务是把自然语言描述的测试用例拆解成结构化步骤列表。提示词设计的关键是约束输出格式和提供足够的业务上下文。
我常用的规划者提示词模板是这样的:
你是一个移动端测试规划助手。你的任务是把测试用例拆解成原子步骤序列。 测试用例:{test_case} 应用背景信息: - 应用名称:{app_name} - 主要功能模块:{modules} - 已知的页面跳转关系:{navigation} 拆解要求: 1. 每个步骤必须是原子操作,不可再分 2. 每个步骤包含:步骤编号、操作类型、目标描述、预期结果 3. 操作类型限定为:点击、输入、滑动、等待、验证 4. 步骤之间要有明确的时序关系 5. 如果测试用例包含条件分支,用“如果...则...”描述 输出格式(JSON): { "steps": [ { "step_id": 1, "action_type": "点击", "target": "登录按钮", "expected": "进入登录页面" } ] }这个模板里有几个细节值得注意。应用背景信息这一块很多人会忽略,但它对规划质量影响很大。如果规划者不知道应用有哪些模块,它可能会拆出一些不存在的步骤。操作类型限定是为了让执行者能提前准备好对应的操作处理器,避免出现无法识别的操作类型。条件分支的处理是个难点,我的经验是让规划者用自然语言描述分支条件,执行者在运行时根据实际页面状态决定走哪个分支。
3.2 执行者角色的提示词设计
执行者的输入是当前步骤描述和当前页面元素树,输出是具体的操作指令。提示词设计的关键是元素定位的准确性和操作参数的完整性。
你是一个移动端测试执行助手。你的任务是根据当前步骤描述和页面元素树,生成具体的操作指令。 当前步骤: - 步骤编号:{step_id} - 操作类型:{action_type} - 目标描述:{target} - 预期结果:{expected} 当前页面元素树: {element_tree} 生成要求: 1. 根据目标描述在元素树中找到最匹配的元素 2. 如果找到多个候选元素,选择语义最接近的 3. 如果找不到匹配元素,输出 {"error": "element_not_found", "reason": "..."} 4. 操作指令必须包含完整的定位信息 输出格式(JSON): { "action": "click", "locator": { "type": "resource_id", "value": "com.example.app:id/login_btn" }, "params": {} }这里有个实操心得:元素树的序列化方式直接影响执行者的准确率。我试过三种序列化方式:完整 XML、简化后的元素列表、带语义标注的元素列表。实测下来,带语义标注的元素列表效果最好。具体做法是只保留可交互元素和文本元素,每个元素标注它的类型、文本内容、资源 ID、位置信息。这样既减少了 token 消耗,又保留了关键定位信息。
另一个心得是候选元素排序。当目标描述比较模糊时,执行者可能会找到多个候选元素。我通常会在提示词里要求执行者输出一个候选列表,按置信度排序,然后由程序根据历史成功率选择最可能正确的那个。这个策略在元素文本相似的情况下特别有用。
3.3 观察者角色的判断逻辑
观察者的任务是判断操作是否达到了预期结果。它的输入是操作前后的页面状态,输出是判断结果和置信度。
你是一个移动端测试观察助手。你的任务是根据操作前后的页面状态,判断操作是否达到预期结果。 操作信息: - 操作类型:{action_type} - 目标描述:{target} - 预期结果:{expected} 操作前页面状态: {before_state} 操作后页面状态: {after_state} 判断要求: 1. 对比操作前后的页面变化 2. 判断变化是否与预期结果一致 3. 如果一致,输出 {"result": "pass", "confidence": 0.95} 4. 如果不一致,输出 {"result": "fail", "confidence": 0.8, "reason": "..."} 5. 如果无法判断,输出 {"result": "unknown", "confidence": 0.5, "reason": "..."}观察者的难点在于预期结果的模糊性。比如“进入首页”这个预期,怎么判断当前页面就是首页?我的做法是在提示词里加入应用页面特征库,每个页面用几个关键元素标识。观察者判断时,先匹配页面特征,再判断是否与预期一致。这个特征库可以手动维护,也可以从历史测试记录中自动提取。
3.4 角色间的通信协议设计
角色之间的通信协议决定了整个系统的稳定性和可调试性。我的经验是全部用 JSON 格式,每个消息包含消息类型、发送者、接收者、时间戳、负载五个字段。
{ "msg_type": "step_execution", "from": "planner", "to": "executor", "timestamp": 1700000000, "payload": { "step_id": 3, "action_type": "输入", "target": "用户名输入框", "expected": "输入框显示用户名" } }这种结构化协议的好处是每个消息都可以被日志系统记录和回放,出问题时可以精确定位是哪个角色在哪个环节出了错。我通常会保留最近十次测试的完整消息日志,排查效率比单模型方案高很多。
4. 完整实操流程与配置方法
4.1 环境准备与依赖安装
先说一下基础环境。这套方案对运行环境的要求不算高,但有几个关键依赖需要提前装好。
- Python 3.10 以上,推荐 3.11,因为异步性能更好
- 移动端自动化框架,Android 用 uiautomator2,iOS 用 facebook-wda
- 大模型调用 SDK,根据你用的模型选择对应的包
- 消息队列,轻量级场景用 Python 内置的 asyncio.Queue 就够了
安装命令大概是这样:
pip install uiautomator2 facebook-wda openai pydantic loguru这里有个坑要注意:uiautomator2 的初始化需要设备连接稳定。我遇到过设备断连导致元素树获取失败的情况,后来加了一个重连机制,每次获取元素树之前先检查设备状态,断了就自动重连。这个逻辑虽然简单,但能省掉很多莫名其妙的失败。
4.2 角色调度器的实现
角色调度器是整个系统的中枢,负责接收测试用例、调用规划者、分发步骤给执行者、调用观察者验证结果。我用 asyncio 实现了一个简化版调度器,核心逻辑大概一百行左右。
import asyncio from dataclasses import dataclass @dataclass class TestContext: test_case: str steps: list current_step: int = 0 history: list = None class Orchestrator: def __init__(self, planner, executor, observer): self.planner = planner self.executor = executor self.observer = observer async def run(self, test_case: str): ctx = TestContext(test_case=test_case, steps=[], history=[]) ctx.steps = await self.planner.plan(test_case) for step in ctx.steps: before_state = await self.get_page_state() action = await self.executor.execute(step, before_state) if "error" in action: await self.handle_error(ctx, step, action) continue await self.perform_action(action) after_state = await self.get_page_state() result = await self.observer.verify(step, before_state, after_state) ctx.history.append({ "step": step, "action": action, "result": result }) if result["result"] == "fail": await self.handle_failure(ctx, step, result) break return ctx.history这个调度器的关键设计是每一步都记录完整上下文。before_state、action、after_state、result 四个信息全部落盘,出问题时可以精确回放。我实测下来,这套日志机制在排查偶发失败时特别有用,因为很多失败是时序相关的,不看完整上下文根本定位不到原因。
4.3 元素树获取与预处理
元素树的获取和预处理是执行者准确率的关键。Android 上用 uiautomator2 的 dump_hierarchy 方法,iOS 上用 wda 的 source 方法。原始元素树是 XML 格式,直接丢给模型 token 消耗太大,需要预处理。
我的预处理流程分三步。第一步是过滤,只保留可交互元素(按钮、输入框、可点击的容器)和文本元素。第二步是扁平化,把嵌套的 XML 结构转成扁平列表,每个元素记录它的层级路径。第三步是语义标注,给每个元素加上类型标签和文本描述。
def preprocess_element_tree(xml_str): import xml.etree.ElementTree as ET root = ET.fromstring(xml_str) elements = [] def traverse(node, path=""): attrib = node.attrib if is_interactive(attrib) or has_text(attrib): elements.append({ "path": path, "type": attrib.get("class", "").split(".")[-1], "text": attrib.get("text", ""), "resource_id": attrib.get("resource-id", ""), "bounds": attrib.get("bounds", ""), "clickable": attrib.get("clickable", "false") == "true" }) for i, child in enumerate(node): traverse(child, f"{path}/{i}") traverse(root) return elements预处理之后,一个中等复杂度页面的元素列表大概只有二三十个条目,token 消耗降到原来的十分之一左右。这个优化对成本控制非常关键,尤其是测试用例多的时候。
4.4 操作执行与异常处理
操作执行看起来简单,但异常处理是重头戏。我总结了移动端自动化最常见的五类异常,每类都有对应的处理策略。
| 异常类型 | 典型表现 | 处理策略 |
|---|---|---|
| 元素未找到 | 定位符匹配不到元素 | 重试一次,仍失败则上报协调者 |
| 元素不可点击 | 元素存在但被遮挡 | 等待 500ms 后重试,最多三次 |
| 页面未跳转 | 点击后页面无变化 | 等待 2s 后重新获取页面状态 |
| 输入失败 | 输入框未获得焦点 | 先点击输入框再输入 |
| 应用崩溃 | 进程退出 | 重启应用,从当前步骤重试 |
这些策略不是拍脑袋想的,是实际跑了几百个测试用例之后总结出来的。比如元素不可点击这种情况,很多时候是因为页面还在渲染动画,等 500ms 就好了。页面未跳转往往是网络请求慢,等 2s 比立即重试成功率高得多。
4.5 结果验证与报告生成
观察者验证完之后,调度器会把结果汇总成测试报告。报告包含每个步骤的执行状态、耗时、截图、失败原因。我通常会把报告存成 JSON 和 HTML 两种格式,JSON 用于程序分析,HTML 用于人工查看。
def generate_report(ctx): report = { "test_case": ctx.test_case, "total_steps": len(ctx.steps), "passed": sum(1 for h in ctx.history if h["result"]["result"] == "pass"), "failed": sum(1 for h in ctx.history if h["result"]["result"] == "fail"), "steps": ctx.history } return report报告里我特别关注失败步骤的上下文。一个步骤失败,往往不是孤立事件,可能是前面某一步的副作用。所以报告里会把失败步骤前两步和后一步的信息也带上,方便定位根因。
5. 常见问题与排查技巧实录
5.1 规划者拆解粒度不合理怎么办
规划者拆解太粗或太细都是常见问题。太粗的典型表现是“登录”作为一个步骤,执行者不知道具体要做什么。太细的典型表现是把“点击登录按钮”拆成“移动手指到按钮位置”和“按下”两步,执行者反而懵了。
我的调整方法是在提示词里加正反例。给规划者看两个例子,一个是好的拆解,一个是不好的拆解,让它模仿好的那个。这个方法比单纯描述要求有效得多。另外,我会在提示词里明确“每个步骤对应一个用户可感知的操作”,这个约束能过滤掉大部分过细的拆解。
5.2 执行者定位元素频繁出错怎么优化
执行者定位出错通常有三个原因:元素树信息不足、目标描述模糊、页面有多个相似元素。
元素树信息不足的解决办法是增加语义标注,比如给每个元素加上它在页面中的相对位置(顶部、中部、底部)和相邻元素的文本。目标描述模糊的解决办法是在规划者输出时要求更具体的描述,比如“用户名输入框”比“输入框”好。多个相似元素的解决办法是让执行者输出候选列表,程序根据历史成功率选择。
我实测下来,这三个优化做完之后,执行者的定位准确率能从 70% 左右提升到 90% 以上。
5.3 观察者误判怎么减少
观察者误判主要有两种:把失败判成成功,把成功判成失败。前者更危险,因为会掩盖真实问题。
减少误判的关键是提供更明确的判断依据。我会在提示词里加入页面特征库,每个页面用三到五个关键元素标识。观察者判断时先匹配页面特征,再判断是否与预期一致。另外,对于“验证”类型的步骤,我会要求观察者输出具体的验证依据,比如“页面标题为‘个人中心’,与预期一致”,而不是只输出 pass 或 fail。
5.4 角色间通信超时怎么处理
角色间通信超时通常是因为某个角色的模型调用响应慢。我的处理策略是设置分级超时:规划者超时 30s,执行者超时 15s,观察者超时 10s。超时后先重试一次,仍超时则跳过当前步骤并记录异常。
这里有个经验:超时时间不要设得太短。我一开始把执行者超时设成 5s,结果很多正常请求也被判超时,因为模型偶尔会有响应慢的时候。后来调到 15s,误判率大幅下降。
5.5 多设备并行测试的注意事项
多设备并行时,最大的坑是设备间状态互相干扰。比如两个设备同时操作同一个账号,后操作的会覆盖先操作的结果。解决办法是每个设备分配独立的测试账号,或者用不同的测试数据。
另一个坑是资源竞争。多个设备同时获取元素树时,如果共用一个模型服务,可能会出现请求排队。我的做法是给每个设备分配独立的模型调用配额,避免互相影响。
6. 角色拆分方案的扩展思路
6.1 加入记忆角色提升长期稳定性
三角色方案跑通之后,我尝试加了一个记忆角色,负责记录历史执行中的成功和失败模式。比如某个元素在特定页面状态下总是定位失败,记忆角色会把这个模式记下来,下次遇到类似情况时提前预警。
记忆角色的实现不复杂,本质上是一个向量数据库加检索逻辑。每次执行完一个步骤,把步骤描述、页面特征、执行结果存成一条记录。下次执行前,先检索相似记录,如果有失败模式匹配,就提醒执行者注意。
这个扩展在长期运行的测试套件里效果很明显,尤其是那些偶发失败的用例,记忆角色能帮助快速定位规律。
6.2 用微调模型替代通用模型
通用模型在特定应用上的表现往往不够稳定,因为每个应用的 UI 风格和交互模式都不一样。我试过用历史执行数据微调一个小模型专门做执行者角色,效果比通用模型好不少。
微调数据的构造方法是:把历史执行记录里的页面元素树和对应的正确操作指令配对,作为训练样本。样本量大概几百条就能看到效果。微调后的模型在元素定位准确率上比通用模型高 10 到 15 个百分点,而且响应速度更快。
6.3 跨平台适配的注意事项
Android 和 iOS 的元素树结构差异很大,执行者的提示词需要分别适配。Android 的资源 ID 通常比较规范,iOS 的 accessibility identifier 则经常缺失。我的做法是抽象一层元素描述协议,把两个平台的元素树都转成统一的中间格式,执行者只处理中间格式。
这个抽象层的好处是执行者的提示词可以复用,坏处是转换过程可能丢失信息。我的经验是转换时保留原始属性作为附加字段,执行者需要时可以查看。
7. 我在实际项目中的几点体会
这套角色拆分方案我在三个不同规模的移动端测试项目里用过,最大的感受是前期投入值得。虽然搭建角色调度器和调试通信协议花了不少时间,但后期维护成本比单模型方案低很多。单模型方案每次调整提示词都像拆盲盒,不知道会影响到哪个环节;角色拆分之后,每个角色的提示词可以独立调整,影响范围可控。
另一个体会是日志比什么都重要。角色拆分方案的消息日志天然就是结构化的,排查问题时可以按角色、按步骤、按时间多个维度过滤。我现在的习惯是每个测试用例执行完自动生成一份完整日志,出问题先看日志再动手调试。
最后分享一个小技巧:角色提示词版本化管理。每个角色的提示词都当成代码来管理,用 Git 记录每次修改和对应的测试通过率。这样当通过率下降时,可以快速回滚到上一个稳定版本。这个习惯帮我省了很多次深夜排查的时间。