上一篇:不会还有人验收全靠点点点截图吧?配置化断言才喂得回闭环 | YAML 测试
不会还有人把 E2E 当成单测换皮吧?没有归档就没有原料 | 执行器判定归档
系列:《从 Rule 到落地:AI 工程化实践》第 24 篇
标签:E2E · 测试平台 · 判定 · 归档
专栏:从小白到AI落地大师
序言
YAML 用例会写了,童鞋们想拉真链路。E2E 不是单测换皮:真链路、真时序、真配置,成本更高。本篇描述平台最小骨架:执行器、判定策略、失败归档。用虚构名 e2e-harness。
说句自嘲的:我追求过「用例数量好看」,关键路径却三天两头抖。后来砍到五条稳定关键路径,信心反而上升。数量是虚荣,稳定是睡眠。
记住一句话:执行器管跑、判定管色、归档管留证——而「留证」第一条纪律是隔离。上一篇teststation-lite里那个on_fail: capture: ["logs/"]把整个共享目录端走的坑,本篇就当着你的面复现、再当着你的面修掉。下面这份e2e_harness.py纯标准库、复制即跑,把「执行器 → 判定 → flake 归类 → 失败归档(含隔离)」一条链真实跑一遍。
概念
E2E,端到端真链路测试。执行器,拉环境、跑用例集、隔离与汇总。Verdict,判定结果:通过、失败、已知波动等策略输出。失败归档,输入、日志、环境指纹等可回溯材料。
可以理解为:执行器是调度中心,判定是裁判规则,归档是黑匣子。没有黑匣子的赛场,争议只能靠吼。
组件图一张,平台骨架就立住了:
问题在哪
边界不清:把单元测试当 E2E,或反过来用 E2E 冒充一切。判定靠口头例外:「这条 flake 大家知道的」。失败不归档,学习闭环断粮。稳定性工程缺位:乱重试、共享脏数据、时间敏感断言满天飞——平台变成随机数发生器。
举例
执行器职责:拉环境、跑用例集、隔离污染、汇总。判定策略显式:全绿通过;已知 flake 名单单独记账;关键路径必须绿。归档留原始证据。这一整条链,本篇不摆碎片,直接搭一个能跑的e2e_harness.py。
步骤总览(下面这段代码一口气干了四件事):
- 执行器:逐用例跑(这里把结果简化为直接记录),运行留痕统一写进一个共享
logs/——这正是上一篇污染会长出来的温床。 - 判定
verdict:关键路径红 → 整体fail且重试不豁免;名单外失败 → 真红灯;失败全在 flake 名单内 →pass。 - flake 归类
classify:同样是红,按近况失败率分成real_fail / quarantine / flake / stable / too_new——进出名单靠算法,不靠「大家都知道」。 - 失败归档:先复现上一篇
capture: ["logs/"]端走整个目录的污染,再用「一用例一沙盒」把它修掉,最后落meta.json。
判定策略配置(代码里读的就是它):
policy:required_suites:["smoke_critical"]# 关键套件:红则整体红,重试不豁免allow_flake:["ui_anim_03"]# 已知波动:单独记账,不进豁免真红max_retries:1# 重试是止血不是隐瞒,写小完整可跑:e2e_harness.py
# e2e_harness.py —— E2E 最小骨架:执行器 -> 判定 -> 失败归档(含隔离)。承接 ep21 的 capture 污染伏笔并修掉# 纯标准库,复制即跑。用法:python e2e_harness.pyimportjson,shutilfromdataclassesimportdataclassfrompathlibimportPath@dataclassclassCaseResult:id:strsuite:strpassed:boolcritical:bool=Falsefailed_expect:str=""history:tuple=()# 近 N 次 True/False,供 flake 统计SUITE=[CaseResult("adapt_smoke_01","smoke_critical",True,critical=True),CaseResult("adapt_forbid_no_sample","smoke_critical",True,critical=True),CaseResult("adapt_wrong_expect","smoke_critical",False,critical=True,failed_expect="stdout_contains 'PERFECT_TOKEN'",history=(True,True,False,True,False,False)),CaseResult("ui_anim_03","visual",False,failed_expect="screenshot diff > 0.02",history=(False,True,False,True,False,True)),]POLICY={"required_suites":["smoke_critical"],"allow_flake":["ui_anim_03"],"max_retries":1}FLAKE_RATE,WINDOW,MIN_RUNS=0.3,20,5defverdict(runs,policy):"""判定:关键路径必绿且重试不豁免;名单外失败一律真红灯;flake 名单只豁免已知波动。"""forrinruns:ifr.criticalandnotr.passed:return"fail",f"关键路径{r.id}红,重试不豁免"real=[r.idforrinrunsifnotr.passedandr.idnotinpolicy["allow_flake"]]ifreal:return"fail",f"名单外红灯:{real}"return"pass","全绿,或失败全在 flake 名单内"defclassify(case):"""flake 归类:样本不足先观察;在名单且失败率高=隔离整改;在名单且偶发=已知波动;不在名单=真红。"""recent=case.history[-WINDOW:]iflen(recent)<MIN_RUNS:return"too_new"rate=sum(1forpinrecentifnotp)/len(recent)ifrate==0:return"stable"ifcase.idinPOLICY["allow_flake"]:return"quarantine"ifrate>FLAKE_RATEelse"flake"return"real_fail"deftree(root):lines=[]forpinsorted(root.rglob("*")):rel=p.relative_to(root)indent=" "*(len(rel.parts)-1)lines.append(indent+p.name+("/"ifp.is_dir()else""))returnlinesdefmain():out=Path("_e2e_out")ifout.exists():shutil.rmtree(out)out.mkdir(parents=True)# A. 执行器:跑用例(此处简化为直接记结果),把运行留痕统一写进共享 logs/ —— ep21 污染的温床shared=out/"shared_logs"shared.mkdir()print("== 执行器:逐用例跑,留痕写进共享 logs/ ==")forrinSUITE:(shared/f"{r.id}.log").write_text(f"{r.id}->{'PASS'ifr.passedelse'FAIL '+r.failed_expect}\n",encoding="utf-8")print(f" [{'绿'ifr.passedelse'红'}]{r.id}suite={r.suite}"+(""ifr.passedelsef" failed_expect={r.failed_expect}"))# B. 判定:一次评估定全局色v,why=verdict(SUITE,POLICY)print(f"\n== 判定:verdict(policy) ==")print(f" verdict={v}理由={why}")# C. flake 归类:同样是红,红得不一样print("\n== 判定:失败用例的 flake 归类(红不等于该删,也不等于能豁免)==")forrinSUITE:ifnotr.passed:rate=sum(1forpinr.historyifnotp)/len(r.history)print(f"{r.id}:{classify(r)}(近{len(r.history)}次失败率{rate:.0%}, 在allow_flake={r.idinPOLICY['allow_flake']})")# D. ep21 的坑:on_fail capture=["logs/"] 把整个共享目录端走 -> 污染failed=next(rforrinSUITEifnotr.passedandr.critical)polluted=out/"capture_polluted"/failed.idpolluted.mkdir(parents=True)forfinshared.iterdir():shutil.copy(f,polluted/f.name)p_files=sorted(p.nameforpinpolluted.iterdir())print(f"\n== 归档:捕获现场,改前 vs 改后 ==")print(f" [改前] capture=['logs/'] 端走整个共享目录 ->{p_files}")# E. 隔离修复:每条用例独享自己的 box,只 capture 自己box=out/"isolated"/failed.idbox.mkdir(parents=True)(box/f"{failed.id}.log").write_text(f"{failed.id}-> FAIL\n",encoding="utf-8")art=box/"artifacts"art.mkdir()shutil.copy(box/f"{failed.id}.log",art/f"{failed.id}.log")(box/"lesson_hint.md").write_text(f"#{failed.id}\n- failed_expect:{failed.failed_expect}\n- fault_signature: 审核时补\n",encoding="utf-8")c_files=sorted(p.nameforpinart.iterdir())print(f" [改后] 一用例一沙盒,只 capture 自己 ->{c_files}")print(f" 消除的污染项:{sorted(set(p_files)-set(c_files))}")# F. meta.json:字段为第 19/23 篇回流预留;fixture_sha 呼应 ep21meta={"run_id":"2026-10-14/run-001","correlation_id":"run-2026-10-13-a3","fixture_sha":"f7c49c86","verdict":v,"cases":[{"id":failed.id,"pass":False,"failed_expect":failed.failed_expect,"artifact_dir":f"isolated/{failed.id}/artifacts/"}],}(box/"meta.json").write_text(json.dumps(meta,ensure_ascii=False,indent=2),encoding="utf-8")print("\n== 归档树(下篇 lesson 原料从这长出来)==")forlineintree(out/"isolated"):print(" "+line)if__name__=="__main__":main()verdict的分支顺序是刻意的:先扫关键路径,红就直接判死且不进重试豁免——关键路径的绿是底线,不能被「retry 三次终于绿了」蒙混。只有关键路径全绿,才轮到「名单外真红」和「flake 名单豁免」两条。重试也别裸重,加抖动防雪崩齐步打爆下游:wait = base * 2 ** attempt + random.uniform(0, 1)。
跑起来长这样
python e2e_harness.py,真实输出原样贴上:
== 执行器:逐用例跑,留痕写进共享 logs/ == [绿] adapt_smoke_01 suite=smoke_critical [绿] adapt_forbid_no_sample suite=smoke_critical [红] adapt_wrong_expect suite=smoke_critical failed_expect=stdout_contains 'PERFECT_TOKEN' [红] ui_anim_03 suite=visual failed_expect=screenshot diff > 0.02 == 判定:verdict(policy) == verdict=fail 理由=关键路径 adapt_wrong_expect 红,重试不豁免 == 判定:失败用例的 flake 归类(红不等于该删,也不等于能豁免)== adapt_wrong_expect: real_fail (近6次失败率 50%, 在allow_flake=False) ui_anim_03: quarantine (近6次失败率 50%, 在allow_flake=True) == 归档:捕获现场,改前 vs 改后 == [改前] capture=['logs/'] 端走整个共享目录 -> ['adapt_forbid_no_sample.log', 'adapt_smoke_01.log', 'adapt_wrong_expect.log', 'ui_anim_03.log'] [改后] 一用例一沙盒,只 capture 自己 -> ['adapt_wrong_expect.log'] 消除的污染项:['adapt_forbid_no_sample.log', 'adapt_smoke_01.log', 'ui_anim_03.log'] == 归档树(下篇 lesson 原料从这长出来)== adapt_wrong_expect/ adapt_wrong_expect.log artifacts/ adapt_wrong_expect.log lesson_hint.md meta.json输出你看什么
- 一刀多响的判定:四条用例里两条红,但
verdict只看第一条关键路径红就拍板fail,理由写死「重试不豁免」。ui_anim_03也红,可它不是关键路径——判定的优先级不是「红了几条」,是「红的这条该不该要命」。 - 同样是红,红得不一样:
adapt_wrong_expect失败率 50% 但不在flake 名单 →real_fail(真红灯,得修代码);ui_anim_03失败率一样 50% 却在名单里 →quarantine(隔离整改,别当稳定用例供着)。classify用近况把「该删、该观察、该隔离、该豁免」分开记账,取代「这条 flake 大家知道的」这种口头例外。 - 上一篇的坑,这一篇当面复现:
[改前]那行capture=['logs/']端走了整个共享目录——四个用例的日志全被卷进adapt_wrong_expect的 artifacts。这正是 ep23 报告里那句「连同另外两条用例的日志一起进了 artifacts」的污染,单跑没事,套件一起跑就串味。 - 隔离修复见效在文件名层面:
[改后]一用例一沙盒,只 capture 自己那条 log;消除的污染项明确列出被剔掉的三个别人的日志。隔离不是「少拷点」,是每条用例只看得见自己的盒子——归档目录从此不会张冠李戴。 - 归档树就是下篇的原料口:
artifacts/adapt_wrong_expect.log是现场、lesson_hint.md是给记忆层的草稿(fault_signature: 审核时补——签名要人来定,机器只留坑位)、meta.json把correlation_id / failed_expect / artifact_dir串起来。第 25 篇「红灯喂回闭环」就从这棵树里取料。
落盘的meta.json一次把回流要用的字段配齐(fixture_sha呼应上一篇那 6 行夹具指纹,复盘时对不上就是「你跑的不是你看到的夹具」):
{"run_id":"2026-10-14/run-001","correlation_id":"run-2026-10-13-a3","fixture_sha":"f7c49c86","verdict":"fail","cases":[{"id":"adapt_wrong_expect","pass":false,"failed_expect":"stdout_contains 'PERFECT_TOKEN'","artifact_dir":"isolated/adapt_wrong_expect/artifacts/"}]}最小里程碑:先五条稳定关键路径,再扩张。关联 lesson id 预留在归档里,下一篇接回流才不痛苦。
自检
# E2E 骨架自检 - [ ] 能否画出平台组件图? - [ ] 是否有失败归档目录规范? - [ ] 判定策略是否显式(含 flake 名单)? - [ ] 关键路径是否优先稳定? - [ ] 归档是否预留 lesson 关联?两问。第一,口头例外写进策略了吗?没写,就还是口头。第二,红灯目录能否让没参加现场的人复盘?不能,归档还只是日志垃圾桶。
关键路径清单请贴在团队可见处。没有名单的「先稳定」,会变成每人心里各有一条关键,平台永远扩不准。
max_retries 写小一点。重试是止血,不是隐瞒不稳定。靠重试刷绿的 E2E,是穿着西装的随机数。
最小观测字段表(贯穿编排 / MCP / 测试 / lesson)
后面排障请统一用这张表,别各写各的字段名:
| 字段 | 含义 | 谁写 |
|---|---|---|
correlation_id | 一次用户意图的全链路 id | 编排入口 |
command/skill | 命中的路由 | 路由层 |
tool | MCP 工具名 | 协议层 |
risk | 风险级 | Manifest / 工具元数据 |
decision | allow / deny / confirm | 门禁 |
test_case_id | YAML / E2E 用例 id | 测试执行器 |
lesson_id | 审核通过的教训 id | 记忆层 |
日志里能靠correlation_id串起「谁点的菜 → 用了哪本手册 → 调了哪个工具 → 哪条测试红了 → 沉淀哪条 lesson」,你才算有黑匣子,而不是只有报警器。
E2E 执行器最小职责:拉起夹具 → 跑步骤 → 判定 → 归档(原始输出 + correlation_id + 判定)。没有归档的 E2E,只是绿色的节日彩灯,喂不回 lesson。
归档目录建议按日切:artifacts/2026-10-11/<correlation_id>/,内含 stdout、判定 JSON、环境版本。留七天还是三十天,按合规写进 README,别靠磁盘喊疼时再删。
E2E 不是单测换皮:它跨进程、跨工具、跨门禁。单测保函数,E2E 保路径。两条都要,互相替代会瞎。
读到这里,不妨立刻做一个五分钟动作:打开你们仓库,找到与本篇对应的那个目录(没有就新建),把文中最小示意落成真实文件名。文件在,讨论才有坐标;文件不在,下周还会从「我们觉得」开始。第 24 篇的验收不是「读完」,是「仓库里多了一份可指出的东西」。
举例加长:判定 JSON 最小字段
{ "test_case_id", "correlation_id", "pass", "failed_expect", "artifact_dir" }。缺artifact_dir,人审只能靠想象复现。
思考
小结三句。E2E 平台先保关键路径稳定,而非追求用例数量。失败归档是学习闭环的原料库。判定策略要显式,避免口头例外。
可以理解为:黑匣子装好了,再把红灯接进档案室。下篇:从红灯到知识条目的自动与半自动路径,并防假绿。
下篇预告:《不会还有人红灯只会重跑和甩锅吧?假绿自动修是自杀 | 红灯喂回闭环》——假绿自动修,是自杀式优化。
觉得有用的话,欢迎点赞、留言晒你的归档树;有「用例很多关键路径很抖」的体感,也欢迎对照里程碑拆。
专栏:从小白到AI落地大师|《从 Rule 到落地:AI 工程化实践》