前言

各位测试工程师朋友们,让我猜猜你的日常:

凌晨三点,你睡得正香,手机突然炸了。钉钉消息 99+,微信群里 @ 了你八百遍,邮件标题赫然写着——“线上回归测试全挂了!!!”

你一个鲤鱼打挺从床上跳起来,打开电脑,颤抖着点开测试报告,发现 200 个用例挂了 87 个。你心里一凉:“完了完了,是不是线上出 Bug 了?”

结果排查了半小时发现——

前端同事把「登录」按钮的 idlogin-btn 改成了 loginButton

就这?就这。

一个驼峰命名的改动,让你凌晨三点起来加班。你看着镜子里的黑眼圈,陷入了沉思:我写的到底是自动化测试,还是自动化找茬?

别慌,今天这篇文章就是来拯救你的。我们要给 Selenium 装上一个 AI 自愈引擎——当元素定位失败时,测试框架会自动尝试修复定位策略,而不是直接报错挂掉。听起来像科幻?其实用 Python + AI Vision 就能搞定。

让我们开始吧。


一、先搞清楚问题:为什么 Selenium 这么"脆"?

1.1 传统定位的"阿喀琉斯之踵"

Selenium 测试最脆弱的地方就是元素定位。我们常用的定位方式有这几种:

# ID 定位 - 最稳,但前端改个 id 就完蛋
driver.find_element(By.ID, "login-btn")

# CSS 定位 - 还行,但页面结构一改就跪
driver.find_element(By.CSS_SELECTOR, "div.main > form > button.submit")

# XPath 定位 - 最灵活也最脆,堪称"定时炸弹"
driver.find_element(By.XPATH, "/html/body/div[3]/div/form/div[2]/button")

# 文本定位 - 看起来稳,但换个文案就没了
driver.find_element(By.XPATH, "//button[text()='登录']")

每种定位方式都有自己的"死法":

定位方式 死法 痛苦指数
ID 前端改了 id 命名 ⭐⭐
CSS Selector 页面结构重构 ⭐⭐⭐
XPath 任何风吹草动 ⭐⭐⭐⭐⭐
文本匹配 改了按钮文案 ⭐⭐⭐
Class Name 改了 CSS 框架 ⭐⭐⭐⭐

1.2 一个残酷的事实

根据业界统计,UI 自动化测试中 60%-80% 的失败都不是因为 Bug,而是因为定位器失效

这意味着你每天花大量时间排查的"测试失败",大部分时候都是在跟定位器搏斗。你的测试框架没有在帮你找 Bug,它在帮你找 NoSuchElementException

这就好比你雇了个保安,结果他每天的工作是跟门锁较劲,而不是抓小偷。


二、方案一:多重定位策略——最简单的"自愈"

在上 AI 大招之前,我们先来一个简单实用的方案:给每个元素准备多套定位策略,失败了自动切换

2.1 设计思路

就像找工作不能只投一家公司一样,定位元素也不能只靠一个选择器。我们给每个元素维护一个"定位策略列表",按优先级依次尝试:

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from dataclasses import dataclass
from typing import Optional
import logging
import time

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("SelfHeal")


@dataclass
class Locator:
    """元素定位策略"""
    by: By
    value: str
    priority: int = 0  # 优先级,数字越小越优先
    description: str = ""

    def __repr__(self):
        return f"Locator({self.by}={self.value}, priority={self.priority})"


class SmartElement:
    """支持自愈的智能元素"""

    def __init__(self, name: str, locators: list[Locator]):
        self.name = name
        # 按优先级排序
        self.locators = sorted(locators, key=lambda x: x.priority)
        self._working_index = 0  # 当前有效的定位器索引
        self._heal_history: list[dict] = []

    def find(self, driver: WebDriver, timeout: int = 10) -> Optional[WebElement]:
        """查找元素,失败时自动尝试下一个定位策略"""
        from selenium.webdriver.support.ui import WebDriverWait
        from selenium.webdriver.support import expected_conditions as EC

        # 优先尝试上次成功的定位器
        for attempt, index in enumerate(self._rotate_from_working()):
            locator = self.locators[index]
            try:
                element = WebDriverWait(driver, timeout).until(
                    EC.presence_of_element_located((locator.by, locator.value))
                )
                if index != self._working_index:
                    # 定位策略切换了,记录自愈事件
                    old_locator = self.locators[self._working_index]
                    self._heal_history.append({
                        "time": time.strftime("%Y-%m-%d %H:%M:%S"),
                        "from": str(old_locator),
                        "to": str(locator),
                        "element": self.name
                    })
                    logger.warning(
                        f"🔧 元素 [{self.name}] 自愈成功!"
                        f"从 {old_locator} 切换到 {locator}"
                    )
                    self._working_index = index
                return element
            except Exception:
                logger.debug(f"定位策略 {locator} 失败,尝试下一个...")
                continue

        logger.error(f"❌ 元素 [{self.name}] 所有定位策略都失败了!")
        return None

    def _rotate_from_working(self):
        """从当前工作索引开始轮转"""
        n = len(self.locators)
        for i in range(n):
            yield (self._working_index + i) % n

    def get_heal_report(self) -> list[dict]:
        """获取自愈报告"""
        return self._heal_history

2.2 实战用法

from selenium import webdriver
from selenium.webdriver.common.by import By

# 定义登录按钮的多重定位策略
login_button = SmartElement("登录按钮", [
    Locator(By.ID, "login-btn", priority=0, description="首选ID定位"),
    Locator(By.CSS_SELECTOR, "button[type='submit']", priority=1, description="CSS提交按钮"),
    Locator(By.XPATH, "//button[contains(text(),'登录')]", priority=2, description="文本匹配"),
    Locator(By.CSS_SELECTOR, ".login-form button", priority=3, description="表单内按钮"),
    Locator(By.XPATH, "//button[contains(@class,'btn') and ancestor::form]", priority=4, description="兜底方案"),
])

# 使用
driver = webdriver.Chrome()
driver.get("https://example.com/login")

# 前端改了 id?没关系,自动切换到 CSS 选择器
btn = login_button.find(driver)
if btn:
    btn.click()
    print("登录成功!")

# 看看自愈报告
for heal in login_button.get_heal_report():
    print(f"  🔧 {heal['time']} - {heal['element']}: {heal['from']}{heal['to']}")

这个方案简单粗暴但有效,能解决 70% 的定位失效问题。但它的局限也很明显——你得提前猜到前端会怎么改。如果页面大改版,预设的策略可能全军覆没。

是时候上 AI 了。


三、方案二:AI Vision 自愈——让 GPT-4V 当你的"眼睛"

3.1 核心思路

传统的自愈方案依赖 DOM 结构分析,但 AI Vision 方案的思路完全不同:截图 → 发给多模态大模型 → 让 AI 告诉你元素在哪

这就好比你闭着眼摸按钮(XPath),和睁着眼看按钮(AI Vision)的区别。

3.2 技术栈

  • Selenium:浏览器自动化
  • OpenAI GPT-4V / Claude Vision:多模态大模型,能"看"网页截图
  • Python:胶水语言,无所不能

3.3 完整实现

import base64
import json
import re
from io import BytesIO
from dataclasses import dataclass
from typing import Optional
from openai import OpenAI
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from PIL import Image


@dataclass
class ElementLocation:
    """AI 定位结果"""
    x: int  # 中心点 X 坐标
    y: int  # 中心点 Y 坐标
    confidence: float  # 置信度 0-1
    description: str  # AI 的描述
    locator_hint: str  # AI 建议的定位策略


class AIVisionHealer:
    """基于 AI 视觉的元素自愈引擎"""

    def __init__(self, api_key: str, model: str = "gpt-4o"):
        self.client = OpenAI(api_key=api_key)
        self.model = model
        self.heal_count = 0

    def take_screenshot(self, driver: webdriver.Chrome) -> str:
        """截取当前页面并转为 base64"""
        screenshot = driver.get_screenshot_as_png()
        img = Image.open(BytesIO(screenshot))
        # 缩小图片以减少 token 消耗
        img.thumbnail((1280, 720))
        buffer = BytesIO()
        img.save(buffer, format="PNG")
        return base64.b64encode(buffer.getvalue()).decode("utf-8")

    def locate_element(
        self,
        driver: webdriver.Chrome,
        element_description: str,
        page_context: str = ""
    ) -> Optional[ElementLocation]:
        """
        用 AI Vision 定位页面元素

        Args:
            driver: WebDriver 实例
            element_description: 要找的元素描述,如"登录按钮"
            page_context: 页面上下文信息
        """
        screenshot_b64 = self.take_screenshot(driver)
        window_size = driver.get_window_size()

        prompt = f"""你是一个 Web UI 自动化测试专家。请分析这张网页截图,找到以下元素的位置:

**要找的元素:** {element_description}
**页面上下文:** {page_context or '未知'}
**页面尺寸:** {window_size['width']}x{window_size['height']}

请返回一个 JSON 对象,格式如下:
{{
    "found": true/false,
    "x": 元素中心点的X坐标(像素),
    "y": 元素中心点的Y坐标(像素),
    "confidence": 置信度(0-1之间的小数),
    "description": "你看到的元素描述",
    "css_hint": "建议的CSS选择器",
    "reasoning": "你的推理过程"
}}

注意:
1. 坐标要精确到像素
2. 如果找不到元素,found 设为 false
3. confidence 表示你对结果的确信程度
4. css_hint 是你根据页面结构推测的 CSS 选择器
5. 只返回 JSON,不要其他内容"""

        try:
            response = self.client.chat.completions.create(
                model=self.model,
                messages=[
                    {
                        "role": "user",
                        "content": [
                            {"type": "text", "text": prompt},
                            {
                                "type": "image_url",
                                "image_url": {
                                    "url": f"data:image/png;base64,{screenshot_b64}",
                                    "detail": "high"
                                }
                            }
                        ]
                    }
                ],
                max_tokens=500,
                temperature=0.1  # 低温度,结果更稳定
            )

            result_text = response.choices[0].message.content.strip()
            # 提取 JSON(处理可能的 markdown 代码块)
            json_match = re.search(r'\{[^{}]*\}', result_text, re.DOTALL)
            if not json_match:
                return None

            result = json.loads(json_match.group())

            if not result.get("found"):
                logger.info(f"🔍 AI 未找到元素: {element_description}")
                return None

            self.heal_count += 1
            logger.info(
                f"🤖 AI Vision 定位成功: {element_description} "
                f"→ ({result['x']}, {result['y']}) "
                f"置信度: {result['confidence']}"
            )

            return ElementLocation(
                x=result["x"],
                y=result["y"],
                confidence=result["confidence"],
                description=result.get("description", ""),
                locator_hint=result.get("css_hint", "")
            )

        except Exception as e:
            logger.error(f"AI Vision 定位失败: {e}")
            return None

    def click_element(
        self,
        driver: webdriver.Chrome,
        element_description: str,
        page_context: str = ""
    ) -> bool:
        """通过 AI Vision 找到元素并点击"""
        location = self.locate_element(driver, element_description, page_context)

        if location is None:
            return False

        if location.confidence < 0.6:
            logger.warning(
                f"⚠️ AI 置信度过低 ({location.confidence}),跳过点击"
            )
            return False

        # 使用 ActionChains 点击指定坐标
        actions = ActionChains(driver)
        actions.move_by_offset(
            location.x - driver.get_window_size()["width"] // 2,
            location.y - driver.get_window_size()["height"] // 2
        ).click().perform()

        logger.info(f"✅ 已点击: {element_description}")
        return True

    def get_stats(self) -> dict:
        """获取自愈统计"""
        return {
            "total_heals": self.heal_count,
            "model": self.model
        }

3.4 实战演示

import os
from selenium import webdriver

# 初始化
driver = webdriver.Chrome()
driver.get("https://example.com/login")

healer = AIVisionHealer(api_key=os.getenv("OPENAI_API_KEY"))

# 正常方式找不到元素时,用 AI Vision 自愈
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

try:
    # 先尝试传统方式
    btn = WebDriverWait(driver, 5).until(
        EC.element_to_be_clickable((By.ID, "login-btn"))
    )
    btn.click()
except Exception:
    # 传统方式失败,启动 AI 自愈
    print("🔄 传统定位失败,启动 AI Vision 自愈...")
    success = healer.click_element(
        driver,
        element_description="登录按钮",
        page_context="这是一个登录页面的登录按钮"
    )
    if success:
        print("🎉 AI 自愈成功!")
    else:
        print("😢 AI 也找不到了,可能真的出 Bug 了")

# 查看统计
print(f"自愈统计: {healer.get_stats()}")

3.5 进阶:结合坐标和 DOM 双重验证

AI Vision 的坐标定位虽然强大,但有时候会有偏差(特别是小按钮)。一个更稳妥的做法是 先用 AI 识别目标区域,再在该区域内用 DOM 查找

def smart_locate_with_fallback(
    self,
    driver: webdriver.Chrome,
    element_description: str,
    fallback_locators: list[Locator]
) -> Optional[WebElement]:
    """
    混合定位策略:
    1. 先尝试预设的定位器
    2. 都失败了再用 AI Vision
    3. AI 定位后尝试匹配最近的 DOM 元素
    """
    # 第一步:传统定位器
    for locator in fallback_locators:
        try:
            element = driver.find_element(locator.by, locator.value)
            if element.is_displayed():
                return element
        except Exception:
            continue

    # 第二步:AI Vision 定位
    location = self.locate_element(driver, element_description)
    if location is None:
        return None

    # 第三步:用坐标反查 DOM 元素
    # Selenium 没有直接的 "元素在坐标上" 方法,但可以用 JS
    js_code = """
    var elem = document.elementFromPoint(arguments[0], arguments[1]);
    if (elem) {
        return {
            tagName: elem.tagName,
            id: elem.id,
            className: elem.className,
            text: elem.textContent.substring(0, 50),
            xpath: getXPath(elem)
        };
    }
    return null;

    function getXPath(element) {
        if (element.id !== '')
            return '//*[@id="' + element.id + '"]';
        if (element === document.body)
            return '/html/body';
        var ix = 0;
        var siblings = element.parentNode.childNodes;
        for (var i = 0; i < siblings.length; i++) {
            var sibling = siblings[i];
            if (sibling === element)
                return getXPath(element.parentNode) + '/' + element.tagName + '[' + (ix + 1) + ']';
            if (sibling.nodeType === 1 && sibling.tagName === element.tagName)
                ix++;
        }
    }
    """

    dom_info = driver.execute_script(js_code, location.x, location.y)
    if dom_info:
        logger.info(f"🔍 AI 坐标反查到 DOM: {dom_info}")
        # 尝试用反查到的属性重新定位
        if dom_info.get("id"):
            return driver.find_element(By.ID, dom_info["id"])
        if dom_info.get("xpath"):
            return driver.find_element(By.XPATH, dom_info["xpath"])

    return None

四、方案三:MCP 协议自愈 Agent——终极形态

4.1 为什么要用 MCP?

前面两个方案都还是"单机版"的。如果你想要一个更强大的自愈系统,可以把 AI 自愈能力封装成 MCP(Model Context Protocol)服务,让任何 AI Agent 都能调用。

MCP 协议简单理解就是 AI Agent 的"万能接口"——就像 USB 之于电脑,MCP 之于 AI Agent。关于 MCP 的基础知识,可以看我之前的文章《MCP 协议入门:给你的 AI 装上手脚,从此告别嘴炮》。

4.2 MCP 自愈服务实现

# mcp_healer_server.py
import asyncio
import json
import base64
from io import BytesIO
from mcp.server import Server
from mcp.types import Tool, TextContent
from selenium import webdriver
from PIL import Image

# 创建 MCP 服务
server = Server("selenium-healer")


class BrowserSession:
    """浏览器会话管理"""
    def __init__(self):
        self.driver = None
        self._initialized = False

    def ensure_init(self):
        if not self._initialized:
            options = webdriver.ChromeOptions()
            options.add_argument("--headless")
            self.driver = webdriver.Chrome(options=options)
            self._initialized = True

    def get_screenshot_b64(self) -> str:
        self.ensure_init()
        screenshot = self.driver.get_screenshot_as_png()
        img = Image.open(BytesIO(screenshot))
        img.thumbnail((1280, 720))
        buffer = BytesIO()
        img.save(buffer, format="PNG")
        return base64.b64encode(buffer.getvalue()).decode("utf-8")

    def get_page_info(self) -> dict:
        self.ensure_init()
        return {
            "url": self.driver.current_url,
            "title": self.driver.title,
            "window_size": self.driver.get_window_size()
        }


browser = BrowserSession()


@server.tool()
async def navigate(url: str) -> list[TextContent]:
    """导航到指定 URL"""
    browser.ensure_init()
    browser.driver.get(url)
    return [TextContent(
        type="text",
        text=json.dumps({
            "status": "ok",
            "url": browser.driver.current_url,
            "title": browser.driver.title
        })
    )]


@server.tool()
async def get_page_screenshot() -> list[TextContent]:
    """获取当前页面截图(base64)"""
    browser.ensure_init()
    info = browser.get_page_info()
    screenshot_b64 = browser.get_screenshot_b64()
    return [TextContent(
        type="text",
        text=json.dumps({
            "page_info": info,
            "screenshot": screenshot_b64[:100] + "...(truncated)",
            "screenshot_full": screenshot_b64
        })
    )]


@server.tool()
async def smart_click(
    element_description: str,
    page_context: str = ""
) -> list[TextContent]:
    """
    智能点击元素 - 先尝试传统定位,失败则用 AI Vision

    Args:
        element_description: 要点击的元素描述,如"登录按钮"
        page_context: 页面上下文,如"这是一个登录页面"
    """
    browser.ensure_init()

    # 先截图发给 AI 分析
    screenshot_b64 = browser.get_screenshot_b64()
    page_info = browser.get_page_info()

    # 这里可以调用 AI Vision API 来分析截图
    # 简化版:返回截图信息供 Agent 分析
    return [TextContent(
        type="text",
        text=json.dumps({
            "action": "smart_click",
            "target": element_description,
            "context": page_context,
            "page_info": page_info,
            "screenshot_available": True,
            "message": f"请分析截图,找到'{element_description}'的坐标位置"
        })
    )]


@server.tool()
async def click_at(x: int, y: int) -> list[TextContent]:
    """
    点击指定坐标位置

    Args:
        x: X 坐标
        y: Y 坐标
    """
    browser.ensure_init()
    from selenium.webdriver.common.action_chains import ActionChains

    actions = ActionChains(browser.driver)
    actions.move_by_offset(
        x - browser.driver.get_window_size()["width"] // 2,
        y - browser.driver.get_window_size()["height"] // 2
    ).click().perform()

    return [TextContent(
        type="text",
        text=json.dumps({"status": "clicked", "x": x, "y": y})
    )]


@server.tool()
async def try_locators(
    locators: list[dict],
    action: str = "click"
) -> list[TextContent]:
    """
    尝试多个定位策略

    Args:
        locators: 定位策略列表,如 [{"by": "id", "value": "login-btn"}, ...]
        action: 执行的动作,click 或 get_text
    """
    browser.ensure_init()
    from selenium.webdriver.common.by import By

    BY_MAP = {
        "id": By.ID,
        "css": By.CSS_SELECTOR,
        "xpath": By.XPATH,
        "name": By.NAME,
        "class": By.CLASS_NAME,
        "tag": By.TAG_NAME,
        "link_text": By.LINK_TEXT,
        "partial_link_text": By.PARTIAL_LINK_TEXT,
    }

    results = []
    for loc in locators:
        by_type = BY_MAP.get(loc["by"].lower())
        if not by_type:
            results.append({"locator": loc, "status": "error", "reason": "不支持的定位方式"})
            continue

        try:
            element = browser.driver.find_element(by_type, loc["value"])
            if action == "click":
                element.click()
                results.append({"locator": loc, "status": "success", "action": "clicked"})
                return [TextContent(type="text", text=json.dumps({
                    "success": True, "used_locator": loc, "results": results
                }))]
            elif action == "get_text":
                text = element.text
                results.append({"locator": loc, "status": "success", "text": text})
                return [TextContent(type="text", text=json.dumps({
                    "success": True, "used_locator": loc, "text": text, "results": results
                }))]
        except Exception as e:
            results.append({"locator": loc, "status": "failed", "error": str(e)})

    return [TextContent(type="text", text=json.dumps({
        "success": False, "results": results
    })])


if __name__ == "__main__":
    import sys
    from mcp.server.stdio import stdio_server

    async def main():
        async with stdio_server() as (read_stream, write_stream):
            await server.run(read_stream, write_stream)

    asyncio.run(main())

4.3 MCP 配置

在你的 MCP 客户端配置文件中添加:

{
    "mcpServers": {
        "selenium-healer": {
            "command": "python",
            "args": ["/path/to/mcp_healer_server.py"],
            "env": {
                "OPENAI_API_KEY": "your-api-key"
            }
        }
    }
}

4.4 AI Agent 如何使用

有了 MCP 服务,AI Agent 就可以这样使用:

Agent: 我来执行登录测试。

1. 调用 navigate("https://example.com/login") → 打开登录页
2. 调用 try_locators([{"by":"id","value":"username"}, {"by":"css","value":"input[name='user']"}]) → 输入用户名
3. 调用 smart_click("登录按钮") → 看截图,找到按钮位置
4. 调用 click_at(640, 450) → 点击坐标
5. 验证登录成功

整个过程 Agent 可以自主决策:传统方式能搞定就用传统方式,搞不定就看截图,AI 来判断点哪里。


五、自愈测试框架完整集成

把上面的方案整合到一起,形成一个完整的自愈测试框架:

# self_healing_framework.py
import os
import json
import logging
import functools
from datetime import datetime
from typing import Callable, Any
from dataclasses import dataclass, field
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("SelfHealFramework")


@dataclass
class HealEvent:
    """自愈事件记录"""
    timestamp: str
    test_name: str
    element_name: str
    original_locator: str
    healed_locator: str
    method: str  # "fallback" | "ai_vision" | "ai_dom"
    success: bool


class SelfHealingFramework:
    """自愈测试框架"""

    def __init__(self, driver: webdriver.Chrome, config: dict = None):
        self.driver = driver
        self.config = config or {}
        self.heal_events: list[HealEvent] = []
        self.stats = {
            "total_tests": 0,
            "passed": 0,
            "failed": 0,
            "healed": 0,
            "heal_success": 0
        }

        # 初始化 AI Vision(可选)
        self.ai_healer = None
        api_key = self.config.get("openai_api_key") or os.getenv("OPENAI_API_KEY")
        if api_key:
            self.ai_healer = AIVisionHealer(api_key)
            logger.info("🤖 AI Vision 自愈引擎已启用")
        else:
            logger.info("⚠️ 未配置 API Key,AI Vision 自愈不可用")

    def smart_find(
        self,
        element_name: str,
        locators: list[Locator],
        timeout: int = 10,
        use_ai: bool = True
    ):
        """
        智能查找元素,支持自愈

        Args:
            element_name: 元素名称(用于日志)
            locators: 定位策略列表
            timeout: 超时时间
            use_ai: 是否启用 AI 自愈
        """
        # 第一步:多重定位策略
        smart_element = SmartElement(element_name, locators)
        element = smart_element.find(self.driver, timeout)

        if element:
            # 记录自愈事件
            for heal in smart_element.get_heal_report():
                self.heal_events.append(HealEvent(
                    timestamp=heal["time"],
                    test_name=self._current_test,
                    element_name=element_name,
                    original_locator=str(heal["from"]),
                    healed_locator=str(heal["to"]),
                    method="fallback",
                    success=True
                ))
                self.stats["healed"] += 1
                self.stats["heal_success"] += 1
            return element

        # 第二步:AI Vision 自愈
        if use_ai and self.ai_healer:
            logger.info(f"🔄 传统定位全部失败,启动 AI Vision 自愈: {element_name}")
            self.stats["healed"] += 1

            location = self.ai_healer.locate_element(
                self.driver,
                element_name,
                page_context=f"当前页面: {self.driver.title}"
            )

            if location and location.confidence >= 0.7:
                # 用坐标反查 DOM
                from selenium.webdriver.common.action_chains import ActionChains
                actions = ActionChains(self.driver)
                actions.move_by_offset(
                    location.x - self.driver.get_window_size()["width"] // 2,
                    location.y - self.driver.get_window_size()["height"] // 2
                ).perform()

                # 尝试获取当前位置的元素
                js = "return document.elementFromPoint(arguments[0], arguments[1]);"
                element = self.driver.execute_script(js, location.x, location.y)

                if element:
                    self.stats["heal_success"] += 1
                    self.heal_events.append(HealEvent(
                        timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
                        test_name=self._current_test,
                        element_name=element_name,
                        original_locator=str(locators[0]),
                        healed_locator=f"AI Vision ({location.x}, {location.y})",
                        method="ai_vision",
                        success=True
                    ))
                    return element

        logger.error(f"❌ 元素查找失败: {element_name}")
        return None

    def run_test(self, test_name: str, test_func: Callable) -> bool:
        """运行测试,自动记录状态"""
        self._current_test = test_name
        self.stats["total_tests"] += 1

        logger.info(f"\n{'='*50}")
        logger.info(f"🧪 运行测试: {test_name}")
        logger.info(f"{'='*50}")

        try:
            test_func()
            self.stats["passed"] += 1
            logger.info(f"✅ 测试通过: {test_name}")
            return True
        except Exception as e:
            self.stats["failed"] += 1
            logger.error(f"❌ 测试失败: {test_name} - {e}")
            return False

    def generate_report(self) -> str:
        """生成自愈报告"""
        report = {
            "summary": self.stats,
            "heal_rate": (
                f"{self.stats['heal_success']}/{self.stats['healed']}"
                if self.stats['healed'] > 0 else "N/A"
            ),
            "events": [
                {
                    "timestamp": e.timestamp,
                    "test": e.test_name,
                    "element": e.element_name,
                    "from": e.original_locator,
                    "to": e.healed_locator,
                    "method": e.method
                }
                for e in self.heal_events
            ]
        }
        return json.dumps(report, indent=2, ensure_ascii=False)

    @property
    def _current_test(self) -> str:
        return getattr(self, "_test_name", "unknown")

    @_current_test.setter
    def _current_test(self, name: str):
        self._test_name = name


# ========== 使用示例 ==========

def test_login(framework: SelfHealingFramework):
    """登录测试用例"""
    driver = framework.driver
    driver.get("https://example.com/login")

    # 输入用户名 - 用了多种定位策略
    username = framework.smart_find("用户名输入框", [
        Locator(By.ID, "username", priority=0),
        Locator(By.CSS_SELECTOR, "input[name='username']", priority=1),
        Locator(By.CSS_SELECTOR, "input[type='text']", priority=2),
        Locator(By.XPATH, "//input[@placeholder='用户名' or @placeholder='Username']", priority=3),
    ])
    if username:
        username.send_keys("testuser")

    # 输入密码
    password = framework.smart_find("密码输入框", [
        Locator(By.ID, "password", priority=0),
        Locator(By.CSS_SELECTOR, "input[type='password']", priority=1),
        Locator(By.NAME, "password", priority=2),
    ])
    if password:
        password.send_keys("testpass123")

    # 点击登录按钮
    login_btn = framework.smart_find("登录按钮", [
        Locator(By.ID, "login-btn", priority=0),
        Locator(By.CSS_SELECTOR, "button[type='submit']", priority=1),
        Locator(By.XPATH, "//button[contains(text(),'登录')]", priority=2),
        Locator(By.CSS_SELECTOR, ".login-form button", priority=3),
    ])
    if login_btn:
        login_btn.click()

    # 验证登录成功
    welcome = framework.smart_find("欢迎信息", [
        Locator(By.CSS_SELECTOR, ".welcome-msg", priority=0),
        Locator(By.XPATH, "//*[contains(text(),'欢迎')]", priority=1),
    ])
    assert welcome is not None, "登录失败:未找到欢迎信息"


if __name__ == "__main__":
    driver = webdriver.Chrome()

    framework = SelfHealingFramework(driver, config={
        "openai_api_key": os.getenv("OPENAI_API_KEY")
    })

    # 运行测试
    framework.run_test("登录功能测试", lambda: test_login(framework))

    # 生成报告
    print("\n📊 自愈报告:")
    print(framework.generate_report())

    driver.quit()

六、踩坑指南:别踩这些坑

6.1 AI Vision 的延迟问题

AI Vision 需要截图 + 调用 API,一次调用大概 2-5 秒。如果每个元素都用 AI 定位,一套测试跑下来你可能等到天荒地老。

解决方案:AI 是兜底,不是首选

# ❌ 错误做法:每个元素都用 AI
element = ai_healer.find("登录按钮")  # 2秒

# ✅ 正确做法:传统方式优先,失败了再用 AI
try:
    element = driver.find_element(By.ID, "login-btn")  # 0.01秒
except:
    element = ai_healer.find("登录按钮")  # 2秒,但只在失败时触发

6.2 坐标漂移问题

AI 返回的坐标可能因为页面滚动、缩放、响应式布局等原因不准确。

解决方案:相对坐标 + 验证

def safe_click_at(driver, x, y):
    """安全的坐标点击,带验证"""
    # 先滚动到目标区域
    driver.execute_script(f"window.scrollTo(0, {y - 300})")

    # 重新获取滚动偏移
    scroll_y = driver.execute_script("return window.scrollY")

    # 获取元素在视口中的位置
    js = """
    var elem = document.elementFromPoint(arguments[0], arguments[1] - arguments[2]);
    if (elem) {
        elem.scrollIntoView({behavior: 'smooth', block: 'center'});
        return {found: true, tag: elem.tagName, text: elem.textContent.substring(0, 30)};
    }
    return {found: false};
    """
    result = driver.execute_script(js, x, y, scroll_y)

    if result["found"]:
        actions = ActionChains(driver)
        actions.move_by_offset(
            x - driver.get_window_size()["width"] // 2,
            y - scroll_y - driver.get_window_size()["height"] // 2
        ).click().perform()
        return True
    return False

6.3 成本控制

GPT-4V 的调用不便宜,一张高清截图大概消耗 1000-2000 tokens。如果频繁调用,月底账单会让你怀疑人生。

解决方案:

# 1. 截图压缩
img.thumbnail((960, 540))  # 缩小分辨率

# 2. 只在必要时调用
if not element_found_by_traditional_methods:
    ai_locate()  # 只在传统方式失败时才调 AI

# 3. 缓存 AI 结果
locator_cache = {}
if element_name in locator_cache:
    return cached_result

6.4 多语言页面的问题

如果你的页面有中英文混排,AI 的文本匹配可能会混乱。

解决方案:给 AI 更多上下文

prompt = f"""
页面语言:中文
要找的元素:{element_description}
提示:这个按钮的中文文本可能是"登录"、"登入"、"Sign In"等
"""

七、方案对比:该选哪个?

方案 实现难度 修复能力 延迟 成本 适用场景
多重定位策略 ⭐ 简单 70% 免费 小改动、元素属性微调
AI Vision ⭐⭐⭐ 中等 90% 2-5秒 $$ 大改版、新元素、复杂页面
MCP Agent ⭐⭐⭐⭐ 较难 95% 3-8秒 $$$ 智能化测试、无人值守

我的建议:

  1. 先上方案一(多重定位策略),投入产出比最高
  2. 关键场景加方案二(AI Vision),比如登录、支付等核心流程
  3. 方案三(MCP)留给高阶玩家,适合有 AI Agent 平台的团队

组合使用效果最佳:

# 三层防御体系
element = (
    try_traditional_locators()    # 第一层:传统定位(毫秒级)
    or try_ai_vision()            # 第二层:AI 视觉(秒级)
    or request_human_help()       # 第三层:求助人类(最可靠)
)

八、未来展望:测试的终极形态

说实话,AI 自愈只是权宜之计。真正的未来是 AI 原生测试——AI 直接理解业务需求,自动生成测试用例,自动执行,自动验证。

想象一下:

你: “帮我测试一下登录功能。”

AI: “好的,我已经生成了 47 个测试用例,覆盖了正常登录、密码错误、账号锁定、验证码过期、网络超时等场景。正在执行中… 45 个通过,2 个发现潜在 Bug。详细报告已发送到你的邮箱。”

这不是科幻,Google I/O 2026 提到的"全栈智能体"就在朝这个方向走。但在这之前,自愈测试框架是当下最实用的过渡方案


总结

今天我们从一个"凌晨三点被电话叫醒"的故事出发,讲了三种 Selenium 测试自愈方案:

  1. 多重定位策略:简单粗暴,给元素多准备几套"身份证",一个失效了换另一个
  2. AI Vision 自愈:让 AI 帮你"看"页面,找到你要点的按钮,不依赖 DOM 结构
  3. MCP 自愈 Agent:把自愈能力封装成服务,让 AI Agent 自主决策

核心思想就一句话:测试要像人一样灵活,而不是像机器一样死板

当你的测试能自己修复定位问题时,你就可以安心睡觉了。至少不用因为前端改了个 id 就凌晨三点爬起来。

当然,如果测试真的挂了——那可能是真 Bug,该起来还是得起来 😂


如果觉得有用,点个赞 👍 收藏 ⭐ 关注 🔔 三连走起!

有问题欢迎评论区讨论,我会尽量回复(除非我又在凌晨三点被电话叫醒了)。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐