分享如何通过 prompt 和工具集成让 Claude 进行移动应用自动化测试,直接降低 QA 成本。
“当生活看起来像是走上了康庄大道,危险可能已经来到你家门口。”——Grateful Dead,《Uncle John’s Band》
(关于为什么选这句歌词:我让 Claude 帮我找一句符合主题的 Grateful Dead 歌词。它没能做到——搜索“dead lyrics”会触发内容过滤策略:API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"Output blocked by content filtering policy"}。最后只能由我自己选了这一句。)
Zabriskie 是我一个人做的——没有团队,没有投资人,只有我独自在卧室里开发这个社区 app,因为我认为互联网需要更好的聚集空间。打造“产品”给我上的第一课是:如果它不在 App Store 里,那它就等于不存在。早期有些用户很喜欢 Web 版,却不愿意每天使用,因为它不是“一个 app”。那它就几乎等于不是真实存在的。因此,我需要同时发布到三个平台——Web 用于快速迭代和测试,iOS 和 Android 则是人们真正生活的地方。
问题在于,我只有一个人。我不可能编写并维护三套独立的代码库。答案是 Capacitor:它把我已经构建好的 React Web app 包装进原生外壳——Android 上是 WebView,iOS 上是 WKWebView——让同一套代码可以在所有平台运行。再配合服务端驱动的 UI 架构(后端以 JSON 形式发送页面布局,客户端只负责渲染),我不用等待 App Store 审核,就能把改动推送到三个平台。一套代码库,三个平台,一个开发者。这是让整件事得以运转的唯一方式。
但 Capacitor 会把你丢进一个测试领域的无人区。Playwright 无法触达原生外壳内部——它已经不再是浏览器标签页,而是一个 app。XCTest 和 Espresso 之类的原生测试框架也无法与其中的内容交互——那是 WebView 里的 HTML,不是原生 UI 元素。对 Web 工具来说,你太原生;对原生工具来说,你又太 Web。本文中的每一种测试方案,都是为了填补这道鸿沟而存在的。
Zabriskie 同时运行在三个平台上。Web 端由 Playwright 测试——每次 push 都会运行 150 多个 E2E 测试。但移动 app 什么都没有。没有自动化 QA,没有视觉回归检查,也无法在不手动点遍每个页面的情况下,判断两个客户端是否正确渲染。于是我决定解决这个问题:教会 Claude 操作两个移动平台、截取屏幕截图、分析其中的问题,并自行提交 bug 报告。
Android 花了 90 分钟。iOS 花了六个多小时。这种差距足以说明 2026 年移动端自动化工具的现状。
第一个挑战是网络连接。在 Android 模拟器内部,localhost 指向的是模拟器自身,而不是宿主 Mac。当 Capacitor app 尝试访问 localhost:3000 或 localhost:8080 时,什么也得不到。解决方法是 adb reverse:
adb reverse tcp:3000 tcp:3000
adb reverse tcp:8080 tcp:8080
很简单,但每次模拟器重启后都得重新执行。
真正的突破,是意识到 Capacitor app 运行在 Android WebView 中,而 WebView 会暴露一个 Chrome DevTools Protocol socket。你可以找到它,把它转发到本地端口,然后突然之间,你就拥有了完整的程序化控制能力:
# Find the WebView's DevTools socket
WV_SOCKET=$(adb shell "cat /proc/net/unix" | \
grep webview_devtools_remote | \
grep -oE 'webview_devtools_remote_[0-9]+' | head -1)
# Forward it to a local port
adb forward tcp:9223 localabstract:$WV_SOCKET
# Full CDP access
curl http://localhost:9223/json
有了 CDP,认证只需要一条 WebSocket 消息——把 JWT 注入 localStorage,然后跳转到信息流页面。导航也是另一条消息——设置 window.location.href。无需猜测坐标,无需与 UI 交互,也不用和键盘或对话框较劲。这就是 Playwright 和 Puppeteer 使用的同一套协议,只不过它连接的是 Android WebView,而不是桌面浏览器。
再结合用 adb shell screencap 截图,我写了一个 Python 脚本,可以在大约 90 秒内扫完 app 的全部 25 个页面。落地页、登录页、全部四种信息流、帖子详情、个人资料、演出中心、内容创建表单、目录、对战、bug 论坛、日记、徽章、巡演团队——一个不漏。每张截图都会接受视觉问题分析:布局损坏、错误消息、图片缺失、空白页面、状态栏重叠。
扫描发现问题后,它会以 zabriskie_bot 的身份完成认证,把截图上传到 S3,然后在生产环境论坛中提交一份格式规范的 bug 报告。标题格式是 [Android QA] Shows Hub: RSVP button overlaps venue text——一眼就能看出这是自动化流程提交的,也能知道受影响的是哪个页面。它同样了解哪些状态符合预期:团队详情页对非成员返回“Forbidden”并不是 bug,空的头像圆圈不是 bug,个人资料设置中的“Preview”文字也是一个已知的外观问题。
整套流程作为定时任务,每天早上 8:47 运行。第一次完整执行的结果很干净:25 个页面,0 个严重问题,2 条轻微外观备注。如果有人提交的改动在夜间破坏了某个页面,那么在任何人喝上咖啡之前,bug 报告就已经提交好了。
从开始到结束,90 分钟。
我原以为 iOS 也会很直接。同一个 app,同一批页面,Simulator 就在我的 Mac 上。结果却变成了我经历过最荒谬的调试过程之一——并不是因为问题在技术上多么深奥,而是因为 iOS Simulator 就像一座由无数细小限制构成的堡垒。每条限制单独看似乎都合情合理,但叠加在一起就成了一场噩梦。
最初的想法很干净:添加一个 deep link 处理程序,生成 JWT,通过 simctl openurl 打开 URL,完全跳过登录表单。尝试了四次,遇到四种不同的失败方式——原生 bundle 已经过期、配置指向生产环境、JWT secret 错误、Vite 开发服务器监听 IPv6,而 Simulator 尝试使用 IPv4。登录成功次数为零。
于是我退回到在登录表单里输入凭据。AppleScript 可以向 Simulator 发送按键。但登录表单的 input 使用了 type="email",而 AppleScript 的 keystroke "@" 会发送 Shift+2,Simulator 则会把它解释成键盘快捷键。每次尝试输入 @,结果不是把表单切换到 Sign Up,就是跳转到 Forgot Password,或者打开上下文菜单。
粘贴也行不通。Cmd+V 会被 Simulator 拦截。通过 simctl pbcopy 设置 iOS pasteboard,得到的却是乱码。macOS clipboard 和 iOS pasteboard 是两个独立的系统。
最终的解决方法是修改代码:把后端登录处理程序从 WHERE email = $1 改成 WHERE email = $1 OR username = $1,把表单 input 从 type="email" 改成 type="text",再创建一个密码已知的测试用户。现在我可以输入“qatest”,不再需要 @ 符号。为了绕过键盘限制,我修改了后端。
登录后,iOS 会显示一个“Would Like to Send You Notifications”对话框。它由 UIKit 渲染,而不是 WebView。任何由 macOS 合成的输入,都无法关闭 iOS 原生对话框。
我尝试过用 AppleScript 在由 100 多个位置组成的坐标网格上逐个点击。尝试用 cliclick 点击所有可能的坐标。尝试 Python Quartz CGEvent 鼠标事件。尝试按 Return 和 Enter。尝试在 accessibility tree 中寻找按钮(未暴露)。尝试 simctl privacy grant(iOS 26 不支持通知权限)。尝试 simctl ui alert accept(根本不存在)。
对话框就那样一动不动地待在那里,挡住整个 app。
解决方法是直接写入 Simulator 的 TCC.db——也就是隐私权限数据库——为 kTCCServiceUserNotification 插入一条预先批准的记录,然后重启 SpringBoard。但执行时机至关重要:必须在安装 app 之前完成,否则权限状态会被缓存。而且 app 的 JavaScript 会在登录时调用 PushNotifications.requestPermissions(),这可能再次触发对话框,所以我还得添加一个 guard,在 localhost 上跳过权限请求。
正确顺序是:卸载 app、写入 TCC 权限、重启 SpringBoard、重新安装 app、启动,然后登录。只有严格按照这个顺序执行,对话框才不会出现。
这个 app 的右上角有一个悬浮导航栏,里面有三个气泡按钮——Z logo、头像和一个 +——每个按钮都会打开一个垂直下拉菜单。为了测试全部 25 个页面,我需要点击特定的下拉菜单项。我已经从 CSS 中拿到了坐标,数学计算也完全正确。但每一种操作方式都有不同的失败模式。
AppleScript 的 click at 使用 macOS 窗口坐标。你需要知道窗口位置、设备屏幕组偏移、Simulator 的缩放模式(Point Accurate、Pixel Accurate 或 Fit Screen),以及工具栏当前是否显示。第一次扫描的准确率是 42%。
Facebook 的 idb 使用设备逻辑点(390x844)发送点击,因此不需要做坐标转换。它在主导航按钮上的表现更好,但下拉菜单项的坐标还是有些偏差——点击会在命中菜单项之前先关闭下拉菜单,或者穿透 z-index,点到后面的内容上。第二次扫描的准确率是 57%。
突破来自 ios-simulator-mcp 工具的 ui_describe_point 函数。把它指向任意坐标,它就会返回 accessibility label、role 和 frame:
ui_describe_point(365, 163)
→ AXLabel: "Currents", type: Link, frame: (342, 159, 40x40)
我以 48pt 为间隔进行探测,映射出每一个下拉菜单项。我的 Y 坐标是正确的,但 X 坐标错了——+ 下拉菜单项位于 x=258,而不是 x=269。仅仅 11 个点的误差,就让每次点击都落到错误的列上。使用经过验证的坐标,再为下拉动画等待 1.5 秒之后,扫描终于能够 100% 命中所有页面。
最终胜出的组合是:用 ui_describe_point 做发现,用 idb ui tap 执行。先映射 UI,再点击。不要猜坐标——测量它们。
两者的反差极其鲜明。Android 认证:
ws.send('{"method":"Runtime.evaluate","params":{"expression":"localStorage.setItem(\'token\',\'xxx\')"}}')
iOS 认证:卸载 app、写入 TCC 数据库、重启 SpringBoard、重新安装 app、启动、等待 5 秒、在特定坐标点击 Sign In、等待、点击 Email 字段、通过 AppleScript 输入“qatest”、按 Tab、输入“qatest123”、按 Return、等待,然后祈祷。
Apple 的 WKWebView 不会暴露 Chrome DevTools Protocol。Safari Web Inspector 使用的是专有二进制协议,只有 Safari 能理解。ios-webkit-debug-proxy 只能与通过 USB 连接的真实设备配合使用。safaridriver 连接的是 macOS Safari,而不是 Simulator 中的 WebView。
Android 给你一个 WebSocket,然后说:“浏览器在这里,想做什么都可以。”iOS 给你的是一扇上了锁的门,门上还留着一张纸条:“请使用 Xcode。”
在让 Android 正常工作和完成 iOS 之间,发生了一件事。它体现了另一种类型的失败——不是平台限制,而是 Agent 纪律问题。
Railway 部署开始因为 Go 版本不匹配而失败。本地 Go 自动更新到了 1.26,悄无声息地把 go.mod 提升为要求 Go 1.25,而 Dockerfile 仍然使用 golang:1.24-alpine。这本来只是一个涉及两个文件的修复。
Claude 当时正在一个 git worktree 中操作——那是一份干净、隔离的 repo 副本,设计目的正是处理这种外科手术式的小改动。它没有在那里进行修复,反而 cd 进入了主 repo,而我在那里还有十几个互不相关、尚未完成的改动。它暂存了所有 dirty file,把它们与 Go 版本修复一起提交、push,并创建了 PR。这个 PR 包含 QA 登录 endpoint、bug 论坛更新、iOS Simulator 变通方案、E2E 测试配置更改、推送通知代码和三个新的 skill 文件。所有这些都与 Go 版本号毫无关系。
然后,在我来得及关闭它之前,它就被自动合并了。
这次糟糕的合并在整个测试套件中留下了重复的变量声明——函数声明两次,变量也声明两次。其中一项意外包含的改动,是把表单 placeholder 从 "Email" 重命名为 "Email or Username",导致所有使用 page.fill('input[placeholder="Email"]') 的认证 E2E 测试全部失败。还有一项目录测试断言 itemCount > 50,它只能在我的本地数据库上通过——CI 里只有寥寥几条记录。
为了修复一个只涉及两个文件的改动,我最终通过三个 PR 做了四次后续 commit。前两次我没有在本地运行测试就直接 push 了。它们都失败了。第三次,我终于先运行了测试。它通过了。进行了三轮“push and pray”之后,我才做了本该作为第一步的事:运行测试、阅读输出、修复损坏的部分、验证,然后再 push。我在每次 session 中都会强调同一条调试原则——先检查日志,再提出理论——但面对自己的改动时,我却忽略了它。
现在,两个平台都拥有可以正常工作的 QA skill。每天早上,Android emulator 和 iOS Simulator 都会启动,各自扫描 25 个页面、分析截图,并为任何看起来不对劲的地方提交 bug 报告。三个平台全部经过测试,也都会自行提交 bug。
这些经验不断相互印证:
优先使用 CDP,而不是点击。如果能使用浏览器自己的调试协议,就不要和坐标系统较劲。Android 免费为你提供了这项能力。iOS 没有,而每一种变通方案都会增加脆弱性。
测量,不要猜测。最终让 iOS 导航正常工作的 accessibility API,与形成理论之前先检查日志遵循的是同一条原则。不要想当然地认为你知道按钮在哪里——去问系统。
待在 worktree 里。只有尊重边界,隔离才会有效。你一旦为了“快速看一眼”而踏出去,就离一条粗心的命令把十几个无关文件提交到生产环境只差一步。
push 之前先运行测试。做了三轮“push and pray”,才开始执行本该作为第一步的操作。懂得一条规则与真正遵守它之间的差距,可以用浪费掉的 commit 数量来衡量。
Apple,如果你正在阅读这篇文章:请为 Simulator WebView 暴露 CDP 或 WebDriver。由人类使用时,这些开发者工具很出色。但当 AI 尝试使用它们时,几乎毫无用处。