上一篇把 AI 分诊接进 CI 时,我们留了一道默认关着的门:L3,让 AI 自己改代码。这一篇推开一条缝,聊它最成熟、风险最低的一个落地场景——前端改版把脚本弄失效了,让 AI 自动修脚本(self-healing,测试自愈)。
2026 年主流框架和商业平台大多已经内置自愈:Playwright 有官方 healer,Cypress 有 cy.prompt,Selenium 系有 Healenium,商业平台 Testim/Mabl 更是标配。如果你是 Python 栈、官方方案覆盖不到,第四章给了一套可直接跑的实现。
一、先搞清楚:自愈到底在修什么,边界在哪
一个最常见的早晨:前端把 #submit-btn 改名成 #checkout-submit,或者把 <button> 换成带 role="button" 的 <div>,一片用例红了,报错全是"元素找不到 / 等待超时"。产品没坏,是脚本和新版界面对不上了。 这种维护长期吃掉测试工程师大量时间,正是自愈要解决的。
但"脚本坏了"不只有定位器这一种原因。业界(QA Wolf 等厂商的生产口径,数字供参考)把不是产品 bug 的测试失败大致分成六类,定位器只占约 28%:
| 失败类型 | 典型表现 | 自愈在做什么 | 适合全自动吗 |
|---|---|---|---|
| 定位器 / 选择器(~28%) | 元素改名、结构微调 | 换一个等效定位器 | 最适合:对错客观、只改测试、当场能验 |
| 时序 | 元素加载慢、动画、懒加载 | 用自适应等待替掉固定 sleep | 谨慎,容易变成"无脑加 sleep 掩盖问题" |
| 运行时 / 环境 | 瞬断、依赖服务重启 | 隔离崩溃组件、短暂重试 | 不该改测试,该去治环境 |
| 测试数据 / 登录态 | token 过期、seed 缺失 | 刷新会话、重新造数 | 要有数据工厂和环境隔离,第二篇已讲 |
| 视觉断言 | 截图像素差异 | 过滤无关像素差 | 放宽阈值容易把真 UI 回归也放过 |
| 交互 / 流程变化 | 元素藏进菜单、tab、弹窗 | 自动补前置操作步骤 | 已接近改测试逻辑,风险高,应转人 |
"定位器自愈"这一类:它是唯一同时满足"对错客观、只动测试不碰产品、现场就能验证、即使点错也容易被业务断言抓出来"的。
两种工作模式:这是选型的第一道分水岭
看任何工具,先分清它是哪种,安全性天差地别:
- 运行时自愈(heal-and-continue):测试正跑着,定位器找不到了,工具当场换一个、继续往下跑,当次直接给你绿灯。代表:Healenium、Testim/Mabl 的默认模式、Cypress 的 cy.prompt。省事,但当次绿灯掺了水——你得回头看它到底换成了什么。
- 跑后提建议(suggest / PR 模式):测试该红还是红,跑完之后工具把"建议改成 X"整理成报告或一个等人审的 PR,你合了下次才生效。代表:Playwright 官方 healer、Healenium 的 locator-updater 插件、pw-doctor 的预览模式,以及本文自研方案。慢半拍,但每盏绿灯都是干净的、可审计的。
生产门禁里,强烈建议用第二种。 运行时模式不是不能用,但要打开它的"自愈留痕",并且绝不能让它把当次结果直接当通过。
为什么所有工具都要留个心眼:三组真实数据
自愈最大的风险不是"修不好",而是修错了还告诉你修好了。
- 2026 年一项研究把 AI 修复时跑过的"验证通过"命令,原样拿回没修复的坏代码上重跑,46% 在坏代码上照样通过——这些绿灯和 bug 修没修毫无关系;23.8% 的 AI 修复全程就靠这种无效绿灯宣布成功(arXiv:2607.28871)。
- 在代码修复评测 SWE-bench 上,补丁通过了 AI 自己写的测试,可拿到官方藏起来的"标准答案测试"上,21.8%(Claude 档)到 33.0%(GPT-4o 档)又挂了——只是糊弄过了眼前的测试,术语叫"过拟合"。
- UI 自愈特有的两种翻车:一是点错元素还报绿(新定位器匹配到一个长得像的错按钮);二是越修越偏(A→B→C→D 逐周替换,每次单看都合理,几周后测的东西和原意毫不相干)。商业工具 Testim 对外口径自愈成功率约 78%、误报率约 4.2%,Mabl 约 82%——都不是 100%,而且这些是厂商/第三方口径。
结论:工具负责"修","信不信这个绿"永远是你的责任。 第三章那三条规矩,就是用来管这件事的,换任何工具都适用。
二、主流工具选型地图(2026)
先按你的技术栈对号入座。
| 你的栈 | 首选(免费/官方) | 商业 / 其他 | 运行时还是跑后 | 备注 |
|---|---|---|---|---|
| Playwright(TS/JS) | 官方内置 healer agent | pw-doctor(社区 CLI)、Healenium Pro | 跑后给补丁/PR | 最省心,见 2.1 |
| Cypress | 官方 cy.prompt(需 Cypress Cloud) | cypress-healing(社区插件) | 意图驱动,下次运行自动适配 | 全程留痕,见 2.2 |
| Selenium / Appium(任意语言) | 开源 Healenium | Testim、Mabl 等 | 默认运行时,可配合插件写回 | Java/Python/JS/C# 都能用,见 2.3 |
| Playwright(Python) | 暂无官方自愈 | Healenium Pro 或本文自研 | —— | 见第四章 |
| 预算充足的团队 / 低代码 | —— | Testim、Mabl、Tricentis、Functionize、QA Wolf | 多为运行时+报告 | 开箱即用但收费、有平台锁定,见 2.4 |
先澄清一个常见误会:Selenium 4 官方并没有"自愈"功能。它的相对定位器(relative locator,above/near 等)只是一种更稳的"按相对位置找元素"写法,能减少脚本失效,但不会在失败后自动修复——Selenium 系的自愈靠 Healenium 这类第三方。
2.1 Playwright:直接用官方 healer(TS/JS 栈)
⚡ 一句话上手: 在已装 Playwright 的 Node 项目里执行
npx playwright init-agents --loop=claude(或vscode/opencode),然后在 Claude Code / Copilot 里直接说"跑测试,把失败的用例修好";它给出测试文件的改动补丁,你 review 后再合。
Playwright 从 2025 下半年起内置了三个 Test Agent:planner(规划)、generator(生成用例)、healer(自愈),免费,跟着框架走(官方文档)。测试红了,healer 会重放失败步骤、检查当前 UI、给出补丁(定位器更新、等待调整、数据修复),重跑到通过或被护栏停下。
上手(在你已用 Claude Code / GitHub Copilot / OpenCode 的前提下):
# 给对应的 AI 编程工具生成 agent 定义(底层是一组指令 + MCP 工具)
npx playwright init-agents --loop=claude # 或 vscode / opencode
之后让你的 AI 工具去跑 healer、修失败用例即可,产出的是可 review 的测试文件改动,不是偷偷改。
两个必须盯的点:
- 官方 agent 目前只覆盖 TypeScript/JavaScript 生态,Python 栈用不上(这也是本文要给 Python 自研方案的原因)。
- healer 有个默认行为要警惕:它若判断"功能本身坏了",会把这条用例标记为 skip(跳过)。绝不能让红灯被一跳了之——跳过必须通知人、当成待处理项。
社区还有个轻量选择 pw-doctor(npm),定位器脆弱度静态体检 + 修复,默认只预览、加 --apply 才写文件,CI 模式只吐 JSON、对发给 AI 的页面内容脱敏。适合不想引入整套 agent 的人。
2.2 Cypress:官方 cy.prompt,或社区插件
⚡ 一句话上手: 开通 Cypress Cloud,在脚本里把
cy.get('#旧选择器')换成cy.prompt('用一句话描述要点的元素');正常cypress run,元素改版时它下次运行自动重找,自愈过程显示在 Command Log。不想绑 Cloud 就装社区插件cypress-healing,填好模型 Key 直接用。
Cypress 官方的做法是 cy.prompt:你在用例里用自然语言描述步骤(而不是写死选择器),UI 改版导致选择器失效时,它下次运行会自动重新解析、并在 Command Log 里明确标出这一步发生了自愈(官方文档)。
// 不再写死 cy.get('#submit-btn'),而是描述意图
cy.prompt('点击提交订单按钮');
它把自愈分成两种并分别标出来:命中缓存的自愈(选择器变了但缓存里能对上,不调 AI)和调 AI 的自愈(缓存对不上,按你最初的意图重新找元素);读取页面时会自动排除密码框、信用卡框、隐藏域的值再发给模型。需要一个 Cypress Cloud 账号(各付费/免费套餐均提供,具体以官方为准)。
不想绑 Cloud,可以用社区插件 cypress-healing:先走启发式兜底(data 属性、ARIA 标签、role、文本),不行再调 OpenAI/Gemini/Copilot,带 HTML 自愈报告和手动映射表 manual-healing.json。
2.3 Selenium / Appium:开源 Healenium(Java/Python/JS/C# 通吃)
⚡ 一句话上手: 用官方 docker-compose 把"代理 + PostgreSQL"拉起来,再把测试里 WebDriver 连接的地址从原来的 Selenium Grid 改成指向 Healenium 代理(代码其他部分不动),照常跑用例;跑完打开 Healenium 报告页看每次自愈的新旧定位器,要把修复写回代码就装它的 IntelliJ 插件。
大量老自动化是 Selenium 栈,Healenium 是这里最主流的开源选择(Apache 系、免费,官网文档)。原理:先存一份成功时的定位器基线,运行中一旦抛 NoSuchElement,它用机器学习(LCS 路径比对 + 梯度提升加权)在当前页面找最匹配的元素、取最高分的那个继续操作,并生成带截图和详情的报告。
跨语言用 Healenium-Proxy 模式:起一个独立代理服务夹在 Selenium Server 和被测应用之间,测试代码只需把 driver 指向代理,Java/Python/JS/C# 都能用;纯 Java 项目也可用内嵌的 Healenium-Web(包一层 SelfHealingDriver)。它还支持 Appium,移动端同理。需要起一个 PostgreSQL 存定位器基线。
注意版本边界(官方对比):开源版覆盖 Selenium/Appium 的基础定位器自愈和 IntelliJ 的 locator-updater 插件(可把修复批量写回代码);Playwright 支持、接入任意大模型、一键生成 GitHub PR 是付费的 Healenium Pro 才有。它默认是运行时自愈,生产上建议把"写回代码"这一步走插件、过人工 review,而不是只享受当次绿灯。
2.4 商业平台:省心但算清账
⚡ 一句话上手: 官网注册账号、装它的浏览器插件,按引导录制/导入用例,之后在平台或连本地 CI 跑;元素一改它自动适配,去平台的 Healing / Insights(自愈记录) 页逐条确认改了什么。不需要自己写代码,开通即用,按用量或席位付费。
Testim(Tricentis 旗下)、Mabl、Functionize、Tricentis Tosca、QA Wolf 这一类,是录制+低代码+平台托管,自愈范围最广(定位器、等待、数据、视觉都管),开箱即用、有团队管理和趋势报告。Testim 用多属性加权的 Smart Locator + 视觉兜底、按置信度阈值决定自动修还是报错;Mabl 会把每一次自愈决策都记录下来供复查。
要权衡的也很明确:
- 贵:企业销售、按运行量/席位计费。公开报价口径差异较大,Mabl 入门档有资料称约 3 万~8 万/年;Testim 中型团队有资料称每月数千到上万美元、大型部署年费可更高。数字仅作量级参考,务必以官方报价为准。
- 锁定:用例资产存在平台的私有格式里,以后想迁走成本高。
- 黑盒:"它凭什么判断没点错"你看不透,更要靠第三章的独立验证去兜底。
适合预算充足、维护大量用例、工程人力紧张的中大型团队;个人和小团队一般从官方/开源方案起步更划算。
2.5 选型就问两个问题
- 它改完,是提一个 diff/PR 让我审,还是当场改绿、甚至直接 skip 掉红灯? 选前者。
- 它怎么证明自己没点错元素? 有没有独立于定位器之外的业务结果校验、每一次自愈是否留痕可查?答不上来的,再省心也别放进合并门禁。
三、用任何工具都要守住的三条规矩
工具会变、栈会换,这三条不过时。
规矩一:门禁里优先跑后建议模式,慎用运行时自愈。 运行时模式会让当次绿灯失去意义,还有性能成本——有测算称 200 条用例坏 20 个、一天跑 50 次,运行时自愈能多耗 33~83 分钟/天。如果工具只支持运行时模式,务必打开"每次自愈留痕",并把"当次结果"和"自愈后的结果"分开看,不许直接当通过。
规矩二:动作后面必须有独立的业务断言。 这是防"点错还报绿"的命门。定位器解决"点哪个元素",断言解决"点完业务对不对"——比如点了提交,就要断言出现"订单提交成功"文案、URL 跳到成功页。只要断言是冲着业务结果去的,工具哪怕点到一个长得像的错按钮,也会被这条断言拦下来。用任何工具,上线前都先检查:那些被自愈的用例,到底有没有真正的业务断言,还是只是"点完不报错就算过"。
规矩三:每一次自愈都要看得见、查得到,定期查漂移。 选会输出自愈清单/报告的工具(Cypress 的 Command Log、Healenium 报告、Mabl 的 healing 记录、PR 描述都行),重点盯两个信号:同一条用例短期内被反复自愈(A→B→C),说明定位策略本身有问题、该重写而不是继续贴膏药;整体自愈量突然暴增,往往是一次产品大改版,这时候应该让构建显眼地失败、提示"结构性变更",而不是默默把几十条用例全修绿。
最后做一次反向验收(比正向跑通更重要): 选好工具、配好之后,故意把页面上那个按钮删掉或改名成完全无关的东西,再跑。靠谱的工具应该是:找不到合理元素 → 失败/转人工 → 那条用例保持红。如果它居然自己另找了个相似按钮点下去、还报绿,这个工具的配置(或你的断言)就不合格,不能上生产。
四、现成方案不满足?再自己写(Python + pytest + Playwright 完整落地)
如果你是 Python + Playwright 栈(官方 healer 暂不覆盖)、要把自愈嵌进自己的 CI 流水线、或者要求每一步判断都在自己仓库里、可解释可定制。下面这套两个脚本(加起来约 240 行),做的就是第一章说的最安全的"跑后提建议(PR)"模式,只修定位器、只改你标记过的行、必须有业务断言、只提 PR 不自动合。技术栈 Python 3.11 + pytest + Playwright(Python)1.49+,模型用任意兼容 OpenAI 接口的服务。
整条链路:
pytest 跑红 失败钩子自动存证据 heal 脚本 提 PR
┌──────────┐ 失败 ┌──────────────────┐ ┌────────────────────────┐ ┌──────────┐
│ 用例红了 │ ───────▶ │ conftest 存下失败 │ ─▶│ ① 看报错关键词确认是定位器问题│ ─▶│ 自愈分支 │
│ report.xml│ │ 那一刻的 aria 快照│ │ ② 读意图 + 真实快照问模型│ │ 一个 PR │
└──────────┘ └──────────────────┘ │ ③ 候选逐个替换、重跑该用例│ │ 等人审 │
│ ④ 业务断言过 + 只改标记行 │ └──────────┘
└────────────────────────┘
过不了任何一关 → 打印"转人工",不硬凑
4.1 先看全貌:要动哪些文件
全部放在你现有 pytest 项目的根目录(平时敲 pytest 的目录),不用新建工程。共 5 处:新增 3 个文件(pytest.ini、conftest.py、scripts/heal_locator.py),改动 2 处(requirements.txt、你的用例文件)。
my-tests/ ← 项目根目录(你平时在这里敲 pytest,全程都在这里操作)
├── conftest.py # 【新增】放根目录。pytest 失败钩子,自动存"失败那一刻的页面快照"
├── pytest.ini # 【新增】固定报告输出路径,省得每次手敲参数
├── requirements.txt # 【追加三行依赖】
├── scripts/
│ └── heal_locator.py # 【新增】自愈主脚本(见 4.2)
└── tests/ # 你原有的用例目录,不动结构
└── order/
└── test_checkout.py # 只在需要自愈的定位器行加两个标记(见 4.3)
下面按「新增 → 改动 → 配置 → 运行」的顺序来。
4.2 新增 3 个文件
三个文件直接照抄到对应位置即可。
1)根目录 pytest.ini:固定报告输出路径
作用是把 JUnit 报告固定写到根目录的 report.xml,heal 脚本就读这个文件;其中 testpaths 改成你自己的用例目录:
[pytest]
testpaths = tests
addopts = --junitxml=report.xml
2)根目录 conftest.py:失败时自动存下页面快照
先说清全文反复出现的无障碍快照(aria snapshot)是什么:它是 Playwright 把当前页面"翻译"成的一份纯文本目录,按元素的角色和名字列出页面上有什么,不含颜色坐标样式。结账页的快照大致长这样:
- navigation
- link "首页"
- link "购物车"
- main
- heading "确认订单"
- textbox "收货地址"
- button "提交订单" ← 模型要找的就是这种"角色+名字"
- button "取消"
喂它而不是截图,是因为它精确、没歧义,模型照着写的定位器直接能用,也更省。定位器超时时页面其实已加载到目标页(它在"等元素出现"),所以失败那一刻的快照正好包含候选元素,用钩子存下来,后面就不用再折腾登录态去复现:
# conftest.py —— 用例失败时,存下当时页面的无障碍快照(aria snapshot)
from pathlib import Path
import pytest
HEAL_DIR = Path("test-results/heal")
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
page = item.funcargs.get("page") # 依赖你的浏览器 fixture 名叫 page
if page is None:
return
try:
# Playwright Python 1.59+ 有 page.aria_snapshot();
# 1.49~1.58 用 page.locator("body").aria_snapshot(),二者等价
snapshot = (
page.aria_snapshot()
if hasattr(page, "aria_snapshot")
else page.locator("body").aria_snapshot()
)
safe = item.nodeid.replace("/", "_").replace("::", "__") + ".aria.txt"
HEAL_DIR.mkdir(parents=True, exist_ok=True)
(HEAL_DIR / safe).write_text(snapshot, encoding="utf-8")
except Exception:
pass # 取证失败绝不能反过来影响测试结果本身
3)scripts/heal_locator.py:自愈主脚本
下面这个主脚本约 200 行、自包含,不用你补函数。它做的事用大白话讲:① 从报告读出失败用例和报错;② 看报错关键词判断是不是定位器问题,出现断言失败/500/连不上就直接转人工;③ 拿意图和快照让模型给候选定位器(必须来自真实快照、不许编);④ 开新分支逐个替换、重跑;⑤ 第一个同时满足"跑绿 + 业务断言过 + 只改了标记行"的候选才提交;⑥ 任何一关不过就还原换下一个,全不行就转人工。
# scripts/heal_locator.py
# 先正常跑测试(pytest.ini 会自动生成 report.xml)
# 再执行: python scripts/heal_locator.py (本地建分支,不自动建 PR)
# python scripts/heal_locator.py --pr (并用 gh 建 PR,需已登录 gh)
import argparse
import json
import os
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from openai import OpenAI
JUNIT = Path("report.xml")
SNAP_DIR = Path("test-results/heal")
HEAL_MARK = "@heal-target"
# 定位器失效的报错特征:对准 Playwright 真实文案。
# 别用裸 "timeout"——接口超时、fixture 超时也含这个词,放进来会白调一次模型(最终虽仍转人工)。
LOCATOR_HINTS = ("waiting for locator", "waiting for selector",
"resolved to", "strict mode violation")
# 这些失败明确不许自愈:产品断言失败 / 服务端出错 / 环境连不上
FORBIDDEN_HINTS = ("assertionerror", "expected to", "500", "502", "503",
"connection refused", "net::err")
TARGET_RE = re.compile(
r'^(?P<indent>[ \t]*)(?P<lhs>\w+)\s*=\s*(?P<rhs>page\..*?)[ \t]*'
r'#[ \t]*@heal-target[^\n]*$',
re.M,
)
def run(cmd, check=False):
return subprocess.run(cmd, text=True, capture_output=True, check=check)
def is_healable(error_text: str) -> bool:
"""入口分诊:像定位器问题、且不含断言/环境特征,才放行。宁可漏放给人。"""
low = error_text.lower()
if any(k in low for k in FORBIDDEN_HINTS):
return False
return any(k in low for k in LOCATOR_HINTS)
def parse_failures(junit_path: Path):
"""从 JUnit 取失败用例:(nodeid, 错误文本)。fixture 报错是 <error> 也要抓。"""
failures = []
root = ET.parse(junit_path).getroot()
for case in root.iter("testcase"):
nodeid = f"{case.get('classname', '').replace('.', '/')}.py::{case.get('name')}"
for tag in ("failure", "error"):
node = case.find(tag)
if node is not None:
failures.append((nodeid, (node.text or "")[:1200]))
break
return failures
def snap_path_for(nodeid: str) -> Path:
safe = nodeid.replace("/", "_").replace("::", "__") + ".aria.txt"
return SNAP_DIR / safe
def func_name(nodeid: str) -> str:
return nodeid.split("::")[-1]
def extract_func(source: str, name: str):
"""取出顶层测试函数的源码文本;找不到返回 None。"""
lines = source.splitlines(keepends=True)
start = next((i for i, l in enumerate(lines)
if re.match(rf"def\s+{re.escape(name)}\s*\(", l)), None)
if start is None:
return None
end = start + 1
while end < len(lines) and (not lines[end].strip() or lines[end][0] in " \t"):
end += 1
return "".join(lines[start:end])
def parse_intent(func_text: str):
m = re.search(r"#\s*intent\s*[::]\s*(.+)", func_text)
return m.group(1).strip() if m else None
def replace_target(func_text: str, new_locator: str):
"""只替换 @heal-target 那一行等号右边的定位器表达式。"""
m = TARGET_RE.search(func_text)
if not m:
return None
new_line = f"{m['indent']}{m['lhs']} = {new_locator} # {HEAL_MARK}\n"
return func_text[:m.start()] + new_line + func_text[m.end():]
def has_business_assertion(func_text: str) -> bool:
"""最低门槛:@heal-target 之后必须有 expect(...) 或 assert,否则没法判断点得对不对。"""
after = func_text.split(HEAL_MARK, 1)[1]
return "expect(" in after or bool(re.search(r"^\s*assert\b", after, re.M))
def ask_candidates(intent: str, snapshot: str):
client = OpenAI(base_url=os.environ["LLM_BASE_URL"],
api_key=os.environ["LLM_API_KEY"])
system = (
"你是 UI 测试定位器修复助手。只能在给出的页面无障碍快照中真实存在的元素里选择,"
"禁止编造页面上没有的元素。输出 JSON:"
'{"candidates":[{"locator":"...","confidence":0.0,"why":"..."}]}。'
"locator 必须是以 page. 开头、可直接接 .click() 的 Playwright Python 表达式,"
"优先 get_by_role/get_by_testid;不得包含 assert、expect 或分号。"
"若快照中没有匹配元素,返回 {\"candidates\":[]}。"
)
user = f"操作意图:{intent}\n\n失败时的页面无障碍快照:\n{snapshot}"
resp = client.chat.completions.create(
model=os.environ.get("LLM_MODEL", "model"),
messages=[{"role": "system", "content": system},
{"role": "user", "content": user}],
response_format={"type": "json_object"},
temperature=0,
)
data = json.loads(resp.choices[0].message.content)
cands = data.get("candidates", [])
cands = [c for c in cands if str(c.get("locator", "")).startswith("page.")]
return sorted(cands, key=lambda c: c.get("confidence", 0), reverse=True)
def diff_is_clean(test_file: Path) -> bool:
"""补丁只能动这一个测试文件,且不得增删任何断言行。"""
names = run(["git", "diff", "--name-only"]).stdout.split()
if names != [str(test_file)]:
return False
diff = run(["git", "diff"]).stdout
for line in diff.splitlines():
if line[:1] not in "+-":
continue
body = line[1:].strip()
if body.startswith(("expect(", "assert ")):
return False
return True
def heal_one(nodeid: str, error_text: str, make_pr: bool):
test_file = Path(nodeid.split("::", 1)[0])
if not test_file.exists():
return f"跳过 {nodeid}:找不到测试文件(JUnit 类名需对应文件路径)"
if not is_healable(error_text):
return f"跳过 {nodeid}:报错不像定位器失效(可能是真缺陷/环境问题),转人工"
snap = snap_path_for(nodeid)
if not snap.exists():
return f"跳过 {nodeid}:缺少失败页面快照 {snap},先正常跑一次让它红"
source = test_file.read_text(encoding="utf-8")
func = extract_func(source, func_name(nodeid))
if func is None:
return f"跳过 {nodeid}:源码里找不到该测试函数"
intent = parse_intent(func)
if not intent:
return f"跳过 {nodeid}:缺少 # intent 注释,模型无从知道要找什么"
if not TARGET_RE.search(func):
return f"跳过 {nodeid}:没有 # {HEAL_MARK} 标记行,不允许改这个用例"
if not has_business_assertion(func):
return f"跳过 {nodeid}:动作后没有业务断言,无法判断点得对不对,转人工补断言"
candidates = ask_candidates(intent, snap.read_text(encoding="utf-8"))
if not candidates:
return f"转人工 {nodeid}:模型在当前页面找不到匹配元素(可能元素真被删了)"
if run(["git", "diff", "--quiet"]).returncode != 0:
return "工作区有未提交改动,请先提交或 stash 后再跑,避免污染你的改动"
branch = "heal/" + re.sub(r"[^a-zA-Z0-9_.-]", "-", func_name(nodeid))[:50]
run(["git", "checkout", "-b", branch])
try:
for cand in candidates:
new_func = replace_target(func, cand["locator"])
test_file.write_text(source.replace(func, new_func), encoding="utf-8")
result = run(["pytest", "-q", nodeid])
if result.returncode == 0 and diff_is_clean(test_file):
run(["git", "add", str(test_file)])
run(["git", "commit", "-m",
f"chore(test): heal locator for {func_name(nodeid)}"])
if make_pr:
body = (f"## 🤖 定位器自愈建议(仅改测试定位器)\n"
f"- 用例:`{nodeid}`\n- 意图:{intent}\n"
f"- 新定位器:`{cand['locator']}`(置信 {cand.get('confidence')})\n"
f"- 理由:{cand.get('why','')}\n"
f"- 验证:重跑该用例通过;动作后业务断言通过;仅改动标记行。\n\n"
f"请确认新元素与意图一致后再合并。")
run(["gh", "pr", "create", "--fill", "--body", body])
return f"✅ {nodeid} → 分支 {branch}(候选:{cand['locator']}),去提/审 PR"
run(["git", "checkout", "--", str(test_file)]) # 这个候选不行,还原试下一个
return f"转人工 {nodeid}:{len(candidates)} 个候选重跑后都不满足(跑绿+断言+范围)"
finally:
run(["git", "checkout", "-"]) # 回到原分支(成功失败都回)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--pr", action="store_true", help="成功后用 gh 自动建 PR")
args = parser.parse_args()
if not JUNIT.exists():
sys.exit("没找到 report.xml,先在项目根目录执行一次:pytest")
for nodeid, err in parse_failures(JUNIT):
print(heal_one(nodeid, err, args.pr))
if __name__ == "__main__":
main()
几个设计点说明白,免得用的时候犯嘀咕:
- 为什么"重跑该用例"就等于在真实页面验证了候选? Playwright 是 strict 模式,定位器匹配到 0 个或多个元素会直接报错;候选能让用例跑过,说明它在真实页面上唯一命中、且业务断言成立,不用再单独写"校验元素存在"。
- 为什么不直接信模型给的定位器? 模型只负责"出主意",每个候选都要经过真实重跑 + 改动范围双重确认,"拍板"的是测试结果本身。
- 为什么不专门做"撤回补丁确认还会红"? 定位器场景里旧定位器本来就因找不到元素而红,撤了必然还红,再跑一遍基本是走形式还费时,默认省掉;要双保险可在成功后手动
git checkout - && pytest复核。真正拦住"点错还报绿"的是业务断言。
4.3 改动 2 处
1)requirements.txt:追加三个依赖
playwright>=1.49
pytest>=8
openai>=1.0
追加后执行下面命令,把依赖和浏览器一次装好:
pip install -r requirements.txt
playwright install chromium
2)你的用例:加两个约定
自愈脚本需要你在用例里用两行注释提供信息:
# tests/order/test_checkout.py
import re
from playwright.sync_api import Page, expect
def test_submit_order(page: Page):
# intent: 在结账页点击"提交订单"按钮,完成下单
submit_btn = page.locator("#submit-btn") # @heal-target
submit_btn.click()
# 动作之后必须有"业务结果"断言:这是防止它点错元素还报绿的命门
expect(page.get_by_text("订单提交成功")).to_be_visible()
expect(page).to_have_url(re.compile(r"/order/success"))
# intent: …:用人话写这一步的意图。模型拿意图去页面上找元素,而不是拿已经失效的旧#submit-btn瞎猜。# @heal-target:标在允许被自愈替换的定位器那一行行尾。没标的定位器脚本一概不碰——这就是"范围受限"的物理实现,比事后审 diff 更可靠。- 动作后必须有业务断言(成功文案、跳转 URL 等)。一条用例点完什么都不断言,脚本会直接判它"没资格自愈,转人工"。
4.4 配置环境:模型密钥和 git
配模型(三个环境变量)。 脚本靠 LLM_BASE_URL / LLM_API_KEY / LLM_MODEL 找到模型,用哪家都行(第三篇 2.2 有选型表)。以 DeepSeek 为例:
# Windows PowerShell(当前窗口临时生效,先跑通用这个)
$env:LLM_BASE_URL="https://api.deepseek.com"
$env:LLM_MODEL="deepseek-chat"
$env:LLM_API_KEY="sk-你的key"
# 想长期生效:setx LLM_API_KEY "sk-你的key"(重开终端后生效,别提交进 git)
# macOS / Linux
export LLM_BASE_URL="https://api.deepseek.com"
export LLM_MODEL="deepseek-chat"
export LLM_API_KEY="sk-你的key"
上 CI 时配在仓库的 Secrets / Variables 里,方法同第三篇,别写进代码。这种轻量分类用各家的"小模型快速档"就够,不用旗舰。
准备 git。 自愈脚本靠开新分支隔离改动,所以项目得是个 git 仓库(git init 过、当前工作区干净)。想让它用 --pr 自动发 PR,再装并登录 GitHub CLI:winget install GitHub.cli 后 gh auth login;不装也行,脚本会把分支建好,你手动发 PR。
4.5 跑通并验收
跑一遍,本地把闭环走通,别急着上 CI(全程在项目根目录):
# 1) 页面正常时先跑一遍,确认本来是绿的(pytest.ini 已固定生成 report.xml,不用再手敲参数)
pytest
# 2) 模拟前端改版:把页面那个按钮的 testid 改名,或临时把用例里的 #submit-btn 改成不存在的值
# 3) 再跑一次,确认它变红
pytest
红了以后,先别急着自愈,看一眼失败现场有没有存下来——这是脚本能工作的前提:
test-results/heal/tests_order_test_checkout.py__test_submit_order.aria.txt # 失败那一刻的页面快照(文件名由"用例文件__用例名"转义而来)
report.xml # 带报错信息的报告
有这两个文件,再执行:
# 4) 跑自愈:它会开 heal/xxx 分支、换定位器、重跑该用例
python scripts/heal_locator.py
看到 ✅ ... → 分支 heal/...(候选:...) 就说明它在新分支上把用例修绿了。这时自己核一下:
git diff heal/xxx # 必须只动了带 # @heal-target 的那一行,断言和产品代码原样
# 5) 确认无误,自动发 PR(需 gh 登录),或手动 push 后在网页发 PR
python scripts/heal_locator.py --pr
然后按 4.6 花 30 秒审一眼再合。
反向验收(务必做一次):故意把那个提交按钮从页面彻底删掉再跑 pytest && python scripts/heal_locator.py。正确表现是脚本打印"转人工"、用例保持红,绝不能自己另找个相似按钮点下去还报绿——这一关过了,才说明你的业务断言和入口分诊是有效的。
常见卡点:报 没找到 report.xml → 不在项目根目录跑的,回到根目录;快照目录是空的 → 检查 conftest.py 是否放根目录、Playwright 是否 ≥1.49、失败的是不是定位器异常;模型报 401/余额 → 查三个环境变量;脚本开分支失败 → git status 看有没有未提交改动。
4.6 它提的 PR,你 30 秒怎么审
PR 描述已备齐材料(用例、意图、新定位器、置信度、验证结果)。你只确认一件事:这个新定位器指向的元素,和 intent 描述的是不是同一个业务元素。 是就合;同名按钮多、置信度不高、拿不准,就关掉 PR 手动改。前两周建议每个自愈 PR 都人工过一遍,校准模型对你们页面的理解,稳定后再谈放权。
五、跑顺之后,第二步再做的事
这些都等最小闭环稳定几周后再说,第一天一个都别铺:
- 接进 CI 自动触发:复用第三篇模式,在主测试 job 后挂
if: failure()的非阻塞自愈 job(continue-on-error: true),权限给推分支和建 PR,挂了不影响门禁;只在主仓库内部分支触发,fork 来的 PR 不给密钥、不触发,防泄密和递归。 - 更自动化的档位要卡阈值:只有"唯一候选 + 高置信 + 只读无害流程 + 业务断言齐备"才放开;删除 / 支付 / 提交订单 / 改权限这类破坏性动作,置信度再高也只生成人工工单。
- 变异验证:定期故意把按钮改名/删掉/让提交接口失败,自愈后的测试必须跟着变红,否则说明它已和错误状态"和解"。Python 可用
mutmut,关键链路手写几个变异也行。 - 漂移与自愈率监控:每次自愈留日志(旧/新定位器、候选、置信度、结果),对"反复自愈"和"自愈量暴增"两条信号设告警。
写在最后
这一篇看似在讲怎么让 AI 修脚本,其实通篇在讲一件事:AI 说"修好了"不算数,你得有办法当场验证它是不是真的对。
红灯会撒谎(flaky、环境挂了、脚本坏了),绿灯同样会撒谎(点错元素、断言被放宽、补丁只是糊弄过了眼前的测试)。当 AI 把"修"的成本压到接近零,真正值钱的能力就变成了判断"这个绿信不信得过":对模型放手让它去试,对验收卡到最严——只放确定的定位器问题、必须有独立业务断言、改动可审计、最后人看一眼。
整个系列反复强调的底线是 a red test stays red(该红的测试就得一直红着,不许靠重试和跳过把它弄绿);它还有下半句:a green test must earn its green(每一盏绿灯,都得靠真本事挣来,不是凑出来的)。
至此执行段四篇串成闭环:人定好框架规矩、AI 在规矩里写脚本(一);CI 一片红时 AI 批量分诊(二);把分诊长到流水线上自动跑(三);脚本坏了,AI 提一个能被独立验证的修复、人来拍板(四)。