AI for Testing 提效实战 · 执行自动化(四):脚本坏了让 AI 自愈——它说修好了,你凭什么信

0 阅读21分钟

上一篇把 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 的预览模式,以及本文自研方案。慢半拍,但每盏绿灯都是干净的、可审计的。

生产门禁里,强烈建议用第二种。 运行时模式不是不能用,但要打开它的"自愈留痕",并且绝不能让它把当次结果直接当通过。

为什么所有工具都要留个心眼:三组真实数据

自愈最大的风险不是"修不好",而是修错了还告诉你修好了。

  1. 2026 年一项研究把 AI 修复时跑过的"验证通过"命令,原样拿回没修复的坏代码上重跑,46% 在坏代码上照样通过——这些绿灯和 bug 修没修毫无关系;23.8% 的 AI 修复全程就靠这种无效绿灯宣布成功(arXiv:2607.28871)。
  2. 在代码修复评测 SWE-bench 上,补丁通过了 AI 自己写的测试,可拿到官方藏起来的"标准答案测试"上,21.8%(Claude 档)到 33.0%(GPT-4o 档)又挂了——只是糊弄过了眼前的测试,术语叫"过拟合"。
  3. UI 自愈特有的两种翻车:一是点错元素还报绿(新定位器匹配到一个长得像的错按钮);二是越修越偏(A→B→C→D 逐周替换,每次单看都合理,几周后测的东西和原意毫不相干)。商业工具 Testim 对外口径自愈成功率约 78%、误报率约 4.2%,Mabl 约 82%——都不是 100%,而且这些是厂商/第三方口径。

结论:工具负责"修","信不信这个绿"永远是你的责任。 第三章那三条规矩,就是用来管这件事的,换任何工具都适用。

二、主流工具选型地图(2026)

先按你的技术栈对号入座。

你的栈首选(免费/官方)商业 / 其他运行时还是跑后备注
Playwright(TS/JS)官方内置 healer agentpw-doctor(社区 CLI)、Healenium Pro跑后给补丁/PR最省心,见 2.1
Cypress官方 cy.prompt(需 Cypress Cloud)cypress-healing(社区插件)意图驱动,下次运行自动适配全程留痕,见 2.2
Selenium / Appium(任意语言)开源 HealeniumTestim、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 入门档有资料称约 499/月起、企业级约499/月起、企业级约 3 万~8 万/年;Testim 中型团队有资料称每月数千到上万美元、大型部署年费可更高。数字仅作量级参考,务必以官方报价为准。
  • 锁定:用例资产存在平台的私有格式里,以后想迁走成本高。
  • 黑盒:"它凭什么判断没点错"你看不透,更要靠第三章的独立验证去兜底。

适合预算充足、维护大量用例、工程人力紧张的中大型团队;个人和小团队一般从官方/开源方案起步更划算。

2.5 选型就问两个问题

  1. 它改完,是提一个 diff/PR 让我审,还是当场改绿、甚至直接 skip 掉红灯? 选前者。
  2. 它怎么证明自己没点错元素? 有没有独立于定位器之外的业务结果校验、每一次自愈是否留痕可查?答不上来的,再省心也别放进合并门禁。

三、用任何工具都要守住的三条规矩

工具会变、栈会换,这三条不过时。

规矩一:门禁里优先跑后建议模式,慎用运行时自愈。 运行时模式会让当次绿灯失去意义,还有性能成本——有测算称 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 提一个能被独立验证的修复、人来拍板(四)。