E2E 测试:全绿不算数,浏览器里走通才算数
这是 Harness 系列的 skill 拆解篇,拆的这一份叫 e2e-testing。上一篇 write-acceptance-criteria
讲的是考卷怎么在代码出现之前出出来——rubric.json 和 e2e-cases.json;这一篇讲考卷到了评测阶段由谁执行、怎么执行:e2e-testing 是评测者的浏览器手册,它拿着那份用例集,在真实浏览器里把每条用户旅程逐条走一遍,再把看到的东西变成分数、发现和对考卷本身的改进建议。母篇《从能跑到可交付:一套前端 AI 协作系统的设计笔记
》讲整套系统的形状,这个系列逐份拆 skill:为什么有它,具体怎么干。我用的系统是开源的 frontend-harness-cli
,文中所有规则都能在仓库里找到对应实现。
一套全绿、产品全坏的测试
先看一个具体的场景。
一个待办应用的端到端测试套件全绿:登录用例过、建任务用例过、删任务用例过,三十多条,一条不红。真人打开应用点一遍:新建任务,列表里没出现;刷新一下,出现了,两条。
测试为什么全绿?看断言就明白了。建任务的用例断言的是"提交之后接口返回 200"——接口确实返回了 200,只是前端把返回的数据渲染丢了。列表用例为了稳定,把后端整个 mock 掉,断言"列表区出现了三行"——mock 永远给三行数据,真实接口返回的是分页对象,组件读不到数组,渲染成空白。还有几条用例,等的是一个写在代码注释里的理想选择器,元素后来改了名,选择器等不到,就改成"等页面上出现任意文本"——页面上永远有文本。
这些用例没有一条在说谎:它们断言的每件事都真实发生了。问题是它们断言的全是代码自己说的事——调了哪个函数、返回了什么码、DOM 里有没有某个节点——没有一条断言用户看得见的事:点了新建,列表里多出一行,内容就是刚输入的那条。
绿灯亮着,只能证明测试在自证。这份 skill 存在的理由,就是把"证明"这两个字夺回来:证明的场地是真实浏览器,证明的对象是用户旅程,证明的材料是截图和产物,不是代码的自述。
这份 skill 是什么:在真实浏览器里,把旅程重走一遍
先把它的位置说清。e2e-cases.json 里的用例是澄清阶段由 write-acceptance-criteria 写好的:六个类别(core、negative、boundary、crossFeature、persistence、uiJourney)各有下限,每条带 mapsTo(验的是哪条 REQ)、route、steps、expect,core 和 negative 里各至少一条标了 blocking: true。出题在写代码之前;e2e-testing 不出题,它是执行者——评测阶段(evaluate 和 confirm 两轮都用它)拿着用例集,对着真实跑起来的应用逐条执行、逐条留证据。
它同时还"维护"用例集,但维护的方式很克制,后面会讲:它能提改进建议,不能自己改考卷。
工具只有一条路,不依赖任何 IDE:浏览器相关的一切都用 Playwright——脚本化用例用 @playwright/test 跑 spec,页面截图用 harness capture(底层也是 Playwright),自由探索直接用 Playwright(或宿主提供的 Playwright MCP 服务)。skill 里把话说死了一句:环境里装不上 Playwright,评估就是 blocked,如实报告;绝不用 API 调用、单元测试或读源码代替浏览器旅程。上一篇讲语言层时说过,消音不是修复;这里是同一条纪律的另一面:拿不到真正的证据,就报"没验成",不许拿别的东西冒充验过。
它怎么干:从启动应用到写报告,八步
第一步,启动应用。 按 tech-design.md 里的"运行说明"把应用跑起来。起不来——这就是第一个发现,先记下,别绕过去测别的。应用都启动不了,后面所有"通过"都没有地基。
第二步,跑脚本化用例。 e2e-cases.json 里每条用例都必须有结果:PASS、FAIL 或 SKIP,SKIP 必须写理由。失败的 blocking 用例就是 blocking 发现——这一条不过,功能不许往下走。六个类别在这里各有各的用处:core 证明主旅程走得通;negative 证明输错、没数据、没权限、断网时不塌;boundary 探大数据量和极端输入;crossFeature 验"在这里的操作在该出现的地方真的出现了";persistence 验刷新、重开之后数据还在;uiJourney 验加载、空态、反馈这些界面状态。写用例时配额管的是"有没有",跑用例时分类管的是"这一类失败意味着什么"。
第三步,截图。 harness capture <feature> --run-id c<cycle>-<n> 把核心页面逐页截下来,运行状态必须是 pass。有一条禁令值得单说:证据运行不许用 --allow-fallback——降级拍出来的图不能当证据,因为你不知道你截到的是产品还是降级页。有视觉基线时再跑 harness visual <feature> 做对比;没有基线就如实写 no-baseline,skill 原话:no-baseline 不是匹配,不能把"没比过"说成"比过了"。
第四步,逐张看截图。 不是截完存档就完事——要打开每一张 PNG,逐页写一段结构化的结论:层次、色彩与对比度、间距与对齐、溢出、各种状态,以及和愿景里每个参照(REF-n)之间的差距。能量化的就量化,用 page.evaluate 在页面里实测。截图是给人(和评判者)看的证据,只存不看,等于没截。
第五步,探索。 脚本化用例之外,至少做三种脚本之外的探测,从启发式文档里选:重复提交、超长输入、浏览器前进后退、编辑到一半刷新、乱序操作、开两个标签页、模拟慢网络。脚本化用例验的是"说好要验的",探测验的是"没想到的"——真实用户不会只按 steps 走。
第六步,打分。 给每个 judge 条目写一份证据 JSON:score 只能是 1 / 0.5 / 0,note 写明为什么是这个分,artifacts 列出凭据(截图、spec 输出、API 日志),heuristics 标注用到的启发式编号。凡是靠截图核查的红线条目,artifacts 里必须有真实的 PNG 文件——嘴说"看过了"不算。
第七步,给考卷提改进建议。 执行中发现条目难打分、没法证明、某个缺陷压根没有条目管、某条级别定错了,就往报告里加 rubricRefinementSuggestions,类型分别是 refine、add-verification、add、regrade。
第八步,写报告。 产出 evaluation/eval-<cycle>.md,结构是固定的:用例结果表(用例、类别、是否 blocking、结果、证据)、judge 条目打分表、与参照的逐项对比表、探索发现表、问题清单、rubric 改进建议表。写完报告就收工——明确不许运行 harness eval,汇总与推进状态是内核的事,不是评测者的事。
另外三条规则贯穿始终。一,绝不改产品代码,绝不改 rubric——发现问题只能记下来,这是评测者不下场踢球的纪律。二,绝不读开发者的工作痕迹:developer/**、failure-log.md、提交信息一律不看——评测只对产品和考卷负责,看了开发过程,打分就会不自觉地替意图找补。三,确认轮从零开始:新一轮 capture、新一份证据文件,上一轮的截图和结论不许复用——证据必须是这一轮、当着这一轮的代码现采的。
细节一:断言钉在用户看得见的结果上
回到开头的场景:测试全绿、产品全坏,病根在断言的位置。这份 skill 把断言的位置钉死在三处。
第一处是用例的预期本身。用例集里的 expect 写的是每一步之后用户该看到什么——列表里多出一行、角标数字加一、空状态出现引导文案。执行时对的就是这些可见结果,而不是接口的返回码。接口返回 200 而页面没更新,在这套口径下就是 FAIL,没有商量。
第二处是证据的形态。judge 条目打分必须附 artifacts;结论必须落在一张能打开看的截图、一份 spec 的真实输出上。“我读了源码,逻辑是对的"在这份 skill 里根本不是一种合法的证据——工具一节把话说在前头:读源码连替代浏览器旅程的资格都没有。
第三处是截图要真的被看。第四步要求逐张打开 PNG 并写出结构化结论,差距要逐条对到 REF-n 上。很多视觉问题——文字溢出、对比度不够、空态一片白——在 DOM 和断言里根本不存在,只在人眼(或认真看图的评判者)面前存在。把"看图"写成流程里不可跳过的一步,就是承认这类问题只有看才抓得到,然后强制去看。
细节二:弱化一条失败的用例,和给编译器消音是同一件事
用例跑红了,手边最便宜的修法永远是改用例:把断言放宽一点,把等不到的选择器换成一个宽松的,把这条先 skip 掉。语言层那一篇里说过,AI 面对挡路的检查会选便宜的路——报错行上贴 @ts-ignore 只要一行注释,改断言只要一行代码,而且改完立刻变绿,成就感一模一样。
这份 skill 把这条路从三个方向堵上。
第一,权限上堵:评测者绝不改产品代码、绝不改 rubric。用例属于 rubric 体系的一部分,出题权在澄清阶段、修改权有一套单独的流程;执行者手里没有笔,想弱化也无从下手。
第二,留痕上堵:每条用例必须给出结果,SKIP 必须附理由,写进固定结构的报告里。跳过一条不再是悄无声息的删除,而是一笔要当众解释的账。
第三,给出正道:发现考卷本身有问题——条目没法证明、缺陷没有条目、级别不对——不是不许提,而是走 rubricRefinementSuggestions,由内核编号、在后续轮次里处理,critical 和 major 的发现还必须对应一个 rubric id 或一条 add 建议,保证每个重要问题下一轮都有地方落。考卷可以改,但要留名、要编号、不能由考生在考场上自己改。
同样的逻辑也管"不稳定的用例”。skill 的工具一节有一句容易滑过去的话:某种方式失败了(比如选择器不稳、超时),换方式,而不是反复跑同一条失败命令。这句话的两面都要读全:一面是禁止拿"多跑几次总有一次过"当通过——同一次失败重跑到绿,那个绿不证明任何事;另一面是定位失败发生在哪一层——选择器不稳是测试方式的缺陷,当场换一种定位方式解决它,把缺陷留在明处,而不是用重试把它冲淡成"偶尔红一下"。一次通过可能来自运气;这套评测在别处(确认阶段)还要专门防运气,这里先把"靠重试蹭过去"这条最常见的运气来源掐掉。
细节三:每条用例都带着 REQ 编号,失败要能找到家
验收标准那一篇讲过,每条用例带 mapsTo,写明它验的是哪条需求。这份 skill 是这条设计的消费端:报告里每条用例一行结果,critical 和 major 发现必须映射到一个 rubric id(或者一条 add 建议)——不许出现"发现了一个挺严重的问题"这种没有归属的条目。
为什么对"归属"这么较真?因为评测报告的读者不是收藏家,是下一轮的修复者。一条 FAIL 如果只写"结算用例失败",修复的人还要自己去猜它对应哪条需求、哪项标准、改的时候该盯住什么;带着 REQ 编号和 rubric id,失败就直接落到契约的某一行上:需求是这么写的、标准是这么定的、证据是这张截图。复盘时也能反过来查:哪条需求反复出问题,是实现不稳,还是标准本身写得有毛病——后者的答案就回到第七步的改进建议里。用例、标准、需求三者共用一套编号,评测才是一套能审计的系统,而不是一堆孤立结论。
它的边界
把边界说清。
它只执行和维护,不出题。证据的质量在上游就定了一大半:某一类旅程如果压根没人写用例,它不会在评测现场临时发明一条出来——最多靠探索探测撞见,再写成改进建议送进下一轮。用例集本身写得弱,执行得再守纪律,评测还是弱。它保证的是"说好要验的,都如实验了",不是"要紧的都验到了"。
它也很贵,而且是刻意贵。真实浏览器、整条旅程走完、每张截图打开细看、至少三种探测,确认轮还要从零再来一遍——没有一样便宜。它是整个评测体系里最慢、最重的一层,所以才有类型检查、扫描、命令条目这些快层在前面先筛,把它留给只有它能回答的问题:一个用户在真实浏览器里,到底能不能把这件事办成。
它也只管浏览器旅程够得着的地方。一个接口被直接调用时,响应形状有没有漂移、错误返回的结构是不是约定的样子,不是走一遍 UI 能定论的——那是相邻 skill 的地盘。系列下一篇拆 api-testing:E2E 证明旅程走得通,API 测试证明契约没漂移。
…[truncated 863 chars]