ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

24-不会还有人把 E2E 当成单测换皮吧?没有归档就没有原料 | 执行器判定归档

24-不会还有人把 E2E 当成单测换皮吧?没有归档就没有原料 | 执行器判定归档

上一篇:不会还有人验收全靠点点点截图吧?配置化断言才喂得回闭环 | YAML 测试

不会还有人把 E2E 当成单测换皮吧?没有归档就没有原料 | 执行器判定归档

系列:《从 Rule 到落地:AI 工程化实践》第 24 篇
标签:E2E · 测试平台 · 判定 · 归档
专栏:从小白到AI落地大师

序言

YAML 用例会写了,童鞋们想拉真链路。E2E 不是单测换皮:真链路、真时序、真配置,成本更高。本篇描述平台最小骨架:执行器、判定策略、失败归档。用虚构名 e2e-harness。

说句自嘲的:我追求过「用例数量好看」,关键路径却三天两头抖。后来砍到五条稳定关键路径,信心反而上升。数量是虚荣,稳定是睡眠。

记住一句话:执行器管跑、判定管色、归档管留证——而「留证」第一条纪律是隔离。上一篇teststation-lite里那个on_fail: capture: ["logs/"]把整个共享目录端走的坑,本篇就当着你的面复现、再当着你的面修掉。下面这份e2e_harness.py纯标准库、复制即跑,把「执行器 → 判定 → flake 归类 → 失败归档(含隔离)」一条链真实跑一遍。

概念

E2E,端到端真链路测试。执行器,拉环境、跑用例集、隔离与汇总。Verdict,判定结果:通过、失败、已知波动等策略输出。失败归档,输入、日志、环境指纹等可回溯材料。

可以理解为:执行器是调度中心,判定是裁判规则,归档是黑匣子。没有黑匣子的赛场,争议只能靠吼。

组件图一张,平台骨架就立住了:

fail

pass

调度:选套件 / 起夹具

执行器:跑步骤 / 隔离污染

判定:policy 评估

归档:meta + stdout + assert

记忆层:lesson_hint + correlation_id

红灯

绿灯

问题在哪

边界不清:把单元测试当 E2E,或反过来用 E2E 冒充一切。判定靠口头例外:「这条 flake 大家知道的」。失败不归档,学习闭环断粮。稳定性工程缺位:乱重试、共享脏数据、时间敏感断言满天飞——平台变成随机数发生器。

举例

执行器职责:拉环境、跑用例集、隔离污染、汇总。判定策略显式:全绿通过;已知 flake 名单单独记账;关键路径必须绿。归档留原始证据。这一整条链,本篇不摆碎片,直接搭一个能跑的e2e_harness.py。

步骤总览(下面这段代码一口气干了四件事):

  1. 执行器:逐用例跑(这里把结果简化为直接记录),运行留痕统一写进一个共享logs/——这正是上一篇污染会长出来的温床。
  2. 判定verdict:关键路径红 → 整体fail且重试不豁免;名单外失败 → 真红灯;失败全在 flake 名单内 →pass。
  3. flake 归类classify:同样是红,按近况失败率分成real_fail / quarantine / flake / stable / too_new——进出名单靠算法,不靠「大家都知道」。
  4. 失败归档:先复现上一篇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

输出你看什么

  1. 一刀多响的判定:四条用例里两条红,但verdict只看第一条关键路径红就拍板fail,理由写死「重试不豁免」。ui_anim_03也红,可它不是关键路径——判定的优先级不是「红了几条」,是「红的这条该不该要命」。
  2. 同样是红,红得不一样:adapt_wrong_expect失败率 50% 但不在flake 名单 →real_fail(真红灯,得修代码);ui_anim_03失败率一样 50% 却在名单里 →quarantine(隔离整改,别当稳定用例供着)。classify用近况把「该删、该观察、该隔离、该豁免」分开记账,取代「这条 flake 大家知道的」这种口头例外。
  3. 上一篇的坑,这一篇当面复现:[改前]那行capture=['logs/']端走了整个共享目录——四个用例的日志全被卷进adapt_wrong_expect的 artifacts。这正是 ep23 报告里那句「连同另外两条用例的日志一起进了 artifacts」的污染,单跑没事,套件一起跑就串味。
  4. 隔离修复见效在文件名层面:[改后]一用例一沙盒,只 capture 自己那条 log;消除的污染项明确列出被剔掉的三个别人的日志。隔离不是「少拷点」,是每条用例只看得见自己的盒子——归档目录从此不会张冠李戴。
  5. 归档树就是下篇的原料口: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命中的路由路由层
toolMCP 工具名协议层
risk风险级Manifest / 工具元数据
decisionallow / deny / confirm门禁
test_case_idYAML / 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 工程化实践》

返回列表