目录

Finetest: 一个 Web 自动化测试框架的技术实现

项目简介

Finetest 是一个基于 Python + Selenium + pytest 的 Web 自动化测试框架,框架集成了智能元素定位、可视化回归测试、用例录制回放、文件对比等功能,旨在降低 UI 自动化测试的门槛,降低维护成本,提高测试效率。

为什么需要这个框架

在 Web 自动化测试中,有几个常见的痛点:

  1. 元素定位不稳定 - 页面结构变化导致 xpath/css 定位失效
  2. UI 回归成本高 - 视觉变化难以通过传统断言发现
  3. 用例编写门槛高 - 测试人员需要编写大量代码
  4. 文件校验复杂 - Excel、PDF 等文件的验证不好做
  5. 维护成本高 - 页面、交互的改动需修改大批代码
  6. 测试结果不直观 - 失败原因难以快速定位

Finetest 针对这些问题提供了一套完整的解决方案。

整体架构

┌─────────────────────────────────────────────────────────────┐
│                      测试用例层 (testcase)                    │
│                    pytest 驱动的测试脚本                       │
├─────────────────────────────────────────────────────────────┤
│                      页面对象层 (page_object)                 │
│                   页面业务逻辑封装                             │
├─────────────────────────────────────────────────────────────┤
│                      元素定义层 (page_element)                │
│                   YAML 格式的元素定位定义                       │
├─────────────────────────────────────────────────────────────┤
│                      基础服务层 (page/webpage.py)             │
│          Selenium 二次封装 + 智能定位 + 图片对比 + 文件校验       │
├─────────────────────────────────────────────────────────────┤
│                      工具层 (utils)                           │
│    日志 / 时间/图片处理/Excel 对比/PDF 对比/信号机制              │
└─────────────────────────────────────────────────────────────┘

核心技术实现

一、封装 WebPage 基类

直接使用 Selenium 原生 API 存在以下问题:

  • API 过于底层,每个操作都需要重复处理等待、异常
  • 元素定位失败时缺乏容错机制
  • 无法统一处理页面加载、AJAX 请求等异步场景
  • 缺少业务语义,测试脚本可读性差

WebPage 基类的封装策略

class WebPage(object):
    def __init__(self, driver):
        self.driver = driver
        self.timeout = 10
        self.wait = WebDriverWait(self.driver, self.timeout)
        # 加载 DOM 路径生成脚本
        with open(cm.BASE_DIR + "/page/getdompath.js", "r", encoding='utf8') as f:
            self.getdompathjs = f.read()

封装的核心方法

方法 功能 解决的问题
open_url() 打开网址 统一处理页面加载超时、隐式等待
wait_until_ajax_over() 等待 AJAX 完成 自动检测 jQuery 和 XMLHttpRequest 状态
element_locator() 智能元素定位 多维度加权匹配,提高定位成功率
click_element() 点击元素 支持坐标偏移点击,处理悬浮菜单
img_diff() 图片对比 集成 5 种图像相似度算法
file_diff() 文件对比 自动校验 Excel、PDF 下载内容
expect() 断言方法 统一的断言接口,支持多种校验类型

二、智能元素定位:加权匹配

问题背景

传统的 Selenium 元素定位方式(xpath、css、id 等)存在脆弱性:

  • 开发修改了 id 或 class,定位脚本失效
  • 动态生成的元素 id 带有随机后缀(如 id="button_abc123"
  • 相似元素过多,单一定位器无法唯一确定目标

Finetest 的解决方案

录制阶段通过 Chrome 扩展的 getdompath.js 脚本,为每个元素生成多维定位信息:

function getDomPath(target) {
    return {
        xpath: getDomXPath(target),      // xpath 路径
        selector: getDomSelectorPath(target),  // css 选择器
        text: getDomTextPath(target),    // 文本内容
        value: getDomValuePath(target),  // value 属性
        type: getDomTypePath(target),    // 自定义 type 属性
        abspos: { left, top, width, height },  // 屏幕位置
        classes: "class1 class2..."      // 完整 class 列表
    };
}

运行时的加权匹配算法

def element_locator(self, locator, retry=True):
    # 1. 尝试 xpath 精确匹配
    elements_xpath = self.driver.find_elements_by_xpath(xpath)
    if len(elements_xpath) == 1:
        return elements_xpath[0], locator
    
    # 2. 尝试 css 精确匹配
    elements_selector = self.driver.find_elements_by_css_selector(selector)
    if len(elements_selector) == 1:
        return elements_selector[0], locator
    
    # 3. 多结果时,进入加权匹配
    reserve = {}
    for e in eles1 + eles2:
        weight = 0
        # 文本匹配 +30
        if e["path"].get('text') == text:
            weight += 30
        # value 匹配 +30
        if e["path"].get('value') == value:
            weight += 30
        # type 匹配 +30
        if e["path"].get('type') == stype:
            weight += 30
        # 位置距离 < 100px +30
        d = get_distance(center, get_element_center_pos(e["path"].get('abspos')))
        if d < 100:
            weight += 30
        # 尺寸差异 < 10px +30
        if abs(width_diff) < 10 and abs(height_diff) < 10:
            weight += 30
        # class 匹配 每个 +5
        for c in common_classes:
            weight += 5
        
        reserve[e["id"]] = {"element": e[0], "weight": weight}
    
    # 返回权重最高的元素
    return max(reserve.values(), key=lambda x: x["weight"])["element"]

为什么选择这些权重

维度 权重 原因
文本/value/type 匹配 +30 语义信息最稳定,优先级最高
位置距离 < 100px +30 用户操作的位置记忆相对可靠
尺寸差异 < 10px +30 同类组件尺寸通常一致
class 匹配 +5/个 class 可能复用,权重较低

缺点是,这些都是基于经验的 Magic Number,针对具体场景还要调整。

三、图片对比

问题背景

UI 自动化测试中,验证界面渲染结果的正确性是一个难点:

  • 传统断言只能验证数据,无法验证视觉效果
  • 不同分辨率、浏览器渲染差异可能导致像素级对比失败
  • 需要区分「正常渲染差异」和「真正的 UI bug」

Finetest 集成 5 种图像相似度算法

# imghandle.py(简化代码)
def img_diff(self, locator, directory, imgpath, freeze=False):
    # 1. 定位元素
    log.info("定位图片:{}".format(locator))
    retry = 3
    for i in range(retry):
        try:
            # 2. freeze=True 时,冻结页面交互
            if freeze:
                self.driver.execute_script("""
                    BI.stopEvent = function(event) {
                        event.stopPropagation();
                        event.preventDefault();
                    }
                    document.addEventListener('mouseout', BI.stopEvent, true);
                    document.addEventListener('mousemove', BI.stopEvent, true);
                    document.addEventListener('mouseover', BI.stopEvent, true);
                """)
            
            # 3. 获取元素并移除光标
            ele, path = self.element_locator(locator)
            ActionChains(self.driver).move_to_element_with_offset(ele, 0, 0).perform()
            self.sleep(1)
            
            # 4. 解冻页面
            if freeze:
                self.driver.execute_script("""
                    document.removeEventListener('mouseout', BI.stopEvent, true);
                    document.removeEventListener('mousemove', BI.stopEvent, true);
                    document.removeEventListener('mouseover', BI.stopEvent, true);
                """)
            break
        except (StaleElementReferenceException, ElementNotInteractableException):
            if i == 2:
                raise Exception("元素未出现:" + locator)
            self.sleep(0.5)
    
    # 5. 截图并对比
    img1 = os.path.join(directory, imgpath)  # 基准图
    img2 = os.path.join(cm.TMP_DIR, imgpath)  # 实际截图
    ele.screenshot(img2)
    
    # 6. 5 种算法综合判定
    pham = hamdist(pHash(img1), pHash(img2))  # ≤ 2 认为相似
    aham = hamdist(aHash(img1), aHash(img2))  # = 0 认为相同
    dham = hamdist(dHash(img1), dHash(img2))  # ≤ 2 认为相似
    score = ssim(img1, img2)                   # ≥ 0.95 认为相似
    cdiff = classify_hist_with_split(img1, img2)  # ≥ 0.99 认为相似
    
    if pham > 2 or score < 0.95 or aham > 0 or dham > 0 or cdiff < 0.99:
        score, diff_img = ssim(img1, img2, draw_diff=True)
        cv2.imwrite(diff_path, diff_img)
        raise Exception("图片不一致")

freeze 参数的作用

freeze 参数用于处理悬浮元素(hover menus、tooltips、dropdowns 等)的截图场景。

问题:当鼠标移到元素上触发悬浮菜单后,如果直接调用 screenshot(),鼠标需要先移开,导致悬浮菜单立即消失,截图中无法包含菜单内容。

解决方案:在截图前通过 JavaScript 阻止鼠标的 mouseovermouseoutmousemove 事件,使页面保持当前状态。

// 冻结逻辑
BI.stopEvent = function(event) {
    event.stopPropagation();  // 阻止事件冒泡
    event.preventDefault();   // 阻止默认行为
}

// 全局监听鼠标事件
document.addEventListener('mouseout', BI.stopEvent, true);
document.addEventListener('mousemove', BI.stopEvent, true);
document.addEventListener('mouseover', BI.stopEvent, true);

// 截图完成后解冻
document.removeEventListener('mouseout', BI.stopEvent, true);
document.removeEventListener('mousemove', BI.stopEvent, true);
document.removeEventListener('mouseover', BI.stopEvent, true);

使用场景

# 普通元素截图 - 不需要 freeze
page.img_diff({"xpath": "//div[@id='chart']"}, dir, "chart.png")

# 悬浮菜单截图 - 需要 freeze=True
# 1. 先触发悬浮
page.mouseover_element({"xpath": "//button[@id='menu']"})
# 2. 截图时冻结页面状态
page.img_diff({"xpath": "//div[@class='dropdown-menu']"}, dir, "menu.png", freeze=True)

各算法的特点和适用场景

算法 原理 优点 缺点 判定阈值
pHash DCT 变换后二值化 对缩放、亮度变化鲁棒 计算量较大 汉明距离≤2
aHash 平均灰度二值化 计算最快 精度较低 汉明距离=0
dHash 相邻像素梯度二值化 边缘检测好 对噪声敏感 汉明距离≤2
SSIM 结构 - 亮度 - 对比度综合 最接近人眼感知 计算最慢 ≥0.95
三色直方图 RGB 通道直方图重合度 对位置变化不敏感 无法检测局部差异 ≥0.99

为什么需要多种算法组合

单一算法存在盲区,组合使用可以互相补充:

  • pHash/dHash 检测到相似,但 SSIM 低 → 可能是结构变化但内容正确
  • SSIM 高,但直方图低 → 可能是颜色变化
  • 全部通过 → 真正的视觉一致

四、录制功能:Chrome 扩展 + WebSocket 架构

为什么要录制

手写自动化测试用例门槛高、效率低。录制功能可以让测试人员通过实际操作生成用例代码。

架构设计

┌──────────────┐    WebSocket     ┌──────────────┐
│  Chrome      │ ◄──────────────► │  Python      │
│  扩展       	│   (ws:8242)      │  Server      │
│              │                 	│ (Tornado)    │
│ - 捕获操作    │                   │ - 生成代码    │
│ - DOM 定位    │                  │ - 保存用例    │
│ - 截图断言    │                   │ - 文件对比    │
└──────────────┘                  └──────────────┘

Chrome 扩展实现

// foreground.js - 内容脚本
// 1. 监听用户操作
document.addEventListener('click', function(e) {
    if (!isRecording) return;
    
    // 获取元素完整定位信息
    var path = getDomPath(e.target);
    
    // 发送到 background
    chrome.runtime.sendMessage({
        type: 'command',
        data: {
            cmd: 'click',
            path: path,
            x: e.clientX,
            y: e.clientY
        }
    });
});

// 2. 快捷键支持
document.addEventListener('keydown', function(e) {
    // Alt+1 截图断言
    if (e.altKey && e.key === '1') {
        GlobalEvents.emit('screenshotDiff');
    }
    // Alt+2 属性断言
    if (e.altKey && e.key === '2') {
        GlobalEvents.emit('expectDiff');
    }
});

元素路径生成的核心逻辑 (getdompath.js):

录制时,Chrome 扩展会注入 getdompath.js 脚本,该脚本负责为每个被操作的元素生成完整的定位信息:

function getDomPath(target, isAllDom) {
    // 生成 5 种定位路径 + 位置信息
    let path1 = getDomXPath(target, isAllDom);        // xpath 路径
    let path2 = getDomSelectorPath(target, isAllDom); // css 选择器
    let path3 = getDomTextPath(target, isAllDom);     // 文本内容定位
    let path4 = getDomValuePath(target, isAllDom);    // value 属性定位
    let path5 = getDomTypePath(target, isAllDom);     // 自定义 type 属性
    
    // 获取元素的屏幕位置和尺寸
    let rect = target.getBoundingClientRect();
    let abspos = {
        left: rect.left,
        top: rect.top,
        width: rect.width,
        height: rect.height
    };
    
    // 获取完整的 class 列表
    let classes = target.getAttribute && target.getAttribute('class');
    
    return {
        xpath: path1,
        selector: path2,
        text: path3,
        value: path4,
        type: path5,
        abspos: abspos,
        classes: classes
    };
}

XPath 生成策略

function getDomXPath(target, isAllDom) {
    var arrAllPaths = [];
    var node = target;
    
    // 从目标元素向上遍历到根节点
    while (node) {
        path = getRelativeDomPath(document, target, isAllDom);
        if (path) {
            arrAllPaths.push(path);
            // 检查当前路径是否唯一
            if (checkUniqueXPath(document, path, isAllDom)) {
                break;
            }
        }
        node = node.parentNode || node.host;
        target = node;
    }
    
    // 反转数组,从根节点到目标元素
    return arrAllPaths.length > 0 ? arrAllPaths.reverse().join('') : null;
}

function getRelativeDomPath(rootNode, target, isAllDom) {
    var tagName = target.nodeName.toLowerCase();
    var testidValue = target.getAttribute('data-testid');
    var idValue = target.getAttribute('id');
    var textValue = target.textContent;
    var __type__Value = target.getAttribute('__type__');
    var __text__Value = target.getAttribute('__text__');
    var __value__Value = target.getAttribute('__value__');
    var classValues = target.getAttribute('class');
    
    // 优先级 1: data-testid (测试专用属性)
    if (testidValue && checkUniqueXPath(rootNode, `//*[@data-testid="${testidValue}"]`)) {
        return `//*[@data-testid="${testidValue}"]`;
    }
    
    // 优先级 2: 唯一 id
    if (idValue && !isInBlackList(idValue) && 
        checkUniqueXPath(rootNode, `//*[@id="${idValue}"]`)) {
        return `//*[@id="${idValue}"]`;
    }
    
    // 优先级 3: 自定义属性 (__type__, __text__, __value__)
    if (__type__Value && __text__Value) {
        return `//*[@__type__="${__type__Value}" and @__text__="${__text__Value}"]`;
    }
    
    // 优先级 4: 文本内容定位
    if (textValue && textValue.length <= 50) {
        return `//${tagName}[text()="${textValue}"]`;
    }
    
    // 优先级 5: class 定位 (在 whitelist 中的 class 优先)
    if (classValues) {
        for (let cls of classValues.split(' ')) {
            if (cls in classWhiteList && 
                checkUniqueXPath(rootNode, `//*[contains(@class,"${cls}")]`)) {
                return `//*[contains(@class,"${cls}")]`;
            }
        }
    }
    
    return null;
}

路径生成的优先级策略

优先级 定位方式 原因
1 data-testid 测试专用属性,最稳定
2 唯一 id 标准定位方式,但可能动态变化
3 自定义属性 (__type__, __text__) 框架注入的语义化属性
4 文本内容 对按钮、链接等元素有效
5 class (白名单) 仅限语义化 class,避免样式 class
6 层级 xpath 以上都失败时的兜底方案

动态 id 处理

对于动态生成的 id(如 id="button_abc123"),脚本会尝试截断随机后缀:

// 检测到 id 包含 16 位随机字符
if (idValue.match(/[a-z0-9]{16}/)) {
    // 截断为前缀匹配
    var prefix = idValue.replace(/_[a-z0-9]{16}.*$/, '');
    return `//*[starts-with(@id,"${prefix}")]`;
}

CSS Selector 生成

function getDomSelectorPath(target, isAllDom) {
    var arrAllPaths = [];
    var node = target;
    
    // 从目标元素向上遍历,寻找最近的唯一 id 祖先
    while (node) {
        if (node.nodeName === '#document') {
            path = getRelativeDomSelectorPath(node, target, isAllDom);
            if (path) {
                arrAllPaths.push(path);
                break;
            }
        }
        node = node.parentNode;
    }
    
    // 使用 /deep/ 连接跨 Shadow DOM 的路径
    return arrAllPaths.length > 0 ? arrAllPaths.reverse().join(' /deep/ ') : null;
}

WebSocket 服务端

# scripts.py
class WSserver(WebSocketHandler):
    arrCodes = []      # 生成的代码行
    eleCodes = {}      # 元素定位信息
    cmdQueue = []      # 操作命令队列
    
    def on_message(self, message):
        message = tornado.escape.json_decode(message)
        
        if message['type'] == 'saveCmd':
            self._on_command(message['data'])
        
        elif message['type'] == 'save':
            self.description = message['data']['description']
            self._save_testfile()      # 保存 JSON 命令流
            self._generate_testfile()  # 生成 Python 代码
    
    def _on_command(self, data):
        # 命令过滤和合并
        self._sendKeysFilter(data)
    
    def _sendKeysFilter(self, cmdInfo):
        # 合并连续的 sendKeys 操作
        if cmdInfo.get('cmd') == 'sendKeys':
            self.arrSendKeys.append(cmdInfo['data']['keys'])
        else:
            if len(self.arrSendKeys) > 0:
                # 执行合并
                merged_cmd = {
                    'cmd': 'sendKeys',
                    'data': {'keys': ''.join(self.arrSendKeys)}
                }
                self.cmdQueue.append(merged_cmd)
                self.arrSendKeys = []
            self.cmdQueue.append(cmdInfo)

生成的测试用例

# testcase/登录/test_登录.py
import pytest
from page.webpage import WebPage

class TestLogin:
    def test_用户登录 (self, drivers):
        """用户登录成功场景"""
        page = WebPage(drivers)
        
        # 打开登录页
        page.open_url('http://localhost:8080/login')
        
        # 输入用户名
        page.send_keys('admin')
        
        # 输入密码
        page.send_keys('123456')
        
        # 点击登录按钮
        page.click_element({"xpath": "//button[@id='loginBtn']"}, 100, 50)
        
        # 验证登录成功
        page.expect(None, "url", "contain", "/home")

五、pytest 集成

conftest.py

@pytest.fixture(scope='module', autouse=True)
def drivers(request):
    # 测试前:创建 WebDriver
    chrome_options = webdriver.ChromeOptions()
    chrome_options.add_experimental_option(
        'prefs', 
        {'download.default_directory': DOWNLOAD_DIR}
    )
    driver = webdriver.Chrome(options=chrome_options)
    driver.set_window_size(1920, 1080)
    
    def fn():
        # 测试后:移动下载文件到临时目录供对比
        for filename in os.listdir(DOWNLOAD_DIR):
            shutil.move(
                os.path.join(DOWNLOAD_DIR, filename),
                os.path.join(TMP_DIR, f"download_{num}_{filename}")
            )
        driver.quit()
    
    request.addfinalizer(fn)
    return driver

自定义 HTML 报告

def pytest_html_results_table_header(cells):
    # 自定义表头
    cells[:] = [
        html.th('测试结果'),
        html.th('用例名称'),
        html.th('用例路径'),
        html.th('执行时长')
    ]

def pytest_html_results_table_row(report, cells):
    # 自定义每行内容
    cells[1] = html.td(report.description)  # 用例名称 = 函数 docstring
    cells[2] = html.td(report.nodeid)       # 用例路径

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item):
    # 失败时自动截图
    outcome = yield
    report = outcome.get_result()
    
    if report.failed:
        screen_img = _capture_screenshot()
        html = f'<img src="data:image/png;base64,{screen_img}" />'
        report.extra.append(pytest_html.extras.html(html))

使用方式

初始化项目

# 安装 finetest 包
pip install finetest(公司内部源)

# 初始化测试项目
finetest init

# 目录结构自动生成

录制用例

# 启动录制
finetest record

# 浏览器自动打开,操作页面
# 使用快捷键添加断言
# 保存后自动生成测试用例

执行测试

# 运行所有测试
pytest

# 生成 HTML 报告
pytest --html=report.html

# 运行指定用例
pytest testcase/登录/

总结

Finetest 的核心价值在于:

  1. 降低门槛 - 录制功能让非技术人员也能编写自动化用例
  2. 提高稳定性 - 智能元素定位减少脚本维护成本
  3. 增强验证能力 - 多维度图片对比 + 文件校验覆盖更多场景
  4. 改善体验 - 失败截图 + 差异图 + 详细日志帮助快速定位问题

这些设计都是为了解决实际测试工作中的痛点,而非单纯的技术堆砌。