我是怎样使用 AI 构建 E2E 测试体系的?

问题

TinyShip 是一个支持 Next.js、Nuxt.js、TanStack Start 三套前端框架组成的 monorepo,同时支持 PostgreSQL 和 SQLite,也就是说每改一个功能,有 6 种不同的组合可能出问题。当开发任何新功能的时候,保证应用完成新功能并起没有 regression 是非常重要的,假如手动测试工作量难以估量,我是开发基础的功能以后,在添加后续的功能的时候,发现没有 E2E 的测试,几乎是非常麻烦的,尤其是针对一个多框架多数据库支持的应用,流程都相似,重复性非常高,所以这里必须有一个开发新功能的时候的测试和验收流程。

基石

TinyShip 的基石是 E2E 测试,我认为在 AI Coding 时代,任何产品的基石都是测试,User Cases 比 代码更宝贵。AI 让代码迭代速度从天变成小时,你一天可能重构 10 次,添加 20 个功能,每次改动都可能意外破坏现有功能。

虽然感觉 E2E 有点重,但是还是毅然将它们加上了,事实证明,有了 AI 的辅助任何看起来很繁琐的任务都实施起来都不难。我让它通过路由和页面分析核心交互,确定一些必须覆盖的关键流程(Critical User Journeys),然后编写 case,加上修改测试和我去 Review,总共也就只花了两天时间。它模拟真实用户在浏览器中的完整操作,保证 核心的流程 必须 100% 有 E2E 覆盖,这样任何修改提交和后续的修改就有一个重要的依靠,对后续的功能开发是重要的。

五阶段流程

有了 E2E 的覆盖,我确定了一新的开发新 feature 的流程, TinyShip 开发新功能的时候定了五个阶段:Spec → Code → Verify → Test → Green,我将这套标准写入到根目录的 Agents.md 下面,这样 AI 可以第一时间按照我的流程完成功能。

核心思路是先想清楚要测什么,再写代码,然后用 agent-browser 走一遍视觉确认,最后写 Playwright 测试。顺序很重要。这套流程在 Agent 的 Plan 模式下就会被激活,伴随着技术方案的创建,在开始 build 以后会完成接下来的步骤。

┌─────────┐   ┌─────────┐   ┌──────────┐   ┌─────────┐   ┌─────────┐
│  SPEC   │──▶│  CODE   │──▶│  VERIFY  │──▶│  TEST   │──▶│  GREEN  │
│ 定义验收 │   │ 实现功能 │   │ 视觉确认  │    │ 写 E2E  │    │ 全通过  │
│ 标准     │  │         │   │          │    │ 测试    │    │        │
└─────────┘   └─────────┘   └──────────┘   └─────────┘   └─────────┘

Spec:先想清楚要测什么

每做一个新功能,第一步是让 AI 在 tests/e2e/TEST-CATALOG.md 里写一段验收标准。就是用自然语言描述:打开哪个页面、点哪里、期望看到什么。例如之前已经有的一个用例,除了自然语言描述,还可以增加结构化字段。

## 8. 个人资料更新测试
**文件:** `specs/profile-update.spec.ts` | **优先级:** P1

验证仪表盘中编辑个人资料的完整流程:进入编辑模式 → 修改姓名 → 保存 → 验证更新。

> 所有测试共用一个浏览器上下文(`beforeAll` 注册),按串行顺序执行。

| # | 测试名称 | 具体流程 |
|---|---------|---------|
| 1 | 个人资料标签页显示用户名和编辑按钮 | API 注册用户 → 访问 `/dashboard` → 验证用户名可见 → 验证 "Edit" 按钮可见 |
| 2 | 可以进入编辑模式并修改姓名 | 访问 `/dashboard` → 等待用户名加载 → 点击 "Edit" 按钮 → 验证 `#name` 输入框可见 → 清空并填入新姓名 → 点击 "Save" → 等待编辑模式关闭("Edit" 按钮重新出现) → 验证新姓名显示在页面上 |

Code:写代码

这个没什么好说,按清单写代码。但写的时候要注意一点:保持三个 app 的一致性。互相充用的逻辑在 libs/* 里实现,路由层尽量薄。这样 E2E 测试写起来也省事,三个 app 的测试逻辑基本一样。

Verify:用 agent-browser 预演一遍

代码写完了,页面跑起来了,接下来不是写测试,而是先用 agent-browser 走一遍,agent-browser 是 Vercel Labs 专门为 AI Agents 设计的浏览器自动化 CLI。

为什么要使用 agent-browser ?

为什么多这一步?

首先因为 Playwright 测试是脆的——选择器经常要调。如果界面有明显的 UX 问题,写测试也是浪费,后面还得改。多次跑会非常慢,而且浪费 token。

agent-browser 基于 Rust + Playwright 底层,首先它极致节省上下文和 Token。传统 Playwright 或 Puppeteer 给 AI 喂一页 HTML/DOM 树,动辄几千到上万 token,很快就占满上下文。 它使用语义化、精简的 Accessibility Tree + 简洁引用(如 @E_1、@E_3 - button “生成图片”),输出非常 compact,能节省 80%+ 的 token。并且它有 AI-First 设计,使用自然语言指令它理解的很好。

# agent-browser 的返回举例,很有趣,只保留交互元素,没有 DOM tree,节省大量 Token。
- textbox "输入提示词" [ref=e1]
- button "选择文件" [ref=e2]  (上传按钮)
- combobox "模型选择" [ref=e3]  (下拉框)
- button "开始生成" [ref=e4]

# 交互采用上面的 ref 来实现,完全不用写 CSS 选择器。
agent-browser click @e4 

在 Verify 阶段用 agent-browser 走完真实流程后,我们已经拿到了可靠的元素引用和实际 DOM 结构,此时再写 Playwright 测试的选择器成功率极高,基本一次就能稳定。而且三个框架的测试代码也可以高度复用,只需少量调整。

Test:写 Playwright E2E

UI 确认没问题了,才开始写 Playwright 测试。这时候选择器都知道了——哪个按钮是 [data-slot="select-trigger"],哪个列表是 role="listbox",哪个输入框的 placeholder 是啥。

为什么不在写代码之前就写好测试?

BDD 不就是先写测试的吗?

试过,不行。

E2E 测试跟单元测试不一样。单元测试是测试一个函数,输入输出都是纯数据,你可以在写代码之前先写测试。但 E2E 测试依赖真实的 DOM 结构——[data-slot="select-trigger"] 这种选择器,你不知道 UI 会长什么样之前根本写不了。而且三个框架(Next.js、Nuxt.js、TanStack Start)渲染方式不一样,同一个选择器可能在一个框架里有效,在另一个里失效。

所以我的做法是:用 BDD 的思维——先想清楚验收标准——但测试代码放在 UI 成型之后再写。

Green:三个 app 都跑通

最后一步,启动 Next.js app,跑一遍测试。然后换 Nuxt.js,再跑一遍。再换 TanStack Start,再跑一遍。三个都绿了,再切数据库,PG 和 SQlite,6 次测试都通过,这个功能才算做完。

切 app 和数据库让 AI 来,不需要手动,一个 app 跑完,切换另一个,跑相同的测试。

E2E 不在 CI 上跑

E2E 测试有个特征:我不让它在 CI 上跑。

CI 上只跑 typecheck 和 build。为什么?几个原因:

  1. 慢。 全量 E2E 跑完一个 app 大概 6 分钟,三个 app 要 18 分钟,再加上两个数据库 36分钟,CI 上排队这么久不划算。
  2. 依赖多。 支付相关的测试需要 Stripe CLI 以及不同支付平台的各种环境变量,由于支持的服务非常多,要配置的环境变量很多。CI 上配这些要么麻烦,要么不安全。
  3. CI 的目的是快速反馈——类型对不对、能不能编译。E2E 解决的是另一个问题:交互流程有没有坏。这俩不是一回事。

所以 E2E 我现在只在本地跑,每次发版前,三个 app 各跑一遍。

三种情况跑 E2E

E2E 不是天天跑全量的。我只在这几种情况跑:

情况跑哪些
做完一个功能只跑相关的 spec 文件
发版前全部 spec,三个 app 都跑
大重构全部 spec,三个 app 都跑

小修小补,跑个 typecheck + build 就够了。全量 E2E 是发版和重构是否才跑。

如果你也有多框架的项目,或者也头疼人肉测试成本太高,可以试试这套流程。