测试开发之路 AI 驱动 UI 自动化的实践心得 - 基于 Stagehand

孙高飞 · 2026年04月03日 · 最后由 dogdogaaa 回复于 2026年05月10日 · 11627 次阅读

前言

上一篇主要介绍了以计算机视觉方案为主的 AI 驱动框架 -- midsence.js ,他需要传递一个多模态大模型。 而今天介绍的是另一个开源框架,基于单模态大模型的方案, 对比计算机视觉,更加节省 token。

一、Stagehand 是什么?

Stagehand 是 Browserbase 开源的 AI 驱动浏览器自动化框架。它的核心理念是:用自然语言描述操作意图,由 LLM 来理解页面并执行操作,而不是让你手写 CSS 选择器。

当然他也支持混合方案,既用户可以根据自己的需要来选择使用传统定位方式还是使用 AI 驱动定位。

核心 API

能力 说明
stagehand.act(prompt) 找到元素并执行操作(点击、输入等)
stagehand.extract(prompt, schema) 从页面提取结构化数据
page.locator(selector) 传统 Playwright 定位(零 Token 消耗)
page.type(text) 键盘逐字输
const stagehand = createStagehand();
await stagehand.init();

// AI 驱动:自然语言描述操作
await stagehand.act('在用户名输入框中输入 "100000000"');
await stagehand.act("点击登录按钮");

// 传统定位:有稳定属性时优先用
await page.locator('input[name="loginName"]').fill("100000000");
await page.locator('button[type="submit"]').click();

// AI 提取:结构化提取页面信息
const result = await stagehand.extract(
  "判断登录是否成功",
  z.object({ success: z.boolean() })
);

二、用 AI IDE 辅助编写测试用例

传统方式:手动打开浏览器 → 看 DOM 结构 → 手写选择器 → 写用例代码,流程割裂、效率低。

新方式:AI IDE 全程辅助,从需求到代码一气呵成。

工具链架构

AI IDE
    ↓ 理解需求意图
Skills (.codebuddy/rules.md 项目架构规范)
    ↓ 注入定位优先级、分层规则
MCP Playwright 工具
    ↓ 实时打开目标页面,抓取真实 DOM
生成符合规范的 TypeScript 测试代码

典型对话流程:

你:我要测试平台新建 Agent 模式应用的功能,
    点击"新建应用"按钮,弹窗里填写应用名称,选择 Agent 模式,
    点击"新建",验证应用出现在列表中。
    注意:弹窗中有多个 type=submit 的图标按钮,"新建"按钮需要用文字精确匹配。

这部分的交互与上一篇介绍 midsence 很像, 就不多过的赘述了。

三、四层分层架构设计

仍然推荐四层架构设计来完成 ui 自动化测试:

src/
├── components/          # 【组件层】可复用 UI 控件原子操作
│   ├── BaseComponent.ts         # 组件基类(持有 stagehand + page)
│   ├── InputComponent.ts        # 输入框封装(AI 定位)
│   ├── ButtonComponent.ts       # 按钮封装(AI 定位)
│   ├── AlertComponent.ts        # 错误提示提取
│   └── NavComponent.ts          # 导航操作
│
├── pages/               # 【页面层】单页面操作封装
│   ├── BasePage.ts              # 页面基类(持有三个通用组件)
│   ├── LoginPage.ts             # 登录页操作
│   ├── HomePage.ts              # 主页(应用列表)
│   ├── CreateAppDialog.ts       # 新建应用弹窗
│   └── ChatPanel.ts             # 右侧对话调试面板
│
├── services/            # 【业务逻辑层】完整业务流程
│   ├── BaseService.ts           # 服务基类
│   ├── AuthService.ts           # 登录流程
│   └── AppService.ts            # 创建应用 + 对话流程
│
├── fixtures/            # 【测试基础设施】
│   └── stagehand.fixture.ts     # Stagehand 生命周期管理 + Cookie 注入
│
├── setup/               # 【全局初始化】
│   └── globalSetup.ts           # 一次性登录,保存 auth-state.json
│
└── tests/               # 【用例层】测试用例 + 断言
    ├── login.test.ts            # 登录功能用例
    └── app.test.ts              # 应用管理 + 对话用例

各层职责边界

职责 禁止
组件层 UI 控件原子操作封装 不得依赖 pages/services
页面层 单页面操作组合,持有组件实例 不得跨页面,不得写 expect
业务逻辑层 跨页面完整流程编排 不得直接操作 DOM
用例层 业务编排 + 断言 不得直接 new XxxPage(),只调用 Service

四、混合定位策略:稳定用传统,易变用 AI

这是本项目最重要的经验总结。

核心原则

不是所有定位都需要 AI,AI 定位有真实的 Token 成本和时间成本。

传统定位:适用场景

当元素有稳定的 HTML 属性时,永远优先用传统定位, 而当页面变化或其他原因导致传统定位失败时,再使用 AI 驱动定位进行兜底。这样最节省 token。比如下面这个核心函数:

export class CommunityPage extends BasePage {
  readonly url = "https://testerhome.com/topics";

  /**
   * 容错定位:先用传统 CSS 选择器,失败后自动降级为 AI act
   */
  private async withFallback(
    selector: string,
    action: (locator: ReturnType<typeof this.page.locator>) => Promise<void>,
    aiFallback: string
  ): Promise<void> {
    try {
      const exists = await this.page.evaluate(
        (sel: string) => !!document.querySelector(sel),
        selector
      );
      if (!exists) throw new Error("element not found");
      console.log(`[CommunityPage] CSS 定位成功: ${selector}`);
      await action(this.page.locator(selector));
    } catch {
      console.warn(`[CommunityPage] CSS 定位失败 (${selector}),降级为 AI act: ${aiFallback}`);
      await this.stagehand.act(aiFallback);
    }
  }

  /**
   * 点击筛选 Tab
   *
   * CSS 定位:ul.filter.nav-pills a[href*="<path>"]
   * AI 降级:点击"<tabName>" 筛选标签
   */
  async selectFilter(filter: TopicFilter): Promise<void> {
    const path = FILTER_PATH_MAP[filter];
    await this.withFallback(
      `ul.filter.nav-pills a[href*="${path}"]`,
      (loc) => loc.click(),
      `点击帖子筛选标签"${filter}"`
    );
    await this.wait(2000);
  }

再上面的逻辑里,我们先试用 css selector 定位控件(ul.filter.nav-pills a[href*="${path}"]),而当传统方式定位失败后,再使用 this.stagehand.act(aiFallback); 这样的方法进行 AI 定位。 并且在失败的时候打印日志,以便后续工具修正传统定位方式。


从零搭建:完整操作手册

Step 1:初始化项目

mkdir my-ui-test && cd my-ui-test
npm init -y

# Stagehand(含 Playwright 底层)
npm install @browserbasehq/stagehand

# LLM 客户端(用于 CustomOpenAIClient)
npm install openai zod

# 测试框架
npm install -D vitest tsx typescript @types/node @vitest/ui

Step 2:配置模型接入

src/config.ts 中配置大模型,Stagehand 通过 CustomOpenAIClient 支持任何兼容 OpenAI 接口的模型:

// src/config.ts
import { Stagehand, CustomOpenAIClient } from "@browserbasehq/stagehand";
import OpenAI from "openai";

export function createStagehand(options?: { headless?: boolean }) {
  const openaiClient = new OpenAI({
    apiKey: process.env.API_KEY || "your-api-key",
    baseURL: process.env.BASE_URL || "https://api.openai.com/v1",
  });

  return new Stagehand({
    env: "LOCAL",
    localBrowserLaunchOptions: {
      headless: options?.headless ?? false,
      args: [
        "--disable-web-security",       // 微前端必须
        "--no-sandbox",
        "--disable-setuid-sandbox",
      ],
      ignoreHTTPSErrors: true,
    },
    llmClient: new CustomOpenAIClient({
      modelName: process.env.MODEL_NAME || "gpt-4o",
      client: openaiClient,
    }),
    logger: (msg) => process.env.DEBUG && console.log(`[${msg.category}] ${msg.message}`),
  });
}

支持的模型方案:

方案 BASE_URL MODEL_NAME 说明
OpenAI GPT-4o https://api.openai.com/v1 gpt-4o 官方,能力最强
DeepSeek-v3 https://api.deepseek.com/v1 deepseek-chat 国内,性价比高
通义千问 https://dashscope.aliyuncs.com/compatible-mode/v1 qwen-max 阿里云
企业私有化 你的内网地址 模型名 适合内网测试环境

Step 3:配置 vitest

// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globalSetup: ["./src/setup/globalSetup.ts"],  // 全局一次性登录
    testTimeout: 120_000,  // AI 推理慢,超时要长
    hookTimeout: 90_000,
    sequence: { concurrent: false },               // 串行,避免浏览器冲突
  },
});

Step 4:编写分层代码

组件层(最底层,封装原子操作):

// src/components/InputComponent.ts
export class InputComponent extends BaseComponent {
  async fill(label: string, value: string) {
    // 优先 AI 定位(input 通常无稳定 name)
    await this.stagehand.act(`在${label}输入框中输入 "${value}"`);
  }
}

页面层(单页面操作组合):

// src/pages/LoginPage.ts
export class LoginPage extends BasePage {
  readonly url = `${ADP_BASE_URL}/portal/`;

  async fillUsername(value: string) { await this.input.fill("用户名", value); }
  async fillPassword(value: string) { await this.input.fill("密码", value); }
  async fillCaptcha(value: string)  { await this.input.fill("验证码", value); }
  async clickLoginButton()          { await this.button.click("登录"); }
  async getErrorMessage()           { return this.alert.getMessage(); }
}

服务层(完整业务流程):

// src/services/AuthService.ts
export class AuthService extends BaseService {
  async login({ username, password, captcha }) {
    const loginPage = new LoginPage(this.stagehand, this.page);
    await loginPage.goto();
    await loginPage.fillUsername(username);
    await loginPage.fillPassword(password);
    await loginPage.fillCaptcha(captcha);
    await loginPage.clickLoginButton();

    // AI 判断是否登录成功
    const { isLoginPage } = await this.stagehand.extract(
      "判断当前页面是否还是登录页",
      z.object({ isLoginPage: z.boolean() })
    );
    const errorMessage = isLoginPage ? await loginPage.getErrorMessage() : undefined;
    return { success: !isLoginPage, errorMessage };
  }
}

整个过程不详细演示了, 其实都是可以用 AI IDE 来实现的,比如 cursor 和 codebuddy。

这里我主要分享一下核心的 skill 文件,或者说是 rules 文件的必要元素和模板。 应该说整个 UI 自动化项目中最重要的一个文件。

# ADP UI 自动化测试项目规范

> CodeBuddy 在生成代码时必须严格遵守以下规范。

---

## 一、元素定位策略:传统 CSS 优先,AI act 降级容错

**核心原则**:所有元素定位优先使用传统 CSS 选择器(零 Token、快速、稳定),**仅当 CSS 定位失败时**自动降级为 AI act(`stagehand.act()`)。

### 必须使用 `withFallback` 模式

每个页面层的元素操作方法都必须遵循以下容错模式:


private async withFallback(
  selector: string,
  action: (locator: ReturnType<typeof this.page.locator>) => Promise<void>,
  aiFallback: string
): Promise<void> {
  try {
    const exists = await this.page.evaluate(
      (sel: string) => !!document.querySelector(sel),
      selector
    );
    if (!exists) throw new Error("element not found");
    console.log(`[${this.constructor.name}] CSS 定位成功: ${selector}`);
    await action(this.page.locator(selector));
  } catch {
    console.warn(`[${this.constructor.name}] CSS 定位失败 (${selector}),降级为 AI act: ${aiFallback}`);
    await this.stagehand.act(aiFallback);
  }
}


### 使用示例

// ✅ 正确:传统定位 + AI 容错
async fillUsername(value: string) {
  await this.withFallback(
    "input#user_login",
    (loc) => loc.fill(value),
    `在用户名输入框中输入 "${value}"`
  );
}

// ❌ 禁止:直接使用 AI act(无传统定位尝试)
async fillUsername(value: string) {
  await this.stagehand.act(`在用户名输入框中输入 "${value}"`);
}

// ❌ 禁止:直接使用传统定位(无 AI 容错)
async fillUsername(value: string) {
  await this.page.locator("input#user_login").fill(value);
}


### CSS 选择器优先级

| 优先级 | 方式 | 示例 |
|---|---|---|
| 1 | id | `#user_login` |
| 2 | name | `input[name="q"]` |
| 3 | type + value | `input[type=radio][value="agent"]` |
| 4 | 语义 class | `input.v-input--default__input` |
| 5 | textContent 精确匹配 | `evaluate + textContent?.trim() === "新建"` |

### 日志规范

- CSS 定位成功时打印:`[类名] CSS 定位成功: <selector>`
- CSS 定位失败降级时打印:`[类名] CSS 定位失败 (<selector>),降级为 AI act: <指令>`
- 日志必须包含类名(使用 `this.constructor.name`),便于排查问题来源

---

## 二、四层分层架构(严格遵守)


src/
├── components/    # 【组件层】可复用 UI 控件原子操作
├── pages/         # 【页面层】单页面操作封装
├── services/      # 【服务层】跨页面完整业务流程
└── tests/         # 【用例层】测试用例 + 断言


### 2.1 组件层(components/)

**职责**:封装可复用的 UI 控件原子操作。前端组件化开发意味着同一个组件(如按钮、输入框、下拉选择器)会出现在多个页面上,定位方式相同,因此需要统一封装。

**规则**:
- 每个组件类继承 `BaseComponent`
- 只封装单个 UI 控件的操作(输入、点击、获取值等)
- **禁止**依赖 pages/ 或 services/
- **禁止**写 `expect` 断言
- 组件方法应使用 `withFallback` 容错模式(如果组件内部需要定位元素)

**文件命名**:`XxxComponent.ts`,如 `InputComponent.ts`、`ButtonComponent.ts`

// ✅ 正确示例
export class InputComponent extends BaseComponent {
  async fill(label: string, value: string) {
    await this.stagehand.act(`在 ${label} 输入框中输入 "${value}"`);
  }
}


### 2.2 页面层(pages/)

**职责**:封装单个页面内的操作组合。每个页面类对应一个实际的 Web 页面。

**规则**:
- 每个页面类继承 `BasePage`
- 持有组件层实例(`InputComponent`、`ButtonComponent` 等),通过组件层操作 UI
- 页面自有的元素操作**必须使用 `withFallback` 容错模式**
- **禁止**跨页面操作(如页面 A 的方法操作页面 B 的 DOM)
- **禁止**写 `expect` 断言
- 必须定义 `readonly url` 属性

**文件命名**:`XxxPage.ts`,如 `LoginPage.ts`、`TesterHomePage.ts`


// ✅ 正确示例
export class TesterHomePage extends BasePage {
  readonly url = "https://testerhome.com/account/sign_in";

  async fillUsername(value: string) {
    await this.withFallback(
      "input#user_login",
      (loc) => loc.fill(value),
      `在用户名或邮箱输入框中输入 "${value}"`
    );
  }
}


### 2.3 服务层(services/)

**职责**:封装跨页面的完整业务流程。可能调用多个 Page 层的方法来完成一个完整的业务操作。

**规则**:
- 每个服务类继承 `BaseService`
- 内部创建和持有多个 Page 对象
- 对外提供语义清晰的业务方法(如 `login()`、`createApp()`)
- 需要默认参数的场景(如账号密码),在服务层提供默认值
- **禁止**直接操作 DOM(必须通过 Page 层)
- **禁止**写 `expect` 断言

**文件命名**:`XxxService.ts`,如 `AuthService.ts`、`AppService.ts`


// ✅ 正确示例
export class TesterHomeAuthService extends BaseService {
  private readonly loginPage: TesterHomePage;

  async login(credentials?: TesterHomeCredentials): Promise<TesterHomeLoginResult> {
    const creds = { ...DEFAULT_CREDENTIALS, ...credentials };
    await this.loginPage.goto();
    await this.loginPage.fillUsername(creds.username!);
    await this.loginPage.fillPassword(creds.password!);
    await this.loginPage.clickLoginButton();
    // ... 判断登录结果
  }
}


### 2.4 用例层(tests/)

**职责**:编写测试用例,包含业务编排和断言。

**规则**:
- 使用 Vitest 的 `describe` + `test` 组织用例
- **只调用 Service 层**的方法,不直接 `new XxxPage()`
- 断言(`expect`)只在用例层写
- 每个 test 块必须有 JSDoc 注释说明步骤和预期
- 通过 `useStagehand()` 或 `useStagehandWithAuth()` fixture 获取上下文

**文件命名**:`xxx.test.ts`,如 `login.test.ts`、`testerhome.test.ts`


// ✅ 正确示例
test("登录后点击【社区】应进入社区页面", async () => {
  const thAuth = new TesterHomeAuthService(ctx.stagehand, ctx.page);
  const result = await thAuth.login();
  expect(result.success).toBe(true);
  // ...
});

// ❌ 禁止:用例层直接 new Page
test("xxx", async () => {
  const page = new TesterHomePage(ctx.stagehand, ctx.page);  // ❌
  await page.fillUsername("xxx");
});




## 三、层间调用关系(严格单向依赖)

用例层 (tests/)
  ↓ 只调用
服务层 (services/)
  ↓ 只调用
页面层 (pages/)
  ↓ 只调用
组件层 (components/)


- **禁止**反向依赖(如组件层调用页面层)
- **禁止**跨层调用(如用例层直接调用组件层或页面层)

---

## 四、其他规范

### 浏览器配置
- 必须在 `config.ts` 的 `createStagehand()` 中统一配置浏览器参数
- 必须禁用 Chrome 密码管理器弹框(通过 `--user-data-dir` + Preferences)
- 微前端场景必须加 `--disable-web-security` 等参数

### 导出规范
- 每层目录下必须有 `index.ts` 统一导出
- 新增文件后必须更新对应的 `index.ts`

### 命名规范
- 组件类:`XxxComponent`(如 `InputComponent`)
- 页面类:`XxxPage`(如 `TesterHomePage`)
- 服务类:`XxxService`(如 `TesterHomeAuthService`)
- 测试文件:`xxx.test.ts`
- 接口类型以用途命名:`XxxCredentials`、`XxxResult`

### 超时配置
- Vitest `testTimeout`: 120_000(AI 推理较慢)
- Vitest `hookTimeout`: 90_000
- `withFallback` 中 CSS 检测超时:即时检测(evaluate)
- AI act 后等待页面响应:按业务场景设置(登录 3s、搜索 5s 等)

以上 rules 文件,可以作为一个模板,大家根据自己的需求修改后加入项目就可以。

结尾

本篇仍然是一个入门介绍,我把自己觉得重要的东西写了出来。 建议大家多参考我写的控件定位策略(就是先用传统定位,再用 AI 定位的逻辑),以及 rules 文件。 这两部分我认为是最重要的。

其实还有很多的与大模型进行交互的技巧,但这些东西我目前为止很难写成一个体系出来,这些就放到本周末的直播里跟大家说吧。感兴趣的同学也可以加入我得星球,里面有更多现成的提示词和模板:

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
共收到 20 条回复 时间 点赞

每个步骤的元素还是得手动提取写到代码里面吗

aajron 回复

AI 识别的,安装了 MCP 后, AI 会启动浏览器,帮你识别控件并生成代码。

孙哥,一直有个苦恼的问题,就是对于 APP 这种 UI 自动化有没有什么好的推荐的方式,网络上大多数都感觉不是特别合适,或者成本极大,能否推荐一下

各层的脚本是 AI 自动写 还是需要手写?

朱晓丽 回复

都是 AI 来写,我专门写了一个 skill,是一个有三个 agent 的 skill,专门去帮我探索 UI 界面并编写脚本。

GL 回复

字节的 midsence 我看文档上是支持全平台的。

朱晓丽 回复

但我没有试过,我没有做过移动端的测试。

孙高飞 回复

请问是类似于这样的场景吗:对于一个全新的页面,交给 AI 后,AI 来根据页面实际情况,封装组件或这个页面类的方法,用于后续给自动化用例的调用,如果是的话,可以大概说下思路吗,最近也在尝试自动化用例生成,发现对于历史封装过的场景,生成效果还可以,但是如果是新的迭代的用例,生成效果就不是很理想了

飞哥,感觉这个框架主要的亮点 是在定位元素失效以后,自动识别新的元素,但是 如果页面元素 ID 稳定,是不是可以不用这个框架了 ...

AIR神神 回复

我写了一个探测的 Agent, 动态启动浏览器探测, 然后生成 CSS 和 AI 的定位方式。 优先 CSS,如果 CSS 定位失败,就降级到 AI 定位。 然后还有一个测试 Agent,测试不通过就打回去重新定位。 现在正确率还可以。

树叶 回复

元素如果极其 ID 稳定,甚至可以不用任何编写代码的框架了。 就老式的录制回放就行。

请问,这种方式的主要是用在回归测试中吗?可以在系统测试中使用吗?

天天向上 回复

你说的系统测试是?

孙高飞 回复

提交测试后的第一轮功能测试

对应测试场景的数据初始化要怎么集合到流程里呢

数据初始化? 是什么数据哈,我没太 get 到你的点

天天向上 回复

不好意思, 刚看到。 如果有很完善的设计稿, 其实是可以测试左移, 在没提前就写好 case 的,因为我们的控件定位现在都是用自然语言描述的了。 但这太要求设计稿的完善和开发团队严格按照设计稿开发的规范。

还是不推荐用高代码的方式,实现 UI 自动化测试,后期的维护成本高,如果不考虑 token 的消耗的话,对应测试最终要的就是场景覆盖(基于文本描述的步骤),完全依赖自然语言驱动测试,我一直在研究这个话题,我使用的是改造后的 browser use+ 大模型来实现了一套,可以完全基于自然语言驱动 UI 一步一步的执行,稳定性也还不错,最终要的资产就是每个步骤的描述,中间可能会沉淀为一个 json 文件,但是也存的是每个步骤的一些语言描述。

孙高飞 回复

我们给的设计稿都很差。。而且很容易需求变更,变更内容还不做同步,自动化的回归测试都没有,就要求 100% 的 AI 测试,真是要炸了

天天向上 回复

所以你们是怎么操作的?

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册