前言
年后重新开始看效能相关的任务,由于去年主要在接口自动化,所以最近开始做一些 UI 自动化的东西。 目前实践了一个基于视觉驱动的 Midscene,另一个基于单模态大模型驱动的 Stagehand。 今天这篇主要介绍一下 Midscene。
PS: 我之前有过一个使用 cursor 来生成测试用例和自动化测试代码的直播录屏,还没看的同学可以看一下:https://testerhome.com/articles/43375
一、Midscene 是什么?
Midscene.js 是字节跳动 Web Infra 团队开源的 AI 驱动 UI 自动化框架。它的核心理念是:用自然语言描述操作意图,由 AI 来理解页面并执行操作,而不是让你写一堆 CSS 选择器。 比如:

如上图所示,控件的定位方式从以前的 xpath 和 css selector,变成了一段自然语言。 控件定位的形式发生了变化,变的更简单,更灵活。 只要 UI 界面没有发生重大变化,多模态大模型都可以自动调整,找到正确的控件。 因为模型会通过截图和用户提供的定位控件的自然语言的描述,找到对应控件的正确位置并进行操作。 即便前端开发修改了 DOM 树结构也没有关系。
AI 驱动的两种模式
以我的理解, AI 驱动 UI 自动化其实有两种实践方式, 一种是我们上面看到的在运行时使用模型来进行控件定位。而另一种其实是利用 AI 编程工具,辅助编写 UI 自动化测试代码。 在这个阶段, cursor/codebuddy/codex 这些 AI 辅助工具会动态启动浏览器,动态获取 DOM 树,自动识别控件的定位方式(xpath/css selector, 也可以是自然语言驱动的形式),如下图:

Midscene 核心能力
| 能力 | 说明 |
|---|---|
aiInput(locatePrompt, { value }) |
找到输入框并输入内容 |
aiTap(locatePrompt) |
找到元素并点击 |
aiAssert(prompt) |
视觉断言(判断页面状态) |
aiQuery(prompt) |
从页面提取信息 |
const agent = new PlaywrightAgent(page);
// 不需要知道是什么 CSS,直接描述
await agent.aiInput('找到搜索输入框', { value: '提示词模板' });
await agent.aiTap('点击放大镜搜索按钮');
await agent.aiAssert('搜索结果列表已显示');
运行原理
-
截图捕获 →
page.screenshot()获取当前视口图像 - DOM 提取 → 同时抓取可访问性树,提供双重上下文
- 模型推理 → 截图 + 操作意图发送给视觉大模型(Qwen-VL 等)
- 坐标反推 → 模型识别目标元素位置,转换为可执行 locator
- 执行操作 → Playwright 真实点击/输入,生成带截图的可视化报告
二、用 CodeBuddy/cursor/codex/CC + MCP + Skills 编写测试用例
传统方式:手动打开浏览器 → 看 DOM 结构 → 手写选择器 → 写用例代码,流程割裂、效率低。
新方式:AI IDE 全程辅助,从需求到代码一气呵成。
IDE 辅助编写 UI 自动化的整体架构

再看这张图展示了完整的工具链分层架构:
| 层次 | 组成 | 职责 |
|---|---|---|
| AI Agent 层 | Claude Code / Cursor / CodeBuddy | 理解测试需求,生成符合规范的测试代码 |
| MCP 协议层 | Playwright MCP Server(探索/调试) Playwright CLI + Skills(批量/CI) |
连接 AI 与浏览器,提供工具调用能力 |
| 浏览器层 | Chromium / Firefox / WebKit | 真实浏览器执行,支持截图与 DOM 分析 |
| 被测平台 | 登录页 · 应用列表 · 对话页 · 工作流编辑页 | 实际被测系统,AI 直接感知页面状态 |
两条路径的分工:
- 探索性测试/调试:通过 Playwright MCP Server,AI 实时操控浏览器,适合开发期探索页面结构、生成初始测试代码
- 批量测试/CI:通过 Playwright CLI + Skills,在 CI 流水线中批量执行,Skills 保证代码始终符合项目规范
工具链架构
AI IDE
↓ 理解需求意图
Skills
↓ 注入项目架构规范、定位优先级规则
MCP Browser Tool
↓ 实时打开目标页面,抓取真实 DOM
生成符合规范的 TypeScript 测试代码
Skills 的关键作用
在项目根目录的 rules.md 中定义项目规范,AI IDE 会自动将其注入到 AI 上下文中。这意味着 AI 每次生成代码都「知道」:
- 项目是四层架构,禁止用例层
new XxxPage() - 定位优先级:
id > name > type > 语义class > 文案 - v-select 组件有双 DOM 陷阱,必须用
:not(.is-hidden)过滤 - 应用名称必须用
randomAppName()生成,末尾带_qta
效果:AI 不会再犯已知错误,生成代码直接符合团队规范,无需反复纠正。
MCP 浏览器工具
配合 Playwright MCP 工具,AI IDE 可以:
- 实时导航到目标页面
- 抓取真实 DOM 结构(而非猜测)
- 根据真实 DOM 选择最优定位方式
三、四层分层架构设计
整体来说 rules.md 是告诉 AI 应该如何生成代码的,本质上,我们的 UI 自动化框架,应该仍然是我之前分享过的 4 层架构:

对这个架构不了解的同学可以访问:https://testerhome.com/articles/31647
tests/
├── components/base/ # 【组件层】可复用 UI 控件原子操作
│ ├── select.component.ts # v-select 封装(双DOM陷阱)
│ ├── search-input.component.ts # 搜索框(AI 驱动)
│ └── captcha.component.ts # 验证码
├── pages/ # 【页面层】单页面操作封装
│ ├── login.page.ts
│ ├── home.page.ts
│ └── agent-editor.page.ts
├── services/ # 【业务逻辑层】完整业务流程
│ ├── auth.service.ts # 登录流程
│ └── app.service.ts # 创建应用流程
└── cases/ # 【用例层】测试用例 + 断言
├── login.spec.ts
└── optimize-prompt.spec.ts
各层职责边界
| 层 | 职责 | 禁止 |
|---|---|---|
| 组件层 | UI 控件原子操作封装 | 不得依赖 pages/services |
| 页面层 | 单页面操作组合 | 不得跨页面,不得写 expect |
| 业务逻辑层 | 跨页面完整流程 | 不得直接操作 DOM |
| 用例层 | 业务编排 + 断言 | 不得直接 new XxxPage() |
这里可以给出,我给大模型的提示词:
我现在要涉及一个专业的,企业级的 UI自动化测试项目。 所以我需要把代码进行分层。
1. 组件层:因为前端也都是组件化开发的,所以各个组件都是复用的。 比如某个button的放在了多个界面上,所以它的定位方式是一样的。 所以我需要单独一层来进行封装这些组件的定位方式。
2. Page层(页面层):封装单独页面的操作:
3. Service层:在页面层之上,封装成熟的业务逻辑, 可能会调用多个Page的的逻辑。
4. case层:编写测试用例的地方。
上面是我当初初始化项目的时候,用的提示词。
四、混合定位策略:稳定用传统,易变用 AI
这是最重要的经验总结。 Midscene 的基于计算机视觉的控件定位方式虽然好,但它有几个缺点:
- 很烧 token,如果每个控件都走 AI 识别,那必定是一笔非常大的支出。
- 视觉方案执行很慢:我用的公司内部署的多模态大模型,识别一个控件的速度大约在 5s 左右。毕竟要把截图发送给大模型进行识别,这个识别速度很难保证。并且你也无法保证大模型的效果如何,万一大模型抽风,识别错了,就尴尬了。
所以我们注定要使用传统定位方式 +AI 驱动的方式来进行定位。 下面我介绍几个核心原则。
核心原则
不是所有定位都需要 AI,AI 定位有真实的 Token 成本。
传统定位:适用场景
当元素有稳定的 HTML 属性时,永远优先用传统定位:
// ✅ 表单控件有 name 属性 → 最优选
page.locator('input[name="loginName"]')
page.locator('input[name="password"]')
// ✅ 业务语义 class → 稳定
page.locator('button.add-agent')
page.locator('button.add-plugin-btn')
// ✅ v-select 双DOM陷阱专用写法
page.locator('.v-popper.v-select__popper:not(.is-hidden) li:has-text("Multi-Agent模式")')
AI 定位:适用场景
当元素属性不稳定、无语义属性、或是复杂控件时,降级用 AI:
// ✅ 搜索框 → 无稳定属性
await agent.aiInput('找到搜索输入框', { value: keyword });
// ✅ CodeMirror 富文本编辑器 → 无 value 属性
await agent.aiInput('找到提示词编辑区域,在"提示词"文案下方', { value: content });
// ✅ 图标型按钮 → 无文案
await agent.aiTap('点击放大镜搜索按钮');
// ✅ Tab 切换 → class 不稳定
await agent.aiTap('点击"自定义模板" Tab');
五、使用的几个技巧
注意:经过我得计算,每次 AI 调用(aiTap/aiInput)都需要截图并发送给视觉模型,约消耗 500-1500 token。
技巧 1:优先传统定位
最直接的节省方式。90% 的元素都有稳定属性,只有真正不稳定的才用 AI。
技巧 2:AI 调用前等待页面稳定
async fillPromptContent(content: string) {
await this.page.waitForTimeout(5_000); // 等待编辑器渲染完毕
const agent = new PlaywrightAgent(this.page);
await agent.aiInput('找到提示词编辑区', { value: content });
}
页面未稳定时 AI 可能定位失败触发重试,反而消耗更多 token。
技巧 3:封装复用,避免重复写 AI 指令,并且 midscence 有缓存,可能利用缓存(它使用自然语言的描述作为缓存的 key)
// ✅ 封装为组件层,多处复用同一 AI 指令
export class SearchInputComponent {
async search(keyword: string) {
const agent = new PlaywrightAgent(this.page);
await agent.aiInput('找到搜索输入框', { value: keyword });
await agent.aiTap('点击放大镜搜索按钮');
}
}
技巧 4:精确描述缩小搜索范围
// ❌ 模糊描述 → 模型需要分析整个页面
await agent.aiTap('找到输入框');
// ✅ 精确描述 → 模型快速定位
await agent.aiInput('找到"提示词"文案下方的富文本编辑区域', { value: content });
技巧 5:合并连续操作用 aiAction
// ❌ 3 次截图
await agent.aiTap('点击新建按钮');
await agent.aiInput('找到标题输入框', { value: title });
await agent.aiTap('点击提交按钮');
// ✅ 1 次截图(连续操作合并)
await agent.aiAction(`点击新建按钮,填写标题"${title}",点击提交`);
七、完整实战教程:用 CodeBuddy + Midscene 从零搭建 UI 自动化测试
以一个通用 Todo 应用(增删改查)为例,完整演示:环境搭建 → 配置多模态模型 → 用 CodeBuddy 对话生成用例 → 使用 AI 视觉 API 操作页面 → 运行验证。
Step 1:安装依赖
mkdir my-ui-test && cd my-ui-test
npm init -y
# Playwright 测试框架
npm install -D @playwright/test
npx playwright install chromium
# Midscene Playwright 集成包
npm install -D @midscene/web
# 加载 .env 的工具
npm install -D dotenv
Step 2:配置多模态视觉模型
Midscene 需要一个支持视觉的多模态大模型(VL Model)。模型通过 .env 文件配置,支持所有兼容 OpenAI 接口的视觉模型。
创建 .env 文件:
# ── 方案一:使用通义千问 Qwen-VL(推荐,官方支持)──────────────────
MIDSCENE_MODEL_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
MIDSCENE_MODEL_API_KEY="sk-你的阿里云DashScope密钥"
MIDSCENE_MODEL_NAME="qwen-vl-max-latest"
MIDSCENE_MODEL_FAMILY="qwen2.5-vl"
# ── 方案二:使用 Google Gemini ─────────────────────────────────────
# MIDSCENE_MODEL_BASE_URL="https://generativelanguage.googleapis.com/v1beta/openai"
# MIDSCENE_MODEL_API_KEY="你的Gemini API Key"
# MIDSCENE_MODEL_NAME="gemini-2.0-flash"
# MIDSCENE_MODEL_FAMILY="gemini"
# ── 方案三:使用智谱 GLM-4V ───────────────────────────────────────
# MIDSCENE_MODEL_BASE_URL="https://open.bigmodel.cn/api/paas/v4"
# MIDSCENE_MODEL_API_KEY="你的智谱API Key"
# MIDSCENE_MODEL_NAME="glm-4v-plus"
# MIDSCENE_MODEL_FAMILY="glm-v"
# ── 方案四:使用企业自建兼容接口(如腾讯混元)──────────────────────
# MIDSCENE_MODEL_BASE_URL="https://你的企业模型地址"
# MIDSCENE_MODEL_API_KEY="sk-你的密钥"
# MIDSCENE_MODEL_NAME="模型名称/视觉模型"
# 注意:若模型不在官方支持列表,注释掉 MIDSCENE_MODEL_FAMILY 即可
MIDSCENE_MODEL_FAMILY 支持的官方值:
| 值 | 适用模型 |
|---|---|
qwen2.5-vl |
阿里通义千问 Qwen-VL 系列 |
gemini |
Google Gemini 系列 |
glm-v |
智谱 GLM-4V 系列 |
doubao-vision |
字节豆包视觉系列 |
gpt-5 |
OpenAI GPT-5 |
注意:
MIDSCENE_MODEL_FAMILY必须填官方支持的值,否则报Invalid MIDSCENE_MODEL_FAMILY value。如果你的模型不在列表中(如企业私有化部署),注释掉这行,Midscene 会跳过校验直接调用。
Step 3:配置 Playwright 和环境加载
playwright.config.ts
import { defineConfig } from '@playwright/test';
import * as dotenv from 'dotenv';
// 主进程加载 .env(worker 进程需要额外处理,见下方)
dotenv.config();
export default defineConfig({
testDir: './tests',
timeout: 120_000, // AI 推理需要时间,超时设长一些
use: {
headless: false, // 建议非 headless,便于观察 AI 操作过程
baseURL: 'http://localhost:3000',
},
});
tests/helpers/setup.ts(解决 worker 进程读不到 .env 的问题)
Playwright 每个测试文件在独立 worker 进程中运行,主进程的
dotenv.config()不会传递给 worker。需要在每个 spec 文件中手动加载一次。
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config({
path: path.resolve(__dirname, '../../.env'),
override: true, // 覆盖已有变量,确保 worker 中生效
});
Step 4:用 CodeBuddy 对话生成测试用例
这是整个体系最核心的效率来源。不需要手写所有代码——直接和 CodeBuddy 对话,描述测试场景,AI 帮你生成符合规范的代码。
典型对话流程:
你:
我要测试 Todo 应用的新建任务功能。访问 http://localhost:3000,
页面上有一个输入框和"添加"按钮。
请帮我写一个 Playwright + Midscene 的测试用例:
1. 打开页面
2. 在输入框里输入"买牛奶"
3. 点击"添加"按钮
4. 断言列表中出现了"买牛奶"这条记录
输入框没有 id,但有 placeholder="请输入任务名称"。
"添加"按钮是 <button type="submit">。
CodeBuddy:
import { test, expect } from '@playwright/test';
import { PlaywrightAgent } from '@midscene/web/playwright';
test('新建 Todo 任务', async ({ page }) => {
// 打开页面
await page.goto('http://localhost:3000');
// 输入框有 placeholder 属性,用传统定位(稳定)
await page.locator('input[placeholder="请输入任务名称"]').fill('买牛奶');
// 提交按钮有 type 属性,用传统定位
await page.locator('button[type="submit"]').click();
// 断言列表中出现了新任务
await expect(page.locator('li:has-text("买牛奶")')).toBeVisible();
});
你(追问):
任务列表是动态渲染的,有时候加载慢。另外"删除"按钮是一个垃圾桶图标,
没有文案也没有 id,我想补充一个删除的用例,用 AI 视觉来找到它。
CodeBuddy:
test('删除 Todo 任务', async ({ page }) => {
await page.goto('http://localhost:3000');
// 先创建一条任务
await page.locator('input[placeholder="请输入任务名称"]').fill('买牛奶');
await page.locator('button[type="submit"]').click();
// 等待列表渲染完成
await page.locator('li:has-text("买牛奶")').waitFor({ state: 'visible' });
// 删除按钮是图标,没有文案,用 AI 视觉定位
const agent = new PlaywrightAgent(page);
await agent.aiTap('"买牛奶"这条任务右侧的删除按钮(垃圾桶图标)');
// 断言任务已消失
await expect(page.locator('li:has-text("买牛奶")')).not.toBeVisible();
});
你(继续):
帮我把这两个用例整理成规范的 Page Object 结构,把页面操作封装到 page 层,
用例层只调用方法和写断言。
CodeBuddy 会按你的架构规范自动生成完整的分层代码(如果你配置了 .codebuddy/rules.md,生成的代码会直接符合团队规范)。
在 AI 驱动编程的时代,学会耐心的与大模型进行沟通,通过一次次的对话来完成代码编写工作是很必要的。
Step 5:Midscene AI 视觉 API 详解
当传统定位无法胜任时,使用 PlaywrightAgent 的 AI 方法。
import { PlaywrightAgent } from '@midscene/web/playwright';
const agent = new PlaywrightAgent(page);
aiTap(prompt) — 视觉点击
// 找到元素并点击,prompt 用自然语言描述"你想点什么"
await agent.aiTap('页面右上角的用户头像');
await agent.aiTap('"买牛奶"这条任务右侧的红色删除按钮');
await agent.aiTap('弹窗底部的"确认"按钮');
// 精确描述可以减少 AI 推理时间和 Token 消耗
// ❌ 模糊:await agent.aiTap('按钮');
// ✅ 精确:await agent.aiTap('新建任务表单右侧的提交按钮');
aiInput(prompt, { value }) — 视觉输入
// 找到输入框并输入内容
await agent.aiInput('任务名称输入框', { value: '买牛奶' });
await agent.aiInput('页面顶部的搜索框', { value: 'keyword' });
// 适用场景:富文本编辑器(CodeMirror/Quill)、无 name/id 的输入框
await agent.aiInput('正文编辑区域(富文本框)', { value: '这是正文内容' });
aiAssert(prompt) — 视觉断言
// 判断页面当前状态是否符合预期
await agent.aiAssert('任务列表中存在"买牛奶"这条记录');
await agent.aiAssert('页面显示了成功提示 Toast');
await agent.aiAssert('提交按钮处于禁用状态(灰色不可点击)');
aiQuery(prompt) — 从页面提取信息
// 从页面视觉提取结构化数据
const result = await agent.aiQuery(
'{ count: number }',
'任务列表当前显示的任务总数'
);
console.log(result.count); // 例如:5
const status = await agent.aiQuery(
'{ text: string }',
'页面顶部状态栏的文字内容'
);
aiAction(prompt) — 合并多步操作(省 Token)
// 将多个连续操作合并为一次 AI 调用,只截图一次
// 适合填写表单等连续输入场景
// ❌ 低效:3 次截图 + 3 次 AI 调用
await agent.aiInput('标题输入框', { value: '我的任务' });
await agent.aiInput('描述输入框', { value: '任务描述' });
await agent.aiTap('提交按钮');
// ✅ 高效:1 次截图 + 1 次 AI 调用
await agent.aiAction(
'在标题输入框填入"我的任务",在描述输入框填入"任务描述",然后点击提交按钮'
);
Step 6:一个完整的测试用例文件
把以上所有元素放在一起,看一个完整的可运行示例:
import './helpers/setup';
import { test, expect } from '@playwright/test';
import { PlaywrightAgent } from '@midscene/web/playwright';
const BASE_URL = 'http://localhost:3000';
test.describe('Todo 应用', () => {
test('新建任务 - 传统定位(元素有稳定属性)', async ({ page }) => {
await page.goto(BASE_URL);
// ✅ placeholder 属性稳定,用传统定位,Token 消耗为 0
await page.locator('input[placeholder="请输入任务名称"]').fill('买牛奶');
await page.locator('button[type="submit"]').click();
// 等待列表刷新
await page.locator('li:has-text("买牛奶")').waitFor({ state: 'visible' });
await expect(page.locator('li:has-text("买牛奶")')).toBeVisible();
});
test('删除任务 - AI 视觉定位(图标按钮无文案)', async ({ page }) => {
await page.goto(BASE_URL);
// 先创建任务(传统定位)
await page.locator('input[placeholder="请输入任务名称"]').fill('买牛奶');
await page.locator('button[type="submit"]').click();
await page.locator('li:has-text("买牛奶")').waitFor({ state: 'visible' });
// ✅ 等待页面稳定,减少 AI 重试
await page.waitForLoadState('networkidle');
// 删除按钮是图标,无文案无 id,用 AI 视觉定位
const agent = new PlaywrightAgent(page);
await agent.aiTap('"买牛奶"这条任务右侧的删除按钮');
await expect(page.locator('li:has-text("买牛奶")')).not.toBeVisible();
});
test('标记完成 - AI 视觉断言验证状态', async ({ page }) => {
await page.goto(BASE_URL);
await page.locator('input[placeholder="请输入任务名称"]').fill('读一本书');
await page.locator('button[type="submit"]').click();
await page.locator('li:has-text("读一本书")').waitFor({ state: 'visible' });
// 勾选复选框(有 type 属性,传统定位)
await page.locator('li:has-text("读一本书") input[type="checkbox"]').click();
// ✅ 用 AI 断言验证视觉状态(文字是否出现删除线样式)
const agent = new PlaywrightAgent(page);
await agent.aiAssert('"读一本书"这条任务显示为已完成状态(文字带删除线或变灰)');
});
test('批量操作 - 用 aiAction 合并多步,节省 Token', async ({ page }) => {
await page.goto(BASE_URL);
// 一次 AI 调用完成整个表单填写流程
const agent = new PlaywrightAgent(page);
await agent.aiAction(
'在任务名称输入框填入"周会准备",' +
'在优先级下拉选择"高",' +
'在备注输入框填入"需要准备 PPT",' +
'然后点击添加按钮'
);
await expect(page.locator('li:has-text("周会准备")')).toBeVisible();
});
});
Step 7:运行与查看报告
# 运行所有测试
npx playwright test
# 运行单个文件
npx playwright test tests/todo.spec.ts
# 有头模式运行(可以看到浏览器操作过程)
npx playwright test --headed
# 查看 Midscene 可视化报告(每个 AI 操作都有截图 + 推理结果)
open midscene_run/report/*.html
# 查看 Playwright 测试报告
npx playwright show-report
Midscene 报告示例 — midscene_run/report/ 下的 HTML 报告会展示:
- 每次
aiTap/aiInput的截图 - AI 识别到的目标元素位置(红框标注)
- 模型的推理过程和置信度
- 整体执行时间和 Token 消耗
结尾
目前使用的还没有很长时间,感受上还不错,我在另一个项目里也在实践另一套 AI 驱动的方案(基于单模态而非多模态的计算机视觉方案),感受都还不错。 下次会分享另一个方案。 另外 AI 生成测试用例的提示词,各种 SKILL 的编写提示词我都记录在了星球里,感兴趣的同学可以加入一下我得星球:
