我是怎样使用 AI 构建 E2E 测试体系的? | Viking
问题
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。为什么?几个原因:
- 慢。全量 E2E 跑完一个 app 大概 6 分钟,三个 app 要 18 分钟,再加上两个数据库 36分钟,CI 上排队这么久不划算。
- 依赖多。支付相关的测试需要 Stripe CLI 以及不同支付平台的各种环境变量,由于支持的服务非常多,要配置的环境变量很多。CI 上配这些要么麻烦,要么不安全。
- CI 的目的是快速反馈——类型对不对、能不能编译。E2E 解决的是另一个问题:交互流程有没有坏。这俩不是一回事。
所以 E2E 我现在只在本地跑,每次发版前,三个 app 各跑一遍。
三种情况跑 E2E
E2E 不是天天跑全量的。我只在这几种情况跑:
| 情况 | 跑哪些 |
|---|---|
| 做完一个功能 | 只跑相关的 spec 文件 |
| 发版前 | 全部 spec,三个 app 都跑 |
| 大重构 | 全部 spec,三个 app 都跑 |
小修小补,跑个 typecheck + build 就够了。全量 E2E 是发版和重构是否才跑。
如果你也有多框架的项目,或者也头疼人肉测试成本太高,可以试试这套流程。