Finetest: 一个 Web 自动化测试框架的技术实现
项目简介
Finetest 是一个基于 Python + Selenium + pytest 的 Web 自动化测试框架,框架集成了智能元素定位、可视化回归测试、用例录制回放、文件对比等功能,旨在降低 UI 自动化测试的门槛,降低维护成本,提高测试效率。
为什么需要这个框架
在 Web 自动化测试中,有几个常见的痛点:
- 元素定位不稳定 - 页面结构变化导致 xpath/css 定位失效
- UI 回归成本高 - 视觉变化难以通过传统断言发现
- 用例编写门槛高 - 测试人员需要编写大量代码
- 文件校验复杂 - Excel、PDF 等文件的验证不好做
- 维护成本高 - 页面、交互的改动需修改大批代码
- 测试结果不直观 - 失败原因难以快速定位
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 阻止鼠标的 mouseover、mouseout、mousemove 事件,使页面保持当前状态。
// 冻结逻辑
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 的核心价值在于:
- 降低门槛 - 录制功能让非技术人员也能编写自动化用例
- 提高稳定性 - 智能元素定位减少脚本维护成本
- 增强验证能力 - 多维度图片对比 + 文件校验覆盖更多场景
- 改善体验 - 失败截图 + 差异图 + 详细日志帮助快速定位问题
这些设计都是为了解决实际测试工作中的痛点,而非单纯的技术堆砌。