拓冰建站拓冰建站
首页 / 资讯中心 / 正文

宽度对比自动化实战:基于Playwright的视觉回归测试方案

这两年做自动化测试越来越觉得行业里有个挺有意思的现象大家张口闭口都在谈自动化但真正把自动化当成一个系统性工程来对待的人其实并不多。就拿我最近在搞的这个宽度对比自动化项目来说听起来就是个不起眼的小工具但做下来才发现它几乎把 Web UI 自动化、接口自动化、CI 持续集成、甚至 AI 辅助测试的路子全串起来了。这篇博文我就把这个项目的完整思路、实现过程、踩过的坑、还有排查问题的方法一次性讲清楚。想让自己的测试工作真正自动起来的朋友不管你是刚入门还是已经写了几年脚本这篇文章应该都能给你点实在的参考。先说清楚这个项目是干嘛的。所谓宽度对比核心就一句话在页面布局发生变化、样式改版、或者响应式断点调整的时候自动去对比不同元素在不同状态下的宽度值判断是否符合预期并且把对比结果生成报告。听起来简单但实际落地的时候牵扯到元素定位策略、等待机制、多浏览器兼容、数据采集口径、报告可视化、CI 集成等等一堆问题。这篇文章会把整个实现链路拆开讲包括最关键的为什么这么做而不只是贴代码。1. 项目整体设计与思路拆解1.1 为什么需要宽度自动对比有段时间我维护一个后台管理系统前端同事几乎每周都要调样式今天改个侧边栏宽度明天动一下表格列宽。每次改完测试这边就要手动打开页面肉眼对比改版前后的布局量一下宽度看有没有溢出、错位、遮挡。这种活干一次两次还行干多了真的崩溃——第一是肉眼对比不精确差一两个像素根本看不出来第二是回归成本太高每次改版都要把所有关键页面过一遍第三是没留痕今天测完觉得没问题过两天线上出了样式故障谁也说不清是哪个版本引入的。后来我就想能不能把这件事做成自动化的让脚本自己打开页面测量关键元素的宽度跟基准值对比超了阈值就报警出报告。宽度对比这个需求看起来是很垂直的小场景但它其实是视觉回归测试里最基础、最容易量化的一环。相比像素级截图对比那种重方案宽度数值对比更轻量、更稳定不容易受到字体渲染差异、滚动条宽度、浏览器缩放等因素的干扰适合作为第一步自动化尝试。这个项目本质上解决的是三个问题一是把人眼对比升级为数值对比让判断标准可量化二是把手动回归升级为自动巡检每次发版前自动跑一遍三是把零散记录升级为结构化报告发现问题之后可以直接追溯到具体元素和具体历史版本。1.2 技术方案选型与思考过程技术上怎么选我大概纠结了一周。最开始考虑过纯 Selenium 方案因为团队里大多数人只会 Python Selenium上手门槛最低。Selenium 做宽度获取确实没问题element.size[width]一行代码就能拿到底。但它有两个短板一是运行速度偏慢启动浏览器、加载页面、等待渲染一套流程下来几十秒就过去了二是元素定位脆弱页面稍微改点结构By.ID变成By.CSS_SELECTOR脚本就在那报错了。后来我盯上了 Playwright。这玩意儿跟 Selenium 比最大的优势是它内置了 WebDriver 那套东西直接走 CDP 协议启动快、API 也更现代。自动等待机制做得好不用像 Selenium 那样到处time.sleep(3)或者WebDriverWait写一堆显式等待。尤其是定位不到元素的时候Selenium 给个NoSuchElementException就完了Playwright 会给详细的 Actionability 报告告诉你这个元素是不是被遮挡了、是不是还在加载中。这对宽度采集这种对时机要求很高的场景来说帮助特别大。具体选型上我的建议是这么分的团队只会 Python项目周期短允许跑得慢一点用 Selenium Pytest 完全没问题。团队愿意学一点新东西或者项目对稳定性要求高直接上 Playwright值得。只需要验证接口返回的数据宽度比如小程序的 rpx 转 px 逻辑那根本不用 UI 自动化Python Requests 就够了后面会详细说。最终我这个项目是 Playwright Pytest Allure 的组合。Playwright 负责浏览器操作和宽度采集Pytest 负责用例组织和断言Allure 负责报告展示Jenkins 负责定时触发和结果推送。这套组合的好处是每一层职责都很清楚出问题了好排查。1.3 整个项目的模块划分动手写代码之前我先画了一下模块边界。项目不复杂但如果不划分清楚后期加页面、加指标的时候会乱成一锅粥。我分了这么几层配置层存放所有可变参数比如基准环境地址、测试环境地址、要对比的页面清单、元素定位表达式、宽度上下限阈值、超时时间等。用 YAML 文件管理不硬编码在代码里。采集层核心的浏览器操作逻辑负责打开页面、等待元素出现、获取宽度数据、截图留证。这一层只做采集不做判断。对比层把采集到的实际值与基准值做对比根据设定的容差范围判断是 PASS 还是 FAIL生成结构化对比结果。报告层把对比结果渲染成 HTML 报告方便在 Jenkins 上查看同时把关键失败信息推送到企业微信/钉钉机器人。驱动层Pytest 用例 Jenkins Pipeline负责组织执行顺序、传递参数、管理测试数据。这样分的好处是任何一层出了问题改动范围都能被隔离。比如前端改了页面结构导致定位符失效我只需要去配置层里改选择器采集层的代码一行不用动。比如产品说容差范围调一下对比层改个参数就行。模块化做得好后面维护成本才降得下来。2. 核心细节解析与实操要点2.1 宽度采集的原理与实现说到宽度对比最核心的就是要搞清楚你拿到的宽度到底是什么宽度浏览器里一个元素的宽度有好几种口径offsetWidth是包含边框和内边距的clientWidth是包含内边距但不包含边框的getBoundingClientRect()返回的是渲染后的实际盒子宽度。这三种数值在某些场景下差异不大但如果元素有box-sizing: border-box那offsetWidth和clientWidth可能就跟 CSS 里写的width属性对不上了。我项目里统一用的是getBoundingClientRect()加小数保留两位的方式。为什么不用element.size因为 Playwright 的element.size底层也是调getBoundingClientRect但返回的是整数会把小数位截掉。做精细对比的时候差 0.5 像素就可能影响结果所以我自己写了个小函数取完整浮点值async def get_element_width(page, selector: str) - float: width await page.eval_on_selector( selector, (el) el.getBoundingClientRect().width ) return round(float(width), 2)实测下来这个方法最稳不管元素是普通 div、canvas 内部区域还是 iframe 里的内容都能拿到相对准确的渲染宽度。另外有个细节宽度对比很多时候不是比单个元素而是比一组元素。比如导航栏有五个菜单项我想知道每个菜单项在改版后是不是都保持在合理的宽度区间内。那就要用query_selector_all循环取然后存成一个列表做数组对比async def get_elements_widths(page, selectors: list) - dict: result {} for name, selector in selectors.items(): count await page.locator(selector).count() widths [] for i in range(count): w await page.locator(selector).nth(i).evaluate( el el.getBoundingClientRect().width ) widths.append(round(w, 2)) result[name] widths return result2.2 等待策略自动化最容易被忽略的坑宽度采集最怕什么怕元素还没渲染完就开测。尤其是现在前端框架普遍是组件化、异步加载你 Selenium 的implicitly_wait(10)其实只能保证元素在 DOM 里存在不能保证它有真实的宽高。常见的情况是登录页面的表单已经出现在 DOM 里了但 CSS 还没加载完宽度全量成了一个默认的 0 或者异常大值。这时候测出来的宽度是毫无意义的。Playwright 的自动等待解决了一部分问题locator.wait_for(statevisible)会等元素可见但可见不等于渲染稳定。我的经验是在宽度对比这种对渲染状态敏感的测试里等待策略要做一个组合先等元素可见visible。再等网络空闲networkidle确保页面依赖的接口和静态资源都加载完了。最后再加一个短轮询连续两次读取宽度一致才认为渲染稳定了。第三种方式是我后来加的代价是多花一两秒但好处是几乎消除了误报。代码如下async def wait_for_stable_width(page, selector: str, timeout10000): last_width None stable_count 0 start time.time() while time.time() - start timeout: width await get_element_width(page, selector) if last_width is not None and abs(width - last_width) 0.5: stable_count 1 if stable_count 2: return width else: stable_count 0 last_width width await page.wait_for_timeout(200) raise TimeoutError(f元素 {selector} 宽度在 {timeout}ms 内未稳定)这套方式跑下来稳定性提升非常明显。之前直接傻等固定时间偶尔会遇到图表组件延迟渲染导致的误报换成稳定轮询之后基本没再出现过。2.3 数据基准与容差设计宽度对比有个绕不开的问题基准值从哪来容差设多少我的做法分两种第一种是首次运行时自动采集当前环境的宽度写入 JSON 基准文件作为后续对比的基线。第二种是针对某些已知会变的元素比如广告位宽度、用户昵称长度影响下的动态元素用手工配置的模式。这两种方式用baseline_mode参数区分record模式记录基准check模式做对比。容差设计上我吃过亏。刚开始我拍脑袋设了个 1 像素容差结果每次跑都有一堆误报。后来分析发现不同浏览器在字体渲染上就有细微差异同样的 CSSwidth: 100pxChrome 里getBoundingClientRect()可能返回 100.02Firefox 里返回 99.98。所以容差不能设成死值要分场景固定宽度元素按钮、输入框容差 1px。流式布局元素侧边栏、容器 main容差看百分比换算比如容器总宽变化导致的漂移。动态内容元素表格列宽、文本过长换行容差放宽到 3-5px或者改成只验证范围区间而不是精确相等。另外做对比的时候最好记录一下对比时的视口尺寸和设备类型。同一个页面1920 宽和 1366 宽下元素的宽度天然不一样如果不记录上下文报告看起来就会莫名其妙。我在采集结果里会额外存viewport、ua、timestamp这几个元信息字段作为排障的上下文。2.4 数据采集的多样性与扩展其实宽度对比完全可以泛化成元素属性对比宽度只是第一个抓手。做的时候我把数据模型抽象了一下每条采集记录包含元素名、选择器、实际值、基准值、容差、结果、截图路径、页面URL、视口信息。这样未来如果想扩展成对比高度、对比位置、对比颜色、对比可见性只需要在采集层加对应的方法对比层和报告层都不用大改。我甚至做过一个大胆的扩展把接口返回的数据包大小也在同一个用例里对比。思路是页面上某个区域的宽度如果异常变宽往往是后端返回了超长文本这时候接口的数据包大小也会异常变大。两者联动校验能提高排查效率。虽然这个场景不常见但说明思路打开了自动化的价值就不仅仅是替代手工而是做手工根本做不到的事。3. 实操过程与核心环节实现3.1 环境准备与项目初始化老规矩先说环境。我是 Windows 11 上开发的Python 3.10Playwright 1.40 左右。项目依赖用 pip 管理pip install playwright pytest allure-pytest pyyaml requests playwright install chromiumplaywright install chromium是必须的不然跑不起来。我建议除了 Chromium也把 Firefox 和 WebKit 一起装了毕竟宽度对比对跨浏览器差异敏感有个对比环境心里才有底。只装 Chromium 的话一次只能验证 Chrome 系覆盖不全。项目目录结构我长这样width_compare/ ├── config/ │ ├── config.yaml # 全局配置 │ └── selectors.yaml # 元素定位表达式 ├── baselines/ # 基准数据文件 │ └── homepage_baseline.json ├── reports/ # 测试报告输出 ├── screenshots/ # 失败截图 ├── core/ │ ├── collector.py # 数据采集层 │ ├── comparator.py # 数据对比层 │ ├── reporter.py # 报告生成层 │ └── wait_utils.py # 稳定等待 ├── tests/ │ ├── conftest.py # pytest fixtures │ └── test_width_compare.py └── requirements.txt这个结构不复杂但足够用。核心逻辑都在core目录下tests里只放用例和夹具配置集中在config下。3.2 用例组织与 Pytest 集成Pytest 里我用的最顺的就是 fixture 机制。每个测试用例需要的东西一个已经登录态的浏览器页面、一份从 YAML 加载的测试数据、一个报告收集器。把这些都做成 fixture用例本身会非常干净。import pytest from core.collector import WidthCollector from core.comparator import WidthComparator from core.reporter import ReportCollector pytest.fixture(scopesession) def config(): return load_yaml(config/config.yaml) pytest.fixture(scopesession) def selectors(): return load_yaml(config/selectors.yaml) pytest.fixture() async def page(browser, config): context await browser.new_context( viewportconfig[viewport], record_video_dirreports/videos if config.get(record_video) else None ) pg await context.new_page() await pg.goto(config[base_url]) yield pg await context.close() pytest.fixture() def collector(page, selectors): return WidthCollector(page, selectors)用例层面比如一个首页关键模块宽度对比的用例写起来就几行def test_homepage_widths(collector, comparator, reporter): collector.visit(homepage) actual collector.collect_widths(homepage) baseline load_baseline(homepage_baseline.json) results comparator.compare(actual, baseline, tolerance1.0) reporter.append(homepage, results) assert all(r.status PASS for r in results), 存在宽度异常元素用 fixture 的好处是灵活性高我随时能在配置里切换环境、切换视口、开启视频录制用例代码却完全不用动。3.3 核心环节一次完整的宽度对比执行链我现在梳理一遍从执行到出报告的完整链路方便你照着搭第一步Pytest 启动fixture 加载配置和选择器。这里我强烈建议在 fixture 里打印一下当前执行的环境、视口、浏览器类型方便报告里区分是哪次运行。第二步collector.visit(homepage)打开目标页面。这个visit方法内部不只是goto还包含了登录态注入如果是需要登录的系统我会先把 cookie 或 token 存下来这里直接设置上去、等待首屏稳定、跳过弹窗提示等逻辑。第三步collect_widths遍历配置里的所有关键元素组对每个组做稳定等待后采集宽度。这一步也是截图的好时机——我每个元素组都会截一张图标注上元素位置方便后续人工确认是哪块出了偏差。第四步comparator.compare拿到实际数据之后逐个元素对比。对比逻辑不只是比数值还会检查元素的可见性、数量是否变化。比如导航栏本该有 5 项这次只采到 4 项那就是 DOM 结构都变了这种直接判定为 CRITICAL比宽度超差还严重。第五步reporter.append把这次执行的原始结果塞进报告收集器里。报告收集器在pytest_sessionfinish阶段统一输出成 JSON 和 HTML 两种格式。HTML 报告里我把数据做成了表格每个元素一行实际值、基准值、差值、容差、状态、截图链接一眼就能扫到问题。第六步Jenkins 在 Pipeline 的 post 阶段把所有报告产物归档同时如果有失败用例通过企业微信机器人推送一条消息带上失败元素和截图链接。整条链路跑完大概 2-3 分钟能覆盖四五个页面的宽度巡检这效率比人工肉眼对比高太多了。3.4 Python 接口场景的补充不带浏览器的宽度校验有些宽度来源其实不在页面上而在接口返回的数据里。比如小程序的rpx换算逻辑、富文本内容的图片width字段、App 端下发的手势热区坐标等。测试这些逻辑如果也要启动浏览器成本就太高了。我补了一个纯接口的校验模块用requests拉取数据然后解析 JSON检查宽度相关字段是否在指定区间内。比如服务端返回一个图片列表我要验证每张图片的width字段不超过 750对应移动端设计稿基准低于 50 的要报警可能是占位图。这种批量数据校验用接口方式做覆盖率可以做到 100%而且是毫秒级的。def validate_image_widths(api_data: dict, max_width750, min_width50) - list: issues [] for img in api_data.get(images, []): w img.get(width, 0) if w max_width or w min_width: issues.append({ url: img.get(url), width: w, expected_range: f{min_width}-{max_width} }) return issues所以宽度对比这个项目里UI 自动化和接口自动化不是互斥的而是互补的。能走接口的绝不走 UI走 UI 的一定是接口覆盖不到的真实渲染场景。这也是我现在做自动化测试的一个基本原则。4. 常见问题与排查技巧实录4.1 元素宽度为 0 或 NaN宽度为 0 是跑宽度对比时最常碰到的现象。原因基本可以归为四类元素是隐藏的display: none或者visibility: hidden这种元素没有渲染盒子拿到的宽度一定是 0。元素还没挂载异步渲染还没完成选择器虽然能匹配到但此时只是在虚拟 DOM 里真实布局还没产生。元素在 iframe 里主文档的eval_on_selector默认访问不到 iframe 内部需要先切到对应 frame 再取值。元素是 canvas 或 SVG 内的逻辑尺寸getBoundingClientRect()拿的是画布元素本身的大小不是绘制内容的尺寸。这种情况要改用 canvas 的getImageData或 SVG 的getBBox()。我排查这个问题第一件事是看截图。如果截图里元素肉眼可见且宽度正常那多半是取值时机问题用稳定等待再试。如果截图里元素也是空的那就要看 CSS 类名是不是改了或者元素被折叠了。大部分宽度为 0 的 Case前者占八成。4.2 偶发性的宽度抖动偶发性问题最磨人有时候连续跑十次都是 PASS第十一次突然 FAIL再跑又好了。宽度抖动一般是这几种原因图表组件重新渲染ECharts 这类组件在数据更新时会有一小段动画过渡宽度在这个过渡期是不稳定的。图片加载前后引起的布局偏移如果元素宽度受旁边图片尺寸影响图片没加载完之前宽度是错的加载完又变了。字体加载插曲font-display: swap的字体加载完之前和之后文本宽度可能差很多。针对这类抖动我的策略有三层首先是稳定等待连续两次宽度一致才记录其次是数据去抖如果一次执行内同一个元素出现了两次不同的宽度结果记录较小的那个并标注疑似抖动;最后是失败自动重试Pytest 的flaky插件或者自己写个重试装饰器单次失败先重试两次重试仍失败才真正标 FAIL。4.3 跨浏览器差异导致的误报同一份 CSSChrome 和 Firefox 渲染出的宽度可能有零点几像素的差异。这不算 bug是浏览器内核的取舍不同。但宽度对比如果死抠 1px 容差就会制造大量误报。处理这个问题我把容差参数改成了可配置的分组容差不同浏览器用不同容差。另外如果项目没有强制要求跨浏览器一致最省事的方案是只针对主浏览器做宽度基准其他浏览器只记录不断言出问题的时候人工介入看。自动化不是为了制造噪音而是为了减少噪音这个原则我一直记着。4.4 常见异常速查表我整理了一个速查表把这些年碰到的宽度对比相关异常和排查方向放在一起方便你遇到问题的时候快速定位异常现象可能原因排查方向宽度为 0元素隐藏 / 未渲染检查 CSS display / visibility用稳定等待重试宽度为 NaN页面 JS 报错导致布局异常打开浏览器控制台查看报错堆栈宽度暴涨图片等比放大 / 弹性布局异常检查容器 max-width 是否存在截图确认宽度忽大忽小异步内容加载抖动增加稳定等待开启失败重试元素数量不匹配DOM 结构变更对比修改前后的 HTML 片段更新选择器定位超时页面加载慢 / 元素改路径先手动打开页面确认再调整超时时间4.5 一些避坑和效率技巧最后分享几个实操中总结出来的经验不一定能在官方文档里看到但对效率提升帮助非常大第一给每个关键元素加一个稳定的数据属性。比如前端开发协作规范里约定需要自动化测试的元素必须带>
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门