这个日增 1400 星的 E2E 框架说"第二次跑不调模型",我改了按钮文案试了试

10 阅读8分钟

用 agent 跑 E2E 测试这件事,我一直有个心病:它把测试代码的维护问题,换成了账单问题和玄学问题。 传统录制回放(Selenium 那套)的痛点是脆——页面一改脚本就废。于是有人想,干脆让 agent 看着页面自己点,测试里只写"把商品加进购物车"这种自然语言。听起来很美,但两个新问题马上冒出来:每次跑测试都在调模型,50 个用例的套件每轮 PR 烧几万 token,花的钱买的还是同样的决策;而且同一个 prompt 今天走 A 路径明天走 B 路径,测试比被测代码还不稳定。

10 月 5 号刷 GitHub Trending,看到榜首有个叫 e2e 的测试框架(tester-army/e2e),两天日增都在 1400 星上下,上周 npm 周下载已经六万多。它打着自然语言 agent 步骤的旗号,但真正让我停下滚动的是一句宣称:agent 步骤验证通过后会被录制,下一次运行直接回放,不调模型——直到 app 变了为止。

零模型调用?回放还能适应页面变化?这两句话放在一起是有点反直觉的,我决定搭个最小实验把它拆开看。

装上就一个感觉:轻

我的环境还是那台 1 核 2G 的沙盒,Node v22.23.2。npm install e2e 装完 29 个包——这个数字有点来头,它的 v0.16.0 把依赖从 117 个包砍到了 29 个,砍掉的部分挪到了按需安装的引擎包里。npx e2e init --yes 会生成配置、示例测试和一套给编码 agent 看的 skill 文件,连 MCP 配置都顺手写了。

模型配置走的是 Vercel AI SDK 的 provider 体系,任何 OpenAI 兼容端点都能接。我手头的deepseek-flash 刚好支持多模态,结果整轮实验没有一张截图也跑得顺,从行为看,默认引擎驱动浏览器走的是可访问性树这类文本信息,图片能力是留着没被用上的。这条我没有深究到源码,只能说实测没踩到图片的坑。补一句包结构:SDK 和 CLI 在 e2e 主包里,浏览器引擎单独是 @e2e-dev/web,测试文件里 test 从引擎包导入、expect 从主包导入,分工是官方就这么设计的。 测试页面是我手写的一个极简 todo:输入框、"Add task"按钮、一行状态文案。测试本身长这样:

import { test } from '@e2e-dev/web';
import { expect } from 'e2e';

test('agent adds a task', async ({ app, agent, screen }) => {
  await app.open('/');
  await agent.act('add a task named "buy milk"');
  await expect(screen.getByRole('status')).toHaveText('1 task on the list');
  await expect(screen.getByRole('listitem')).toHaveText('buy milk');
});

agent.act 写意图,下面的 locator 断言写铁标准。这个混搭结构就是它整个机制的地基,后面会看到为什么。

第一次跑:付钱,留底

第一次运行,控制台最后两行是这样的:

AI  16.8k tokens · 65% cached · 3 model calls · deepseek.chat/deepseek-flash
Cache  1 missed

missed 不是失败,是"没有缓存可回放",live 跑完后把 agent 的决策记录下来了。真正值钱的东西在 .e2e/cache/ 目录里,一个 1497 字节的 JSON:

{
  "actions": [
    {
      "name": "type",
      "summary": "type "buy milk" into textbox "Task name"",
      "target": { "role": "textbox", "name": "Task name", "placeholder": "Task name" },
      "value": "buy milk"
    },
    {
      "name": "tap",
      "summary": "tap button "Add task"",
      "target": { "role": "button", "name": "Add task" }
    }
  ],
  "endAnchors": [
    { "role": "status", "name": "1 task on the list" },
    { "role": "listitem", "name": "buy milk" }
  ],
  "goneAnchors": [
    { "role": "status", "name": "No tasks yet" }
  ]
}

(为省篇幅删了 executor 和 digest 字段。)

这个文件值得细看。它存的不是 DOM 快照,是动作序列 + 终态锚点:agent 决定 type 什么、tap 哪个按钮(用 role 和 name 定位,不是 CSS 选择器),以及这一步做完之后页面上应该出现什么(endAnchors)、应该消失什么(goneAnchors)。里面没有 prompt,没有对话记录,没有截图——意味着这个缓存可以提交进 git 而不太担心泄漏,文档也确实建议把回放缓存当测试数据做 code review。

我原来以为它是 Selenium 式的"录制-回放"换皮,看完这个文件才明白区别在哪:Selenium 录的是像素级操作序列,页面一变就全废;这里录的是"语义级决策 + 验证锚点",配合一个判定层来决定还能不能用。

第二次跑:一行 AI 都没有

同一份测试原样再跑一遍:

 ✓ agent adds a task > agent.act "add a task named "buy milk"" 309ms
 Cache  1 replayed

AI 那一行直接消失了——零次模型调用,零 token。步骤耗时从 3.48 秒掉到 309 毫秒,快了一个数量级。我翻了翻 .e2e/report.json,modelTokens 字段明明白白是 0。

钱省在哪了?回放时 runner 做三件事:对路由(ID 和时间戳会被当占位符抹掉)、对每个动作的目标控件(role、name、上下文都匹配才继续)、对终态锚点(该出现的出现、该消失的消失)。三关全过就免单,过不了就把现场打包交给 agent 接管。本质上是把"agent 决策"当成了可以缓存的纯函数——输入(页面状态)没变,就复用上次的输出。

路由归一化那条规则我盯着看了好一会儿。它把 /orders/42?t=1727780000 和 /orders/7?t=1727780999 认成同一个路由——订单 ID 和时间戳都是占位符——但 /orders/summer-sneaker 和 /orders/winter-boot 是两个不同的屏幕,哪怕页面长得一模一样。区分的标准不是字符串相似度,而是"这个词是不是一个会自增的 ID"。这条线划得挺讲究:划松了缓存会张冠李戴,划紧了每次运行都 miss。

顺带说个容易混淆的点:第一次跑控制台里的 "65% cached" 是 DeepSeek 服务端的 prompt cache,省钱但每次还是发了请求;replay cache 是压根不发请求。两码事,别看混了。

改了按钮文案,戏剧就来了

回放零调用如果只到此为止,那跟快照测试没本质区别。关键是它怎么应对变化。我把页面上 "Add task" 按钮改成了 "Create task",再跑:

 ✓ agent adds a task > agent.act "add a task named "buy milk"" 16.97s · 2 model calls
 Cache  1 handed off

这次是 handed off:回放第一步 type 还成功了(输入框没变),到 tap 一步找不到叫 "Add task" 的按钮,判定失效,把已回放的部分和停下来的原因一起递给 agent,agent 现场重新看页面、点了 "Create task",测试照样绿。2 次模型调用、11.3k tokens——只为了变化的那个部分付钱。

更有意思的是跑完再去看缓存文件,里面 tap 的目标已经变成 tap button "Create task" 了。接管完成后 agent 会重新录制当前页面的决策,下一轮又回到零调用。我又跑了一遍确认:

 Cache  1 replayed

顺带一提,这次接管的 17 秒比第一次纯 live 的 3.5 秒还慢得多。接管不是从头重跑——agent 拿到的是"回放到哪一步、为什么停下"的现场记录,从当前屏幕接着找新的按钮——但它要先消化这份记录再行动,反而多花了一截。省钱的机制在变更那天会先付一笔"找路费",这个心理预期得有。

整个闭环就这么转起来了:录制 → 免单回放 → 变化 → 接管重录 → 再免单。变更检测不是靠 diff 整个页面,而是靠"终态锚点还能不能对上",这个设计判断我觉得挺聪明——它只关心这个测试关心的东西变了没有。

这笔账到底怎么算

单看数字:我的用例首次 16.8k tokens,回放 0。放大到真实项目,假设 50 个 agent 用例、每个 PR 跑一轮、一轮烧 15 万 token——回放缓存之下,只有 app 真的变了、或者测试指令变了,才会重新产生费用,日常 CI 的大多数轮次是免费的。对一个跑在每次 PR 上的测试套件来说,这不是省一点,是把"固定成本"改成了"变更触发"。

当然账也有另一面。agent.assert 这类验证步骤永远 live,该花的钱省不掉;agent 从屏幕上现读的值(验证码、日期、它自己拼出来的字符串)进不了缓存,官方管这个叫 gap,遇到就现场重跑;缓存键绑定了测试名、指令、参数和 agent 上下文,改了任何一样都会 miss 重录。参数里藏时间戳、一次性邮箱这种每跑一次变一次的值更要小心,会让缓存每次都失效,官方给的解法是用 unique() 把值包一层,回放时动态替换成当前值。还有个 --strict-cache 开关值得知道:默认情况下过期缓存的失效是静默的(agent 接管,测试照样绿,费用悄悄回来),开了 strict 之后回放失败直接报 REPLAY_STALE 并拒绝调模型——CI 上想控制成本的话,这个开关比省 token 的 prompt 工程实在多了。

写到这里我的结论也直说:这个框架没有魔法。它只是想明白了一件事——agent 测试里真正贵的是决策,而决策在输入没变的时候,不值得付第二遍钱。如果你的团队正在往 CI 里塞 agent 测试,这套"record once, replay until changed"的思路值得抄——哪怕不用这个框架,判定层的设计(路由归一化、锚点验证、gap 的处理)也能直接搬走。

项目地址:github.com/tester-army/e2e(Apache-2.0),npm 包名就是 e2e,文档在 e2e.tester.army,装完会连离线文档一起塞进 node_modules。