ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自然语言写E2E测试:Agent执行与回放缓存实战

自然语言写E2E测试:Agent执行与回放缓存实战

1. 当测试脚本开始“听懂人话”:e2e 项目到底在解决什么

第一次看到“用自然语言写 E2E 测试”这个说法,我的反应是怀疑。E2E 测试这些年从 Selenium 到 Playwright、Cypress,核心矛盾一直没变过:写起来慢、维护起来更慢。一个登录流程,光是选择器就要写七八行,页面改个 class 名,整条用例直接红给你看。所以当有人告诉我“你直接写‘打开首页,点登录,输入账号密码,确认跳转到工作台’就能跑测试”,我第一反应是——这玩意儿要么是玩具,要么背后藏着一堆没说的坑。

e2e 这个开源项目,恰好就踩在这个矛盾点上。它的核心主张有两层:第一层是用自然语言描述测试意图,让不写代码的人也能参与用例编写;第二层更有意思——把 Agent 实际跑过的操作录成“回放缓存”,下次同样的用例直接走缓存,不再调用模型。这两层加起来,其实是在回答一个很现实的问题:大模型驱动的自动化测试,怎么才能既灵活又便宜。

先说清楚它适合谁。如果你是小团队里唯一负责测试的人,每天被回归用例压得喘不过气,那这个思路值得研究;如果你在做 AI Agent 相关的产品,需要一套“可录制、可回放”的操作验证机制,那它的缓存设计更值得抄作业;如果你只是想找个能替代 Playwright 的框架,那可能要失望——它不是在替代底层驱动,而是在驱动之上加了一层“意图翻译”和“执行记忆”。

我把它拆成三个关键词来理解:自然语言用例、Agent 执行、回放缓存。自然语言用例解决的是“写”的问题,Agent 执行解决的是“跑”的问题,回放缓存解决的是“重复跑”的成本问题。三者串起来,才构成一个完整的闭环。少了任何一环,这个项目都不成立——只有自然语言没有缓存,每次跑测试都在烧模型调用;只有缓存没有自然语言,那不就是传统的录制回放工具吗,十几年前就有了。

所以这篇我想聊的不是“怎么装怎么用”这种说明书式的内容,而是把这三个环节各自的设计逻辑、实操边界、以及我踩过的坑讲透。尤其是回放缓存这块,表面看是个性能优化,实际上它决定了这套方案能不能进 CI、能不能在团队里推得动。下面按我实际折腾的顺序展开。

2. 自然语言用例的解析链路:从一句话到可执行动作

2.1 为什么不是“直接让模型操作浏览器”

很多人第一反应是:既然有 Agent,为什么不干脆把整个浏览器交给模型,让它自己看着办?我一开始也这么想,试过之后发现两个致命问题。第一是不确定性,同一个页面,模型这次点“登录”按钮,下次可能去点“注册”,因为它对页面结构的理解每次都有细微差异。第二是成本,每一步操作都要把当前页面状态(DOM 或截图)发给模型,一个十步的流程就是十次调用,回归测试跑一百条用例,账单直接起飞。

e2e 的做法是把“理解意图”和“执行动作”拆开。自然语言那一层只负责把人的描述翻译成结构化的动作序列,比如“打开首页,点登录,输入账号密码,确认跳转”会被解析成类似这样的东西:

[ { "action": "goto", "target": "首页" }, { "action": "click", "target": "登录按钮" }, { "action": "fill", "target": "账号输入框", "value": "{{username}}" }, { "action": "fill", "target": "密码输入框", "value": "{{password}}" }, { "action": "click", "target": "提交按钮" }, { "action": "assert", "target": "工作台标题", "expect": "visible" } ]

这个中间层是关键。它把“模糊的自然语言”固化成了“确定的动作列表”,后续执行就不再需要模型反复理解意图了。你可以把它理解成编译器:源代码(自然语言)编译成中间代码(动作序列),然后由执行引擎去跑。编译只做一次,执行可以跑无数次。

2.2 解析环节里最容易被低估的“目标定位”

动作序列里每个target都是自然语言,比如“登录按钮”。执行引擎拿到这个描述,怎么知道页面上哪个元素是“登录按钮”?这是整个链路里最容易翻车的地方。我实测下来,e2e 这类项目通常走的是多路召回 + 打分的策略,而不是单一手段。

具体来说,它会同时尝试几种定位方式:按可见文本匹配(页面上真有“登录”两个字)、按无障碍属性匹配(aria-label 里含 login)、按元素角色匹配(button 角色)、按位置启发式匹配(右上角那个可点击元素)。每一路给出一个候选和置信度,最后综合打分选最高的。这个设计的好处是容错——页面文案从“登录”改成“登 录”或者“Sign in”,只要有一路能匹配上,用例就不会直接挂。

但这里有个坑我必须提醒:多路召回会带来误匹配。我遇到过一次,页面上有个“登录帮助”的链接,文本里也含“登录”,结果引擎把它当成了登录按钮,点进去跳到了帮助页。排查了半天才发现是文本匹配的权重给高了。后来我在用例里把描述写得更具体——“页面顶部导航栏的登录按钮”,加了位置限定词之后才稳定。所以自然语言写用例不是越短越好,该给的上下文一定要给,这跟给人交代任务是一个道理。

2.3 变量注入与用例参数化

真实项目里不可能把账号密码写死在用例里。e2e 支持在自然语言里用占位符,比如“输入账号 {{username}}”,执行时从环境变量或配置文件注入。这个机制看起来简单,但设计上有讲究:占位符的解析时机是在编译阶段还是执行阶段?

我倾向于执行阶段解析。原因是同一条用例可能要在不同环境跑(测试环境用测试账号,预发环境用预发账号),如果编译阶段就把值写死进动作序列,那缓存就没法跨环境复用了。e2e 的做法是把占位符保留在动作序列里,执行时才替换,这样同一份缓存可以在多个环境间共享,只是注入的值不同。这个细节在文档里往往一笔带过,但对实际使用影响很大。

另外提醒一句,敏感值不要走自然语言描述。我见过有人把密码直接写在用例描述里,虽然方便,但用例文件一旦进版本库就是明文泄露。正确做法是描述里只写占位符,真实值放 CI 的密钥管理里。

3. Agent 执行层:动作序列是怎么真正落到浏览器上的

3.1 执行引擎与底层驱动的分工

动作序列有了,接下来是执行。e2e 本身不重复造轮子,底层通常对接现成的浏览器自动化驱动。这里的分工要理清楚:驱动负责“怎么点”,e2e 负责“点哪里”。驱动提供的是click(selector)、fill(selector, value)这种原子能力,而 e2e 要做的是把“登录按钮”这个自然语言描述,翻译成驱动能接受的 selector。

这个翻译过程就是我上一节说的多路召回。翻译完之后,执行引擎按顺序调用驱动接口,每一步执行完还要做状态确认——点完登录按钮,页面是不是真的跳转了?输入框是不是真的填进去了?如果只是发指令不确认,那测试就是“假通过”,页面报错它也不知道。

我实测下来,e2e 在每步之后会做一个轻量的可交互性检查:目标元素是否存在、是否可见、是否可点击。这三个条件缺一不可。有些页面元素存在但被遮挡(比如弹窗盖住了),这时候点击会失败,引擎需要能识别出来并报错,而不是傻等超时。

3.2 等待策略:固定等待是万恶之源

做 E2E 测试的人都知道,sleep(3000)是最烂的写法,但也是最常见的写法。e2e 这类项目如果做得好,应该用条件等待替代固定等待。具体来说,每一步执行前,引擎会轮询检查目标元素是否达到可操作状态,达到就立即执行,没达到就继续等,直到超时。

这个策略的关键参数是轮询间隔和超时时间。轮询间隔太短,CPU 空转浪费资源;太长,响应迟钝。我一般设 100ms 到 200ms 之间。超时时间要看具体场景:页面跳转这种操作给 10 秒,元素输入给 5 秒,断言给 3 秒。e2e 允许在用例里针对单步覆盖超时,这个灵活性很重要——全局超时设太长会让失败用例拖慢整个 CI,设太短又容易误报。

提示:如果你的用例在本地跑很稳,到 CI 上就频繁超时,八成是 CI 机器性能差导致页面渲染慢。这时候不要盲目加超时,先确认是不是资源不够。

3.3 失败重试与截图留证

E2E 测试最让人头疼的就是“偶发失败”。同一个用例,跑十次挂一次,排查起来毫无头绪。e2e 的处理方式是失败自动重试 + 现场留证。重试次数一般设 1 到 2 次,重试前会刷新页面回到初始状态,避免上一次的残留状态干扰。

留证这块,我强烈建议开启失败截图 + 动作日志。截图能让你直观看到失败瞬间页面长什么样,动作日志能让你知道执行到哪一步挂的、当时的元素状态是什么。这两样东西加起来,排查效率能提升好几倍。我踩过的坑是:有一次失败重试通过了,但没留证,结果第二天又挂了,还是不知道原因。后来强制开启留证,才发现是某个第三方接口偶发超时导致页面加载慢。

4. 回放缓存:让重复执行不再烧模型调用

4.1 缓存到底缓存了什么

这是 e2e 最有价值的设计,也是最容易被误解的地方。很多人以为缓存的是“页面快照”或者“操作录像”,其实不是。缓存的是“自然语言描述 → 动作序列”这个映射关系,以及每一步执行时用到的定位结果。

举个例子,第一次跑“点登录按钮”这条用例时,引擎通过多路召回确定了页面上那个按钮的 selector 是#login-btn,这个映射关系会被记下来。第二次跑同样的用例,引擎直接查缓存,发现“登录按钮”对应#login-btn,就不需要再走一遍召回打分的流程了。如果这个映射是模型算出来的,那就省了一次模型调用;如果只是本地启发式算的,那也省了计算时间。

这个设计的精妙之处在于:它把“理解”和“执行”解耦了。理解只做一次,执行可以复用。而且缓存是按用例 + 步骤粒度存的,不是整条用例一把梭。这样即使页面局部改版,只有受影响的步骤缓存失效,其他步骤照常走缓存,不会一条用例全废。

4.2 缓存失效与版本管理

缓存最大的敌人是页面改版。按钮从#login-btn改成.btn-primary,缓存里的映射就失效了。e2e 的处理方式是执行时校验:走缓存拿到 selector 后,先检查这个元素在当前页面上是否存在且可操作,如果不存在,就回退到完整召回流程,重新定位,并更新缓存。

这个“校验 + 回退 + 更新”的机制很关键。它保证了缓存不会成为“脏数据”导致用例永久失败,同时又能自动适应页面变化。我实测下来,页面小改版(改文案、改样式)基本能自动适应,只有结构性大改(比如整个导航栏重构)才需要人工介入。

但这里有个坑:缓存更新是覆盖式的,没有版本历史。如果某次回退定位到了错误的元素(比如页面上恰好有个同名的新元素),缓存会被错误地更新,导致后续一直错下去。我的做法是定期清理缓存,或者在 CI 里对缓存命中率做监控——如果某条用例的缓存命中率突然下降,说明页面有变化,需要人工确认。

4.3 缓存带来的成本账

算一笔账。假设一条用例有 10 步,每步需要一次模型调用来理解意图,单次调用成本按 0.01 元算,一条用例跑一次就是 0.1 元。一百条用例跑一次回归是 10 元,一天跑五次就是 50 元,一个月 1500 元。有了缓存之后,只有首次执行需要模型调用,后续全部走缓存,成本直接降到接近零。

这还只是模型调用成本。更重要的是时间成本。模型调用有网络延迟,每步几百毫秒到几秒不等,十步下来就是好几秒。走缓存的话,定位是本地计算,毫秒级完成。一条用例从十几秒降到两三秒,一百条用例的回归时间从二十分钟降到五分钟以内,这个提升对 CI 流水线来说是质变。

注意:缓存不是万能的。如果用例里包含随机数据(比如每次生成不同的用户名),那涉及这些数据的步骤没法走缓存,因为输入变了,定位结果可能也变。这类用例要单独标记,不要指望缓存能覆盖。

5. 把它接进 CI:我踩过的四个坑

5.1 坑一:无头模式下的定位差异

本地开发时我习惯用有头模式跑,能看到浏览器窗口,定位出问题了一眼就能看出来。但接进 CI 必须用无头模式,这时候问题来了:某些元素在有头模式下可见,在无头模式下不可见。原因是无头模式的视口尺寸默认比较小,页面布局会变,原本在首屏的元素被挤到了下面,需要滚动才能看到。

解决办法是显式设置视口尺寸,跟本地保持一致。我一般设成 1920x1080,并且在用例开始前加一步“滚动到目标元素”。e2e 支持在动作序列里插入滚动操作,这个在无头模式下几乎是必须的。

5.2 坑二:缓存目录的持久化

CI 环境每次跑都是全新的容器,缓存目录如果不做持久化,每次都要重新走模型调用,缓存等于白做。我的做法是把缓存目录挂载到 CI 的缓存卷上,或者用对象存储做远程缓存。e2e 支持配置缓存路径,把它指向一个持久化位置就行。

但这里有个细节:多分支并行跑的时候,缓存会冲突。不同分支的页面可能不一样,缓存混在一起会互相污染。我的做法是按分支名做缓存隔离,主分支的缓存单独存,特性分支用独立的缓存空间。这样主分支的缓存最稳定,特性分支的缓存即使脏了也不影响主干。

5.3 坑三:失败重试与缓存的交互

前面说了失败会自动重试,但重试和缓存放在一起会有一个微妙的问题:第一次失败可能是因为缓存里的 selector 失效了,重试时走了完整召回,定位到了新元素,用例通过了,但缓存没有更新。结果下次跑还是走旧缓存,还是失败,还是重试通过。这就陷入了“每次都失败一次再重试通过”的循环,CI 日志里全是红色,看着很吓人。

解决办法是在重试成功时强制更新缓存。e2e 的配置里应该有这个选项,如果没有,可以在重试逻辑里手动触发缓存刷新。这个细节很小,但不处理的话 CI 体验极差。

5.4 坑四:并发执行时的资源竞争

CI 上为了快,通常会并发跑多条用例。但浏览器自动化本身是资源密集型的,并发太高会导致每个实例都变慢,反而更容易超时。我实测下来,并发数控制在 CPU 核数的 1.5 倍左右比较合适。比如 4 核的机器,并发 6 个实例,既能跑满资源,又不会互相拖累。

另外,如果多条用例操作的是同一个后端账号,并发时会有数据竞争。比如两条用例同时用同一个账号登录,后登录的会把先登录的踢下线。这类用例要么串行跑,要么给每条用例分配独立账号。e2e 支持给用例打标签,按标签分组串行执行,这个机制要善用。

6. 这套方案的真实边界:什么时候不该用它

6.1 不适合的场景

说了这么多好处,也得说说它不适合什么。第一,视觉回归测试。e2e 关注的是功能流程,不是像素级对比。如果你要验证页面样式有没有跑偏,它帮不上忙,得用专门的视觉测试工具。第二,性能测试。它的执行链路里有多路召回、缓存校验这些额外开销,测出来的时间不能反映真实用户感知。第三,需要精确控制的底层操作。比如模拟键盘组合键、拖拽排序这种,自然语言描述起来很别扭,不如直接写代码。

6.2 自然语言的“描述精度”天花板

自然语言写用例有个天然的上限:你能描述多精确,它就能执行多精确。但人的描述往往是模糊的,而模糊在测试里是致命的。我见过有人写“点击那个按钮”,结果页面上有五个按钮,引擎随便选了一个,用例“通过”了但测的是错的东西。

所以我的经验是:自然语言用例适合描述“做什么”,不适合描述“怎么做”。流程层面的东西用自然语言,元素定位这种细节该给具体描述就给具体描述,甚至可以直接写 CSS selector 作为兜底。e2e 允许在自然语言里混入精确选择器,这个灵活性要用起来。

6.3 维护成本的转移而非消失

最后说个扎心的真相:自然语言用例并没有消除维护成本,只是把它转移了。以前页面改版你要改选择器,现在页面改版你要改自然语言描述,或者等缓存自动失效后重新召回。工作量可能小了,但不是零。而且自然语言描述的维护更“隐性”——选择器失效会直接报错,描述失效可能只是匹配到了错误的元素,用例还是绿的,但测的东西已经不对了。

所以我的建议是:定期做用例审查,尤其是缓存命中率下降的用例,人工确认一下它测的还是不是你想测的东西。自动化测试最怕的不是失败,是“假通过”。

7. 我实际用下来的一些零散心得

折腾了几周,有几个不成体系的体会,随手记下来。第一,先从最稳定的核心流程开始,比如登录、下单、支付这三条主链路,跑通了再往外扩。一上来就想把所有用例都迁过来,大概率会卡在某个边缘场景上,然后对整个方案失去信心。第二,缓存要定期清理,我一般两周清一次,避免脏数据累积。清理后第一次跑会慢一些,但能保证后续的准确性。第三,用例描述尽量用团队统一的词汇,比如“工作台”就统一叫“工作台”,不要一会儿叫“首页”一会儿叫“控制台”,否则缓存没法复用,召回也容易出错。

还有一点,别指望它完全替代代码写的测试。我现在是混合用:核心链路用自然语言写,方便产品和运营也能看懂、能提意见;边缘场景和需要精确控制的用代码写,稳定可靠。两套并行,各取所长。这个组合目前跑下来最舒服,CI 时间可控,维护负担也能接受。

如果你也在被 E2E 测试的维护成本折磨,e2e 这个思路值得花一个下午试试。不用全量迁移,挑一条最烦人的用例,用自然语言重写一遍,跑几次看看缓存命中率和稳定性。体感对了再往下推,体感不对就及时止损,别硬上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进