测试开发之路 设计一个多 Agent 的 UI 自动化测试工程(一)基础原理

孙高飞 · 2026年07月20日 · 1763 次阅读

开篇

上一篇介绍了多 Agent 协作的相关内容, 今天我们来实战一下如何用这个理念来设计一个 AI 驱动的 UI 自动化测试工程。

AI 驱动 UI 自动化的模式

在我这里 AI 驱动 UI 自动化有两种模式, 分别是:

  • AI 驱动编写 UI 自动化测试脚本,而不是由人类编写脚本。这个依赖于我们对于多 Agent 的构建以及 Skill 的编写。
  • 执行 UI 自动化的时候,UI 控件的定位由模型来完成(通过计算机视觉, 也就是运行期间动态截图,然后发送给多模态大模型,我们描述这个控件长什么样子, 大模型则根据我们的描述和截图识别出控件的具体位置)。 这个依赖于相关框架,我这里使用的是 midsence。 相关的原理我写过文章, 可以参考我写的帖子:Midscene 视觉定位与交互机制深度解析:https://testerhome.com/articles/44376

而今天我们要讲解的就是第一种,怎么编写一个稳定可靠的多 Agent 协作的 Skill 来完成我们的代码编写工作。当前这里也补充一下第二种模式的基本概念, 以免有对 midsence 还不了解的同学看了以后蒙圈。

什么是多模态大模型

所谓单模态就是只能接受一种格式的数据,比如文本。而多模态则是可以接受不同的数据格式,我们可以在一次对话中给大模型输入图片和文字。比如上传一张图片,然后配上一段文字:“请问图片中蓝色的名字叫提交的按钮在什么位置。” 这时候多模态大模型就可以根据你的文字描述和图片数据找到这个按钮并回答用户。 这就是通过视觉方案来定位 UI 控件的原理。

AI 识别控件的背景下要如何编码

点击操作:

await this.getAgent().aiTap('在「添加用户」弹窗底部右侧的「确定」按钮');
await this.getAgent().aiTap('「添加 Agent」弹窗中的「新建」按钮或链接');
await this.getAgent().aiTap('搜索框右侧的放大镜图标按钮');

input的输入操作


// 编辑场景:清空旧值并输入新值(默认 replace 模式,自动先清空)
await this.getAgent().aiInput("标题输入框", { value: newTitle });

// 搜索框输入
await this.getAgent().aiInput("搜索框", { value: keyword });

aiKeyboardPress() — 按键

await this.getAgent().aiKeyboardPress('搜索框', { keyName: 'Enter' });
await this.getAgent().aiKeyboardPress('任意位置', { keyName: 'Escape' });

aiHover() — 悬停

await this.getAgent().aiHover('工具行右侧的「···」按钮');

aiDoubleClick() — 双击

await this.getAgent().aiDoubleClick('文件名称');

以往很多刚入门的同学会困惑, AI 是可以编写代码, 但 AI 怎么知道浏览器中这些控件的定位方式是什么? 也就是说, 它怎么知道 xpath 怎么写? 如果大家用了 midsence 这样的基于视觉的框架就明白了,现在的 UI 定位不用依靠 xpath,而是我们人类去用文字描述对应控件在什么位置。

让 AI 写代码的提示词

所以基于以上的基础内容,我们就可以让 AI 编写代码的时候, 跟它说这样一段话:

1. 创建Agent模式应用
2. 点击 页面左上角的 应用名称 左边的 图标,弹出 编辑应用弹窗
3. 在应用名称中 输入一个新的 名字
4. 点击 确定按钮
5. 验证页面应用名字被修改成新的名字了

我们来看这段提示词:

  • 第 2,3,4 步,我们在提示词中已经描述了要做的操作, 和对应的控件在什么位置以及长什么样子。 这样 AI 就可以把代码编写成我们上面说道的 midsence 的 API 代码。包括 aiTap 的点击操作,以及 aiInput 的输入操作。
  • 第 1 步,可能有同学会困惑在第一步我们可没有告诉 AI 具体的控件操作步骤和位置, 只是用一句很简单的创建应用就完事了, 而这个创建应用可能包括了 N 个操作。AI 是怎么知道要怎么写的?这就涉及到我们之前讲到的知识库了,即便用户没有刻意的构造知识库,但我们说过,当前项目中的每一个代码文件都会被 AI 辅助工具 embedding 并保存到向量数据库中(没有这个机制的也会用 rgrep 去检索)。所以只要你编写过一次创建 Agent 应用的代码,并在方法名称,代码注释等地方说明过这就是在创建应用。 那么大模型就能检索到,所以再写第二次的时候, 就不用每一个步骤都写的那么详细了,表明基本意图后大模型会自行推理和检索到相关代码。 也就是: 编写用例的时候,只有新的操作和控件需要编写很细节的步骤和定位方法
  • 第五步,这样写 AI 会参考 midsence 的 API,自动转换成断言 API 的。 而这个断言 API 模型是怎么知道的呢? 当然是我们有对应的 API 文档了。 比如下面那个是我总结的 API 文档。
# Midscene API 参考

> 来源:https://midscenejs.com/zh/api.html + https://midscenejs.com/zh/web-api-reference  
> 本项目使用 **PlaywrightAgent**,通过 `this.getAgent()` 获取实例。

---

## 🚨 方法选择强制规则

> **编写任何 aiFallback 前,必须先按优先级顺序检查本文档,使用已定义的即时操作方法。`aiAction` 是最后手段。**

**优先级从高到低:**

| 优先级 | 操作类型 | 必须使用的方法 |
|--------|---------|--------------|
| 1 | 输入文本(input/textarea) | `aiInput(locate, { value })` |
| 2 | 点击按钮/链接/标签 | `aiTap(locate)` |
| 3 | 滚动页面/容器 | `aiScroll(locate, opt)` |
| 4 | 按键盘键 | `aiKeyboardPress(locate, { keyName })` |
| 5 | 悬停 | `aiHover(locate)` |
| 6 | 双击 | `aiDoubleClick(locate)` |
| 7 | 提取数据 | `aiQuery()` / `aiBoolean()` / `aiString()` / `aiNumber()` |
| 8 | 断言/等待 | `aiAssert()` / `aiWaitFor()` |
| **最后** | **上述方法无法覆盖的复合多步骤操作** | **`aiAction()`** |

> ❌ **禁止行为:**
> - 能用 `aiTap()` 点击的,不得用 `aiAction("点击XXX")`
> - 能用 `aiInput()` 输入的,不得用 `aiAction("在XXX输入框输入YYY")`
> - 能用 `aiScroll()` 滚动的,不得用 `aiAction("向下滚动")`
> - 不存在的方法(如 `aiClearInput`)一律禁止,改用对应的即时操作方法

---

## 核心概念

| 类型 | 代表方法 | 特点 |
|------|---------|------|
| **自动规划** | `aiAction()` / `aiAct()` / `ai()` | AI 自动拆解步骤执行,**最后手段** |
| **即时操作** | `aiTap()` / `aiInput()` 等 | AI 只负责元素定位,直接执行指定操作,**更快更可靠,优先使用** |
| **数据提取** | `aiQuery()` / `aiBoolean()` 等 | 从页面提取结构化数据 |
| **断言等待** | `aiAssert()` / `aiWaitFor()` | 验证页面状态 |

> ⚡ **选型规则:优先即时操作,`aiAction` 是最后手段**
> - 点击单个元素 → `aiTap()`(**禁止**用 `aiAction()`)
> - 输入文本 → `aiInput()`(**禁止**用 `aiAction()`)
> - 滚动 → `aiScroll()`(**禁止**用 `aiAction()`)
> - 只有上述方法都无法完成时,才允许使用 `aiAction()`

---

## 一、自动规划方法

### `aiAction()` / `aiAct()` / `ai()`

> 三者等价,`aiAction()` 为旧版写法,本项目代码中均有使用


await this.getAgent().aiAction('在搜索框中输入 "JavaScript",然后点击搜索按钮');
await this.getAgent().aiAct('填写表单并提交');


**签名:**

function aiAction(
  prompt: string,
  options?: {
    cacheable?: boolean;      // 默认 true,是否允许缓存
    deepThink?: 'unset' | true | false;  // 规划阶段深度思考,默认 'unset'
    deepLocate?: boolean;     // 是否开启深度定位,默认 false
    abortSignal?: AbortSignal;
  }
): Promise<void>;


**何时用(最后手段):**
- **仅当本文档中的即时操作方法(`aiTap`、`aiInput`、`aiScroll` 等)均无法覆盖时**,才允许使用
- 典型合法场景:需要同时完成"输入 + 按回车搜索"且无法拆分的连贯操作
- 典型非法场景:点击按钮(用 `aiTap`)、输入文本(用 `aiInput`)、滚动(用 `aiScroll`
---

## 二、即时操作方法(推荐优先使用)

### `aiTap()` — 点击元素


await this.getAgent().aiTap('页面顶部的登录按钮');
await this.getAgent().aiTap('「提交」按钮', { deepLocate: true });


**签名:**

function aiTap(
  locate: string | Object,
  options?: {
    deepLocate?: boolean;   // 默认 false,小元素时开启
    xpath?: string;          // 优先用 xpath 定位
    cacheable?: boolean;     // 默认 true
  }
): Promise<void>;


**定位描述写法要求:**

| 要素 | 示例 |
|------|------|
| 容器上下文 | `"在「添加用户」弹窗中"` |
| 视觉位置 | `"底部右侧的"` |
| 元素文案 | `"「确定」按钮"` |
| 元素类型 | `"按钮"、"输入框"、"下拉框"` |

完整示例:

await this.getAgent().aiTap('在「添加用户」弹窗底部右侧的「确定」按钮');
await this.getAgent().aiTap('「添加 Agent」弹窗中的「新建」按钮或链接');
await this.getAgent().aiTap('搜索框右侧的放大镜图标按钮');


---

### `aiInput()` — 输入文本(**所有输入框操作必须使用此方法**)

> 🚨 **强制规范:所有 input/textarea 输入操作,包括新建和编辑场景,统一使用 `aiInput()`,禁止使用 `aiAction()` 或其他方式处理输入。**


// 新建场景:在标题输入框中输入
await this.getAgent().aiInput("标题输入框", { value: title });

// 编辑场景:清空旧值并输入新值(默认 replace 模式,自动先清空)
await this.getAgent().aiInput("标题输入框", { value: newTitle });

// 搜索框输入
await this.getAgent().aiInput("搜索框", { value: keyword });


**签名:**

function aiInput(
  locate: string | Object,
  opt: {
    value: string | number;          // 必填
    mode?: 'replace' | 'clear' | 'typeOnly';  // 默认 'replace'(先清空再输入)
    deepLocate?: boolean;
    xpath?: string;
    cacheable?: boolean;
  }
): Promise<void>;


**mode 说明:**
- `'replace'`(默认,**始终使用此默认值**):先清空输入框,再输入新值。适用于新建和编辑场景
- `'typeOnly'`:直接追加,不清空。仅在明确需要追加内容时使用
- `'clear'`:仅清空,不输入

---

### `aiScroll()` — 滚动


// 在弹窗内向下滚动
await this.getAgent().aiScroll('「添加 Agent」弹窗内容区域', {
  direction: 'down',
  distance: 300,
});

// 滚动到底部
await this.getAgent().aiScroll('表单区域', { scrollType: 'scrollToBottom' });


**签名:**

function aiScroll(
  locate: string | Object | undefined,
  opt: {
    scrollType?: 'singleAction' | 'scrollToBottom' | 'scrollToTop' | 'scrollToRight' | 'scrollToLeft';
    direction?: 'down' | 'up' | 'left' | 'right';  // 默认 'down'
    distance?: number | null;  // 滚动像素,null = AI 自动决定
    deepLocate?: boolean;
    xpath?: string;
    cacheable?: boolean;
  }
): Promise<void>;


> ⚠️ **弹窗内滚动注意事项:**  
> 弹窗有独立滚动容器,必须明确指定定位到弹窗内部区域,如:  
> `'「添加 Agent」弹窗内容区域'` 而非 `undefined`(否则会滚动主页面)

---

### `aiKeyboardPress()` — 按键


await this.getAgent().aiKeyboardPress('搜索框', { keyName: 'Enter' });
await this.getAgent().aiKeyboardPress('任意位置', { keyName: 'Escape' });


> ⚠️ 不支持组合键(如 Ctrl+A)

---

### `aiHover()` — 悬停


await this.getAgent().aiHover('工具行右侧的「···」按钮');


---

### `aiDoubleClick()` — 双击


await this.getAgent().aiDoubleClick('文件名称');


---

## 三、数据提取方法

### `aiQuery<T>()` — 结构化数据提取


// 提取对象
const data = await this.getAgent().aiQuery<{ hasResults: boolean }>(
  '搜索结果列表是否有数据?返回 { hasResults: true } 或 { hasResults: false }',
  { cacheable: false }  // 页面动态变化时关闭缓存
);

// 提取数组
const options = await this.getAgent().aiQuery<string[]>(
  'string[], 当前可见的 Agent 类型选项文案列表'
);


**签名:**

function aiQuery<T>(
  dataDemand: string | Object,
  options?: {
    domIncluded?: boolean | 'visible-only';  // 是否包含 DOM 信息,默认 false
    screenshotIncluded?: boolean;             // 是否包含截图,默认 true
    cacheable?: boolean;                      // 默认 true
  }
): Promise<T>;


> ⚠️ **cacheable 注意:** 若页面状态在上次调用后发生了变化(如搜索结果已更新),必须设置 `cacheable: false`

---

### `aiBoolean()` / `aiNumber()` / `aiString()` — 便捷提取


const hasDialog = await this.getAgent().aiBoolean('是否存在登录对话框');
const count = await this.getAgent().aiNumber('当前搜索结果条数');
const title = await this.getAgent().aiString('当前页面标题');


---

## 四、断言与等待方法

### `aiAssert()` — 断言


await this.getAgent().aiAssert('搜索结果列表中至少有一条记录');


> 💡 **更可靠的替代写法:**  `aiQuery` + `expect` 替代 `aiAssert`(降低 AI 幻觉风险)

---

### `aiWaitFor()` — 等待条件成立


await this.getAgent().aiWaitFor('搜索结果列表变得可见', {
  timeoutMs: 15000,       // 默认 15000ms
  checkIntervalMs: 3000,  // 默认 3000ms
});


> ⚠️ 每次检查都调用 AI,成本较高,简单等待场景用 `await this.wait(ms)` 替代

---

## 五、本项目 withFallback 模式规范

Page 层所有 DOM 操作必须使用 `withFallback`### AI-Only 模板(首次实现必须使用)


async clickXxx() {
  await this.withFallback({
    label: 'clickXxx()',
    cssAction: async () => false,    // ← 必须是 false
    aiFallback: async () => {
      await this.getAgent().aiTap('[容器]中的[文案][元素类型]');
    },
    aiOnly: true,                    // ← 必须有
  });
  await this.wait(500);
}


### CSS-First 模板(通过探针验证后使用)


async clickYyy() {
  await this.withFallback({
    label: 'clickYyy()',
    cssAction: async () => {
      return await this.page.evaluate(() => {
        const btn = document.querySelector('button.xxx') as HTMLButtonElement | null;
        if (btn && !btn.disabled) { btn.click(); return true; }
        return false;
      });
    },
    aiFallback: async () => {
      await this.getAgent().aiTap('...');
    },
  });
  await this.wait(500);
}


---

## 六、常见场景写法参考

### 场景:弹窗内滚动并操作


// ✅ 正确:明确指定弹窗容器
await this.getAgent().aiScroll('「添加 Agent」弹窗的内容滚动区域', {
  direction: 'down',
  distance: 300,
});

// ❌ 错误:会滚动主页面而非弹窗
await this.page.evaluate(() => window.scrollBy(0, 300));


### 场景:先输入再搜索


// ✅ 正确:拆分为输入 + 点击两步(aiInput 处理输入,aiTap 处理点击)
await this.getAgent().aiInput('搜索插件输入框', { value: keyword });
await this.getAgent().aiTap('搜索框右侧的放大镜按钮');


### 场景:新建/编辑时填写表单输入框


// ✅ 正确:统一使用 aiInput,默认 replace 模式(自动清空旧值再输入)
await this.getAgent().aiInput("标题输入框", { value: title });
await this.getAgent().aiInput("概述输入框", { value: overview });
await this.getAgent().aiInput("搜索框", { value: keyword });

// ✅ 编辑场景同样使用 aiInput,无需额外清空步骤
await this.getAgent().aiInput("标题输入框", { value: newTitle });

// ❌ 禁止:用 aiAction 处理输入框输入(应拆分用 aiInput)
// await this.getAgent().aiAction(`在"标题"输入框中输入 "${title}"`);

如何初始化这个 UI 自动化工程

讲完了原理, 我们看要如何初始化这个工程。 我们可以使用我一直推荐的 superpowers, 也可以直接跟 AI 做普通对话:

请使用 superpowers 工作流,为一个全新的 UI 自动化工程做脚手架初始化。
目标:用 Midscene.js + Playwright + vitest 搭建一套 AI 视觉驱动的 UI 自动化测试工程,
并且严格遵循我现有项目的四层架构(component → page → service → case)。

【技术栈与依赖】
- 测试框架:vitest(不是 @playwright/test)
- 浏览器驱动:playwright + @midscene/web(Midscene Playwright 集成)
- 报告:allure-js-commons + midscene HTML 报告
- 配置加载:dotenv(解决 worker 进程读不到 .env 的坑)
依赖:@playwright/test、@midscene/web、playwright、vitest、allure-js-commons、dotenv

【四层架构与目录结构】
src/
├── components/          # 组件层:可复用 UI 控件原子操作(如 Button/Input/Select/Alert)
├── pages/               # 页面层:单页面操作封装(子类继承 BasePage)
├── services/            # 服务层:跨页面业务流程编排(Mixin 模式)
├── fixtures/            # fixture:浏览器/context/page/共享 agent 生命周期
├── setup/               # 登录态 / cookie 初始化
├── utils/               # 基建:midscene-agent、with-fallback、click-by-marker
├── tests/               # 用例层:测试 + 断言(*.test.ts)
├── config.ts            # createBrowser / BASE_URL 等
└── midsence.md          # 架构说明文档

【各层职责边界(强制)】
| 层 | 职责 | 禁止 |
|---|---|---|
| components | UI 控件原子操作封装 | 不得依赖 pages/services |
| pages | 单页面操作组合 | 不得跨页面、不得写 expect/断言 |
| services | 跨页面完整流程编排 | 不直接操作 DOM、不写断言 |
| tests(case) | 业务编排 + 断言 | 不得直接 new XxxPage()/new XxxService(),统一通过 fixture 注入 |

【基类契约(必须实现)】
1. BaseComponent:接收 page,提供 getAgent()(复用 utils/midscene-agent 的 createPlaywrightAgent),
   内部用统一 withFallback({label, cssAction, aiFallback, aiOnly}) 模式(CSS 优先、AI 兜底)。
2. BasePage(abstract):接收 page;this.getAgent() 懒加载;
   核心方法 withFallback(...) 委托 utils/with-fallback 的 runWithFallback(记录 stdout + JSONL 日志);
   子类声明 abstract readonly url,提供 goto() 等待策略;内置 wait(ms)。
3. BaseService:接收 page,public readonly page 字段;this.step(name, action) 记录 Allure;
   getAgent() 懒加载复用共享 agent;wait(ms)。
4. 服务层使用 TypeScript Mixin 模式:先声明 interface XxxMixinMethods(方法签名),
   再用 class XxxMixed 实现,service 主类通过交叉类型组合;
   对外暴露的方法用 this.step() 包裹并调用 page/service 实例。

【Midscene 关键约定】
- agent 统一用 createPlaywrightAgent(page) 创建(utils 内做 WeakMap 共享注册,
  保证一条 case 内 page/service/component 共用同一 agent → 一份 midscene HTML)。
- 方法优先传统定位(locator),仅在无稳定属性/图标/复杂控件时用 AI:
  aiTap('描述')、aiInput('描述',{value})、aiQuery('描述')、aiAssert('描述')、aiAction('合并多步')。
- 定位优先级:id > name > type > 语义 class > 文案 > AI 视觉(AI 有 token 成本,谨慎用)。

【基建文件清单(初始化时必须产出)】
- src/config.ts:createBrowser()、BASE_URL、APP_URL
- src/utils/midscene-agent.ts:createPlaywrightAgent / createSharedAgent / setSharedAgent(WeakMap 共享 + 注入 vitest case 名到 groupName)
- src/utils/with-fallback.ts:runWithFallback(CSS 优先→AI 兜底,事件写 test-results/fallback-events.log JSONL)
- src/utils/click-by-marker.ts:clickByMarker 代理
- src/fixtures/playwright.fixture.ts:usePlaywright() / usePlaywrightWithAuth(),beforeEach 创建浏览器+page+共享 agent,afterEach 截图+清理
- src/setup/*:加载 .env(dotenv.config 带 path/override),登录态/cookie 注入
- .env.example:MIDSCENE_MODEL_BASE_URL / API_KEY / MODEL_NAME / MODEL_FAMILY 模板,注明 worker 进程需每文件 import setup
- vitest.config.ts:testDir=src/tests、timeout 设长(AI 推理慢)、reporter 含 allure
- package.json scripts:test / test:ui / report

【命名与目录约定】
- 组件:XxxComponent(如 ButtonComponent);页面:XxxPage / XxxDialog(弹窗也归 pages);
  服务:XxxService,Mixin 拆为 XxxServiceXxxMixin.ts + 目录 index;
  用例:<场景>.test.ts,按模块分子目录(如 tests/<模块>/)。
- 每个 pages/services 子目录提供 index.ts 统一导出;方法名语义化(动词开头:clickXxx / fillXxx / verifyXxx)。

【交付物要求】
1. 完整可运行的脚手架(上面所有文件)。
2. 一个"四层贯通"的冒烟示例:以被测系统首页的一个简单操作(如打开某弹窗→输入→确定→断言)
   为例,分别实现:1 个 ButtonComponent、1 个 HomePage、1 个 XxxService(含 Mixin),
   1 个 tests 用例调用 fixture 注入的 page/service 完成断言。
3. README 简述四层职责、withFallback 用法、如何新增一个页面/服务/用例。
4. 初始化完成后,运行该冒烟用例确认能跑通(AI 兜底链路可用)。

【约束】
- 只搭建脚手架与示例,不要大批量生成业务页面/用例。
- 保持零业务耦合,示例用占位文案与可配置 URL。
- 所有 AI 定位描述要精确(缩小范围省 token),稳定元素一律传统定位。

用我这样的一段提示词, 就可以按照 UI 自动化的 4 层架构(如果对 UI 自动化 4 层架构还不了解的同学,可以搜一下我之前的帖子)和技术要求初始化我们的代码了。 当然各位同学也可以按自己的需求修改, 或者也不用这么复杂和细节, 就按自己的要求写一个简单的提示词去初始化也可以。(毕竟我这个对于初始化的要求太细节了)

结尾

以上就是 AI 驱动 UI 自动化的基本原理,当然有这个基本原理还是没办法稳定高效的来生成代码的, 我们还需要编写多 Agent 的 skill,这个我们下期介绍哈。

这里再宣传下自己的星球:

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暫無回覆。
需要 登录 後方可回應,如果你還沒有帳號按這裡 注册