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

用Playwright实现宽度对比自动化:从配置到报告的前端回归方案

“宽度对比自动化”这个需求最初不是谁拍脑袋想出来的。当时我们后台管理系统做了一次大改版设计稿里所有按钮、输入框、侧边栏的宽度都标得清清楚楚但前端实现完后测试同学只能打开浏览器开发者工具一个一个 hover 到元素上看宽度再拿计算器和设计稿对。改版前两周光是对着一堆页面量宽度、对像素就搭进去大量时间而且今天量完明天前端又改了一版全部白干。后来我写了一套自动化脚本把“量宽度”这件事变成了一条命令的事自动打开页面、自动定位元素、自动采集宽度、自动和预期值比较最后生成一份 HTML 报告。这篇博文就把这套方案完整拆开讲清楚包括思路、核心代码、踩坑记录和扩展方向适合正在做 UI 自动化测试、前端回归验证或者刚接触 Playwright / Selenium 的读者参考。1. 为什么我给“宽度对比”专门写了套自动化1.1 人工量宽度的坑慢、漏、不可复现很多人觉得量宽度是个小活不就是打开 F12 看一眼吗真做起来完全不是那么回事。首先是慢一个页面上需要校验的关键元素随随便便就有十几个每个元素都要 hover、看计算样式、找宽高属性、记录一个页面走下来至少十分钟一套系统几十个页面量一遍就是大半天。其次是漏人工检查非常依赖责任心没有人能保证每次改版都能把几百个元素全部量一遍总有漏网之鱼。最头疼的是不可复现宽度出了问题当时你没记录事后想追查是哪次改动、哪个页面引入的没有任何历史痕迹。还有个现实情况是多浏览器、多视口验证。同一个页面在 Chrome、Edge、Firefox 里渲染出来的宽度可能不一样桌面端 1440 的视口、笔记本 1280 的视口、平板 768 的视口移动端 375 的视口宽度完全不同。如果全靠人工去量等于把刚才的重复劳动再乘好几倍。所以我在做自动化的时候给自己定的目标是宽度对比必须能一键执行跑完自动出结果能放进发版流程里当关卡。这套思路做完之后一次全站宽度巡检从大半天压缩到 15 分钟以内而且每次跑都有报告哪一次改坏了宽度一查历史就知道。1.2 自动化宽度对比的典型使用场景自动化宽度对比并不只是“改版时校对设计稿”这一种用途我梳理了五个实际高频场景。第一个是改版前后回归。前端样式重构之后最怕的是视觉上“看着差不多”实际上某些元素宽了几像素、窄了几像素。用脚本把改版前后的宽度数据抓出来做 diff比肉眼可靠得多。第二个是设计规范校验。很多公司有统一的设计规范比如主按钮宽度不小于 120px、输入框宽度 240px、侧边栏固定 240px。这些规范适合固化成自动化断言一旦有人违反规范脚本直接标红。第三个是响应式布局检测。不同视口下元素宽度应该在合理的区间内。比如导航栏在移动端应该变成汉堡菜单而脚本可以自动检查桌面端导航宽度和移动端导航宽度确认布局正确切换。第四个是素材尺寸合规。运营上传的 banner、商品主图、类目图标经常有固定尺寸要求上传后前端可能做裁剪或拉伸自动化脚本可以打开上传结果页校验实际渲染宽度是否合规。第五个是需求中明确写了最小宽度、最大宽度的场景比如弹窗宽度不超过视口宽度的 90%、表格最小宽度不低于 800px。这类数值型需求用自动化断言去守比人工抽检稳定得多。2. 技术选型Playwright 与 Selenium 怎么选2.1 Playwright 的优势为什么更明显宽度对比自动化的核心是稳定地拿到元素渲染宽度围绕这一点我对比过 Selenium 和 Playwright最后选了 Playwright。Playwright 吸引我的第一个点是自动等待。Selenium 定位元素后如果页面还没渲染完你去拿尺寸经常拿到 0 或者半截数据必须自己写显式等待等得短了不稳、等得长了浪费时间。Playwright 的 locator 操作会自动等待元素可见、稳定后再执行拿宽度的时候不会出现“元素存在但宽度还没确定”的尴尬。第二个点是它原生提供了bounding_box()方法一行代码就能拿到元素在页面上的坐标和宽高不需要像 Selenium 那样execute_script去调浏览器原生接口。代码更简洁心智负担也小。第三个点是 Playwright 对多视口和移动端模拟的支持非常自然。我可以在同一个浏览器实例里创建多个page每个page指定不同的viewport在同一个测试进程里把桌面端、平板、手机端的宽度全部采样一遍非常适合响应式宽度对比。Selenium 当然也能干这些事但在新项目里没有理由不用 Playwright。如果你所在团队的技术栈还停留在 Selenium别急我下面写了兼容方案。2.2 Selenium 老项目怎么改造接上如果公司里已经有成熟的 Selenium 框架为宽度对比专门切一套 Playwright 的成本可能不低。这时候可以用 Selenium 的原生方案实现同样的宽度采集。Selenium 拿元素宽度最直接的写法是用size属性from selenium import webdriver from selenium.webdriver.common.by import By driver webdriver.Chrome() driver.get(https://example.com/login) el driver.find_element(By.CSS_SELECTOR, #loginBtn) width el.size[width] print(width)这种写法在大多数场景够用但如果元素有 CSStransform缩放或者你关心的是渲染后的实际显示宽度size可能会跟视觉有偏差。更稳妥的写法是用 JavaScript 直接调getBoundingClientRect()width driver.execute_script( return arguments[0].getBoundingClientRect().width;, el )getBoundingClientRect()返回的是元素渲染后的边界框信息包含了transform的影响更贴近用户肉眼看到的宽度。但要注意如果元素设置了display: none这个值会返回 0所以采集前要确认元素确实可见。另外 Selenium 老项目的等待机制建议统一封装比如写一个wait_for_visible方法先等到元素可见再取宽度否则很容易拿到半渲染状态的数据。2.3 宽度数据采集的三个关键方法说到宽度数据采集前端其实有好几个宽度概念我实际用的有三个容易混先放在一张表里说清楚。方法返回内容主要特点element.bounding_box()Playwright元素的 border-box 尺寸和坐标不含transform缩放后的视觉尺寸但适合大多数布局校验getBoundingClientRect().width元素渲染后的实际边界框宽度受transform影响返回值可能是小数el.size[width]Selenium元素尺寸等价于边框盒尺寸简单但不够灵活实际操作中如果是校验设计稿标好的固定宽度用bounding_box()就够了如果是校验元素在页面上看起来“多宽”特别是有缩放、旋转、位移的场景就要用getBoundingClientRect()。还有个细节是这两者返回的都是像素值但可能是小数比如240.00000762939453做对比断言之前一定要做四舍五入处理不然精度误差会干扰判断。3. 核心实现配置驱动自动对比报告输出3.1 用一份配置文件管理所有宽度断言宽度对比如果写在测试用例里写死后期维护成本会很高。我采用的是配置驱动思路把“要检查哪个页面、哪些元素、预期宽度多少、允许误差多少”全部抽到一份配置文件里。业务人员或者新来的同事不需要改代码只要会改配置就能维护宽度用例。我用 YAML 管理配置结构大概是这样的pages: - name: 登录页 url: https://example.com/login viewport: width: 1440 height: 900 elements: - name: 登录按钮 selector: #loginBtn expected: 120 tolerance: 2 - name: 用户名输入框 selector: #username expected: 240 tolerance: 2 - name: 侧边栏 selector: .sidebar expected: 240 tolerance: 0 - name: 首页 url: https://example.com/ viewport: width: 1440 height: 900 elements: - name: 导航栏 selector: .header-nav expected: 1200 tolerance: 4expected是预期宽度tolerance是允许误差。为什么tolerance不能用同一个值因为大元素比如 1200px 宽的导航栏边框、内边距像素级误差对视觉影响很小而小元素比如 24px 的图标差 2px 就很明显。所以配置里把容差拆开小尺寸元素给紧一点的容差大尺寸元素给松一点的容差。读取配置很简单用 Python 的yaml库import yaml with open(width_config.yaml, r, encodingutf-8) as f: config yaml.safe_load(f) pages config[pages]3.2 采集宽度数据的具体实现配置有了接下来是核心采集代码。我用 Playwright 的同步 API先按配置创建浏览器和页面再用bounding_box()采集每个元素的宽度。from playwright.sync_api import sync_playwright def collect_page_widths(page, elements): results [] for item in elements: name item[name] selector item[selector] locator page.locator(selector) if locator.count() 0: results.append({ name: name, selector: selector, actual: None, error: 元素不存在, }) continue locator.first.scroll_into_view_if_needed() box locator.first.bounding_box() if box is None: results.append({ name: name, selector: selector, actual: None, error: 元素不可见, }) continue actual round(box[width], 2) results.append({ name: name, selector: selector, actual: actual, expected: item[expected], tolerance: item[tolerance], }) return results这个函数有几个细节要注意。第一locator.count()检查元素是否存在避免定位不到元素导致直接报异常这样一条数据失败不会让整轮巡检中断。第二scroll_into_view_if_needed()把元素滚动进视口解决懒加载导致元素不在视口内、宽度采集不到的问题。第三bounding_box()返回None表示元素不可见或者没有渲染这种情况下不能当 0 处理因为 0 和不可见是两码事。主流程控制多个页面、多个视口的巡检with sync_playwright() as p: browser p.chromium.launch(headlessTrue) all_results [] for page_conf in config[pages]: context browser.new_context( viewport{ width: page_conf[viewport][width], height: page_conf[viewport][height], } ) page context.new_page() page.goto(page_conf[url], wait_untildomcontentloaded) page.wait_for_load_state(networkidle) page_results collect_page_widths(page, page_conf[elements]) for r in page_results: r[page] page_conf[name] r[viewport] page_conf[viewport][width] all_results.extend(page_results) context.close() browser.close()这里我用了domcontentloaded再加networkidle的组合。为什么不用默认的load事件因为有些页面有埋点、统计脚本一直挂起等load会超时。而networkidle等的是网络空闲页面关键资源一般已经加载完宽度数据也就稳定了。3.3 生成带汇总统计的 HTML 对比报告采集完数据只是第一步宽度对比给谁看很重要。如果只打印在控制台里测试同事看不了前端同事也不想看。我直接生成一个 HTML 报告表格里列出页面、元素、预期宽度、实际宽度、误差、状态顶部带通过率统计打开浏览器就能看。对比逻辑很简单实际宽度和预期宽度的绝对差值小于等于容差就判定为通过。但这里有一个经验对于宽度超过 500px 的元素绝对像素容差往往不够合理应该引入百分比容差。我实际项目中两种都支持def check_width(result): actual result[actual] expected result[expected] tolerance result[tolerance] if actual is None: result[status] FAIL result[diff] None return diff abs(actual - expected) tolerance_value tolerance if isinstance(tolerance, str) and tolerance.endswith(%): ratio float(tolerance[:-1]) / 100 tolerance_value expected * ratio result[diff] round(diff, 2) result[status] PASS if diff tolerance_value else FAIL渲染 HTML 报告的核心部分def render_report(results, output_pathwidth_report.html): total len(results) failed sum(1 for r in results if r[status] FAIL) passed total - failed pass_rate round(passed / total * 100, 2) if total else 0 rows for r in results: color #e74c3c if r[status] FAIL else #27ae60 actual_text r[actual] if r[actual] is not None else r.get(error, N/A) diff_text r[diff] if r.get(diff) is not None else - rows f tr td{r[page]}/td td{r[name]}/td td{r.get(viewport, )}/td td{r[expected]}/td td{actual_text}/td td{diff_text}/td td stylecolor:{color};font-weight:bold{r[status]}/td /tr html fhtml headmeta charsetutf-8title宽度对比自动化报告/title/head body h1宽度对比自动化报告/h1 p总计 {total} 项通过 {passed} 项失败 {failed} 项通过率 {pass_rate}%/p table border1 cellspacing0 cellpadding8 trth页面/thth元素/thth视口/thth预期宽度/thth实际宽度/thth误差/thth状态/th/tr {rows} /table /body/html with open(output_path, w, encodingutf-8) as f: f.write(html)报告里除了结果表格我还会在页面顶部加一个失败项明细列表这样打开报告第一眼就能看到哪些宽度不对不用在一大张表格里找红字。这套报告我们团队一直在用前端按图索骥改样式测试不用重新人肉量效率提升非常明显。4. 实战踩坑这些宽度数据为什么总是不对4.1 元素没加载完采集到 0 或者半截宽度用宽度对比脚本跑第一次全站巡检时我遇到了大量元素宽度为 0 的失败项。排查下来基本都是同一个原因页面进入时元素还没渲染完脚本就已经开始抓宽度了。特别是图片懒加载、组件异步渲染、Tab 默认隐藏内容这些场景元素要么还没出现在 DOM 里要么已经出现在 DOM 里但bounding_box()返回 0。解决办法分两层。第一层是等待策略在采集之前统一调用locator.wait_for(statevisible)确保元素可见。第二层是处理懒加载如果元素在视口外就先用scroll_into_view_if_needed()滚动到可视区域再等。这两层都做了之后宽度为 0 的失败项基本消失。还有一类特殊情况是页面用 JavaScript 动态渲染domcontentloaded和networkidle都满足后数据还在异步返回。这种场景我给每个页面配置了一个extra_wait字段单位毫秒在采集前额外休眠一下。虽然不够优雅但稳定性优先。4.2 动画和字体加载让宽度一直跳动跑了几轮之后我发现有个轮播图的宽度对比时好时坏。一开始以为是 Playwright 的bounding_box()不稳定后来排查发现是轮播动画还没结束元素宽度从一个值过渡到另一个值脚本正好在过渡中间抓了数据。处理思路是等待动画结束但对每个元素都写等待逻辑太麻烦我在采集函数里加了一个通用处理对目标元素先判断它是否在动画中如果在动画中就轮询获取宽度直到连续两次获取的值一致才算稳定。具体用 Playwright 的wait_for_functionpage.wait_for_function( (selector) { const el document.querySelector(selector); if (!el) return false; const rect1 el.getBoundingClientRect(); return new Promise(resolve { setTimeout(() { const rect2 el.getBoundingClientRect(); resolve(Math.abs(rect1.width - rect2.width) 0.5); }, 100); }); }, argselector )这段代码通过两次间隔 100 毫秒的采样来判断宽度是否稳定如果是稳定的就直接通过不用额外等待。字体加载导致的宽度跳动也是类似原理页面里如果有自定义字体字体加载完成前后文字渲染宽度会变化进而影响按钮、导航这类“撑开”型元素的宽度。稳妥做法是在采集前等待字体加载完毕page.evaluate(document.fonts.ready.then(() true))4.3 transform 缩放、iframe 和 Shadow DOM 的干扰宽度对比里最隐蔽的坑是 CSStransform。有个页面的弹窗打开时用scale(0.8)做缩放动画动画结束后回到scale(1)。我在动画执行中抓宽度用bounding_box()拿到的是原始宽度但用户肉眼看到的是缩放过后的宽度两者对不上。这里的关键是分清需求校验设计稿标注的宽度用bounding_box()校验视觉呈现宽度用getBoundingClientRect()后者返回的是 transform 之后的实际边界框。iframe 也是高频坑。宽度对比的目标元素如果嵌在 iframe 里普通的选择器定位不到需要用 Playwright 的frame_locatorframe page.frame_locator(#main-frame) element frame.locator(.target-element) box element.bounding_box()Shadow DOM 里边的元素更麻烦一些Playwright 的 CSS 选择器默认能穿透开放的 Shadow DOM但如果是闭合的需要先获取 shadow host 再进入内部。我当时的处理思路是把这类场景单独标记在配置里加一个container字段指定父级容器采集时先定位容器再做内部查找。4.4 响应式布局下的多视口宽度对比宽度对比最怕的其实是响应式布局。同一个元素在不同视口下的宽度是动态的页面上 1200px 宽的侧边栏在手机端可能变成了顶部抽屉宽度直接是 0。如果配置里机械地写死一个预期值巡检必然会误报。我最终的方案是支持“区间断言”。配置里除了expected还可以配置min_width和max_width断言时只要实际宽度落在区间内就算通过。这样移动端的导航宽度可以设置为“不小于视口宽度”内容容器的宽度可以设置为“不小于 320px 且不超过视口宽度”既灵活又不失严谨。- name: 移动端导航栏 selector: .mobile-nav min_width: 320 max_width: 375还有一个我强烈建议的做法不要只在一个视口下做宽度对比至少桌面 1440、笔记本 1280、移动端 375 三个视口都要跑。响应式布局的问题大部分只有在视口切换时才暴露出来单视口对比覆盖不了。5. 从宽度对比向外扩展的自动化玩法5.1 图片素材尺寸与导出图片宽高校验宽度对比的思路不只是用在网页 DOM 元素上图片素材的尺寸校验也是一个高频场景。运营经常上传活动 banner、商品主图要求宽度必须达到某个值否则会被前端拉伸变形。自动化脚本可以打开页面后直接取图片元素的实际渲染宽度来对比但更可靠的是直接校验图片源文件的真实尺寸用 Python 的 Pillow 库from PIL import Image def check_image_size(path, expected_width, expected_height, tolerance2): with Image.open(path) as img: width, height img.size diff_w abs(width - expected_width) diff_h abs(height - expected_height) ok diff_w tolerance and diff_h tolerance return { path: path, actual_width: width, actual_height: height, expected_width: expected_width, expected_height: expected_height, status: PASS if ok else FAIL, }这个函数可以批量扫描一个目录下的所有图片自动校验尺寸是否合规。比如广告位要求 750x300商品图要求 800x800批量跑一遍几十张图几秒钟就出结果比手动一张张右键看属性强太多。这套逻辑还能用在导出功能测试上导出的图片、PDF 文件尺寸是否符合预期同样可以自动化断言。5.2 接口返回字段宽度长度校验网页元素宽度对应的是 UI 层接口层其实也有“宽度”的概念就是字段长度。很多接口对字符串字段有长度限制比如订单号必须 20 位、用户名最大 30 个字符、备注最长 200 字。人工校验容易遗漏我把它也纳入了自动化巡检范围。效果是接口自动化测试里每个返回字段自动校验长度上下限超出范围直接断言失败。用 pytest 加 requests 写一个最简的长度校验用例import requests def test_response_field_width(): resp requests.get(https://example.com/api/order/detail, timeout5) data resp.json() order_no data[data][order_no] assert len(order_no) 20, f订单号长度异常: {len(order_no)} assert len(data[data][remark]) 200, 备注超长字段长度的校验本质上也是一种宽度断言只是“宽度”的度量单位从像素变成了字符数。这类接口断言我之前遇到过一个极端问题接口返回的某个字段长度超过 200 字符数据库字段定义是 varchar(128)数据写入时报错但接口测试一直没覆盖到上线后才暴露。把它自动化之后这类问题在开发阶段就能拦住。5.3 接入 Jenkins让宽度对比成为发版前的自动关卡宽度对比脚本跑得再快如果只是本地手动执行价值会打折扣。我把它接进了 Jenkins作为 UI 自动化测试套件的一部分每次前端项目构建完成、部署到测试环境后自动触发。配置方式很简单Jenkins 里新建一个 Pipeline 任务里面加一个 stagestage(UI Width Check) { steps { sh python3 -m venv venv sh venv/bin/pip install -r requirements.txt sh venv/bin/python run_width_check.py --config width_config.yaml --report width_report.html publishHTML([ allowMissing: false, alwaysLinkToLastBuild: true, keepAll: true, reportDir: ., reportFiles: width_report.html, reportName: 宽度对比报告 ]) } }加这一步的意义在于宽度对比从“被动发现问题”变成了“主动拦截问题”。之前是测试改版后想起来才去量一量现在是每次发版构建完自动跑一遍有宽度超标的元素直接亮红灯开发在提测前就能自己看报告。实测下来宽度回归导致的线上问题少了一大半因为问题在测试环境就已经被脚本揪出来了。还有个扩展思路值得提一句宽度对比这种“量尺寸、对公差”的自动化做法在非标自动化设备领域也很常见比如用工业相机拍摄产品自动测量宽度是否在公差范围内。原理和网页宽度对比一样只是采集端从 DOM 变成了视觉识别。6. 个人实际操作中的几点体会做了这套宽度对比自动化之后我最大的体会是越是看起来琐碎、简单、不值一提的检查越值得自动化。宽度对比不像复杂业务逻辑那么有技术含量但它是前端回归里最容易被漏掉、又最容易出问题的一环。自动化之后它变得可复现、可追踪、可量化每次跑完都有报告失败了能定位到具体页面具体元素历史记录一拉就知道是哪个版本开始偏离设计稿。这个价值是人工“偶尔抽查”永远给不了的。还有一点经验是起步心态。很多团队想做 UI 自动化一上来就奔着“全流程自动跑通”去结果被各种复杂交互、环境问题劝退。我的建议是从宽度对比这类维度单一、结果清晰的检查做起配置化地维护起来先让团队看到自动化巡检的价值再逐步往视觉回归、接口断言、CI 集成这些方向扩展。自动化是个逐步叠加的过程宽度对比是我觉得最值得先做的那块基石。
分享:

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

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