Midscene 做 UI 测试:把脆弱的选择器换成语义指令 aiAct()
UI 自动化里最贵的从来不是写用例,而是修用例。
一个后台系统跑了一年,用例涨了四倍,通过率却越来越难看。翻失败日志,八成不是业务真出了 bug,而是前端换了个容器的 class,或插了一行提示文案,让 div:nth-child(2) 指向了别的元素。这类失败有个共同特征:报错看着像业务故障,排查完发现是定位故障。
字节开源的 Midscene.js 给了另一个思路:把「点击登录」写成自然语言指令 aiAct(),靠视觉与语义找元素,而不是靠你手写的那串选择器。这篇不讲怎么手写更稳的选择器、怎么用 data-testid 降脆性——那是入门功课,另一篇讲过;这篇假定你已经吃过选择器的亏,想知道 AI 语义定位能换来什么、代价是什么。
一、业务场景:一个两周一改版的运营后台
场景钉死:公司内部运营后台,商品、订单、活动三块,前端两个小组轮着迭代,平均两周一次改版。测试侧维护着 38 条 E2E 用例,核心链路是「登录 → 进商品列表 → 按状态筛选 → 查看详情 → 断言字段」。痛点有三个。
第一,DOM 结构不稳定,但视觉语义极稳定。登录按钮永远写着「登录」,半年里 class 换过三次,文案一个字没改——脆的是绑定定位的方式,不是页面本身。
第二,改版不由测试主导,通知链经常断。前端重构组件库,测试是在 CI 全红之后才知道的。一次改版平均牵动 17 条用例,修一轮花掉一个人两天。
第三,这条链路是回归必跑项,不能砍。登录+查询是后台入口,挂了就全挂。所以问题不是「要不要自动化」,而是「怎么让自动化少受 DOM 变动影响」。
二、硬选择器为什么最脆
选择器的脆,本质是它绑定了实现细节,而实现细节是最常被重构的东西。CSS 选择器和 XPath 绑定 DOM 路径与类名,这两样在组件库升级、设计系统替换时往往一并被改掉。你以为在定位「登录按钮」,实际在定位「第 2 层 div 的第 3 个子节点里那个带 primary 类的元素」。语义指令绑定的是用户视角的意图——描述功能而非结构,所以改版前后都成立。
| 维度 | 硬选择器(CSS / XPath) | 语义指令(aiAct) |
|---|---|---|
| 绑定对象 | DOM 结构、class、层级 | 可见文本、位置、功能语义 |
| 改版抗性 | 低:换 class / 插容器 / 改组件库即失效 | 高:文案与视觉角色不变即命中 |
| 失败表现 | 报 element not found,像业务故障 |
带截图与推理过程,可回放 |
| 耗时与成本 | 毫秒级,近乎零成本 | 秒级 + token 费用,需核算 |
| 适合环节 | 稳定页面、高频冒烟、性能敏感断言 | 频繁改版的业务链路、跨版本回归 |
最容易被读错的是最后一行。语义指令不是选择器的替代品,是补充品。稳定页面和冒烟集,硬选择器依然最优;把全部用例改写成 aiAct,你只会得到一份跑得慢、花钱多、更难调试的资产。
三、六个 ai 方法的分工
Midscene 在 Web 侧的核心是六个方法,分工搞混是最常见的误用。aiAct() 是多步规划入口:给它一句自然语言,它自己拆成若干步逐步执行,强在承接完整意图,代价是慢且贵。aiTap / aiInput / aiQuery / aiAssert / aiWaitFor 是单步动作,目标明确时直接用,跳过规划环节。
| 方法 | 干什么 | 典型写法 | 什么时候用 |
|---|---|---|---|
aiAct |
规划并执行多步操作 | aiAct('登录后台并进入商品列表') |
完整意图、步骤会变 |
aiTap |
点击语义描述的元素 | aiTap('右上角「新建活动」') |
只点一下,不付规划成本 |
aiInput |
向语义输入框写文本 | aiInput('测试商品A', '顶部搜索框') |
框的 class 不稳 |
aiQuery |
提取页面结构化数据 | aiQuery('前 5 行的名称与状态') |
页面内容要变成可断言数据 |
aiAssert |
自然语言断言页面状态 | aiAssert('顶部显示「登录成功」') |
断言视觉/文案而非 DOM |
aiWaitFor |
等待语义条件成立 | aiWaitFor('列表已加载出一行') |
替代固定 sleep |
一条经验规则:能用单步就别用 aiAct。这一步永远是「点登录」,aiTap('登录按钮') 更快更省也更可预测;反过来,一段流程每次改版都要重排步骤,硬拆成一串 aiTap 等于把维护成本从选择器搬到行号上。而 aiQuery 与 aiAssert 的真正价值,是把断言从「某节点的 text 等于某串字符」换成「页面呈现了登录成功这件事」。

四、可运行代码:PlaywrightAgent + aiAct 最小登录用例
Web 侧通过 PlaywrightAgent(包名 @midscene/web/playwright)接入,它在 Playwright 的 Page 之上挂了一层语义能力。下面是一份能直接跑的最小登录+查询用例。
依赖与环境(Node 18+):
npm init -y
npm i -D @midscene/web playwright typescript ts-node @types/node
npx playwright install chromium
# 模型接入:Midscene 需要一个多模态模型来做视觉/语义定位,
# 按官方文档配置你的 API Key 与模型名,这里只给环境变量占位。
export OPENAI_API_KEY="sk-xxxx"
export MIDSCENE_MODEL_NAME="你的多模态模型名"
// tests/login.spec.ts
// 运行:npx ts-node tests/login.spec.ts
import { chromium } from "playwright";
import { PlaywrightAgent } from "@midscene/web/playwright";
const BASE_URL = process.env.BASE_URL || "https://admin.example.internal";
async function main() {
const browser = await chromium.launch({ headless: false });
const page = await browser.newPage();
// cacheId:同一条流程多次跑时复用规划结果,是压耗时的关键开关。
// 参数写法以官方文档当日版本为准;页面大改后应换 id 或清缓存重跑。
const agent = new PlaywrightAgent(page, { cacheId: "admin-login-query" });
try {
await page.goto(`${BASE_URL}/login`, { waitUntil: "domcontentloaded" });
// 1) 多步意图交给 aiAct:登录这一步里包含填账号、填密码、点登录
await agent.aiAct(
`在登录页输入账号 ${process.env.TEST_USER}、密码 ${process.env.TEST_PASS},然后点击「登录」按钮`
);
// 2) 等语义条件成立,而不是 sleep 一个固定秒数
await agent.aiWaitFor("页面左侧出现了「商品管理」菜单,说明已进入后台首页");
// 3) 单步动作:目标明确就别再付规划成本
await agent.aiTap("左侧菜单里的「商品管理」");
await agent.aiInput("测试商品A", "页面顶部的商品名称搜索框");
await agent.aiTap("搜索框右侧的「查询」按钮");
// 4) 把页面读成结构化数据,便于做精确断言
const rows = await agent.aiQuery(
"{name:string, status:string, price:number}[],返回商品列表表格里所有行的名称、状态与价格"
);
console.log("查询结果:", rows);
// 5) 两类断言并用:语义断言管视觉结果,普通断言管数据正确性
await agent.aiAssert("表格中出现了名称为「测试商品A」的一行,且列表不为空");
if (!rows?.length) throw new Error("查询结果为空,链路不通");
} catch (e) {
// Midscene 会生成带截图与推理过程的执行报告,失败时先看报告再看 DOM
console.error("用例失败:", e);
await page.screenshot({ path: "failure.png", fullPage: true });
throw e;
} finally {
await browser.close();
}
}
main();
几处关键写法说明。
aiAct 里塞了三个动作,这是刻意的。 登录框改版最常变的是字段顺序、验证码提示、按钮文案;整段交给 aiAct,一次改版你只改一句自然语言,而不是三个选择器。
aiWaitFor 替代 page.waitForTimeout(3000)。 固定等待是自动化里第二贵的债:等短了 flaky,等长了拖慢流水线。
aiQuery 拿到结构化数据后,精确断言交回普通代码。 数值比较、行数校验在 JS 里判断更可靠,也更省 token。
账号密码走环境变量,不要写进自然语言指令。 aiAct 的指令会被写进执行报告和缓存,明文密码留在报告里是安全事故。
五、缓存:把重复规划从 51s 降到 28s
引入 AI 语义定位,最先撞上的墙是慢:每一步都要截图、送模型、拿回定位结果,一条十步的用例轻松破分钟。Midscene 官方的解法是缓存——同一条流程重复执行时复用已完成的规划结果,跳过重复的模型规划。官方数据是耗时从 51s 降到 28s(。
接近腰斩,但落地时有三条必须自己守住:
第一,缓存要绑 cacheId,一条流程一个 id。 所有用例共用一个 id,A 的规划结果会被套到 B 上——症状是「本地能跑,CI 上乱点」,非常难查。
第二,页面大改后要主动失效缓存。 缓存存的是「这条指令对应这些步骤与位置」,页面重排后旧缓存就是错的;改版合入时同步清缓存或换 id,这条要进前端-测试的联动清单。
第三,缓存只改耗时结构,不改成本结构。 首跑仍付全额规划开销,省的是重复跑——它在「同一条流程每天跑几十遍」的 CI 里收益最大,在一次性脚本里几乎没有。
六、成本口径:$0.59 / 60 任务该怎么读
第二个墙是钱。官方给的成本参照是60 个任务花费 $0.59,折算一个任务不到一美分——但这个数字必须读对:它是官方基准任务集上的口径,不是你 Web 后台的口径,任务复杂度、截图分辨率、模型单价都不同。你要做的不是引用它,而是用它验证数量级——单任务成本落在「美分」而非「美元」量级,这条路线在经济上就成立。真正的放大器是用例数量与执行频次:400 条用例、每天 CI 跑 6 轮,就是 2400 次任务/天。落地前先算一遍:
| 口径 | 怎么估 | 控制手段 |
|---|---|---|
| 单任务成本 | 3—5 条真实用例跑一遍,账单除以任务数 | 优先单步方法,少用 aiAct 做长规划 |
| 日执行次数 | 用例数 × CI 触发轮次 | 分层:语义用例只进回归集,冒烟留硬选择器 |
| 重复与重试开销 | 复跑占比 + flaky 率 × 重试次数 | 绑 cacheId;用 aiWaitFor 等语义,别固定 sleep |
最后一行最容易被忽略:flaky 是成本放大器,一条 30% 概率重试的用例,账单是 1.3 倍还不止。
再说能力边界。官方基准显示 Midscene 在 AndroidWorld 上 Pass@1 为 93.10%(Midscene 1.9.5 + Gemini-3.5-Flash)、MobileWorld 为 78.63%(92/117)、AppControlBench 为 96.7%(58/60)。三个分数分属三个不同榜单,不能混读也不能取平均;且都是移动端 / GUI Agent 场景的成绩,只能说明「语义定位这条路跑得通」,不能换算成你 Web 后台用例的通过率。
七、什么时候不要用 Midscene
以下四种情况,我建议继续用硬选择器。
页面已经稳定、且打了 data-testid。 选择器一年不动一次,语义定位带来的只有耗时和账单。
性能与并发敏感的压测脚本。 几百并发打同一个流程时,每步多一次模型往返不可接受。
断言对象是数值与状态机,不是视觉。 「金额等于 99.00」「状态从待支付到已支付」,走接口或数据库比看页面可靠。
团队还没能力维护执行报告与缓存。 排查依赖报告与截图回放,缓存失效依赖流程约定;没人管,你会得到一套「跑得慢、还查不清」的用例。
反过来,它最该上的位置很清楚:改版频繁、DOM 不稳、但视觉语义稳定的业务链路回归。节奏上先挑 1—2 条最痛的链路试点两周,看维护工时和账单再决定铺不铺开——别一次性把 38 条全改。
八、写在最后
选择器绑的是实现,语义指令绑的是意图——实现天天变,意图很少变。
UI 自动化的维护成本,大头不在写用例,而在前端改了一个我们并不关心的实现细节之后。把定位从「第 2 层 div 的第 3 个子节点」换成「点击登录按钮」,本质是让用例和前端重构解耦:他们改组件库,我们改一句中文。代价是秒级耗时和 token 账单——这笔账在频繁改版的后台系统上算得过来,在稳定页面上算不过来。先算账,再改造,别反过来。
我们在整理 GUI Agent 在测试侧的落地做法,如果你们的 UI 用例也在被前端改版反复打红、想评估语义定位值不值得引入,留言区聊聊你们的改版频率和维护工时。
- 点赞
- 收藏
- 关注作者
评论(0)