“宽度对比(自动化)”这个需求,最初不是谁拍脑袋想出来的。当时我们后台管理系统做了一次大改版,设计稿里所有按钮、输入框、侧边栏的宽度都标得清清楚楚,但前端实现完后,测试同学只能打开浏览器开发者工具,一个一个 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", encoding="utf-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(headless=True) 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_until="domcontentloaded") 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_path="width_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 style="color:{color};font-weight:bold">{r['status']}</td> </tr>""" html = f"""<html> <head><meta charset="utf-8"><title>宽度对比自动化报告</title></head> <body> <h1>宽度对比自动化报告</h1> <p>总计 {total} 项,通过 {passed} 项,失败 {failed} 项,通过率 {pass_rate}%</p> <table border="1" cellspacing="0" cellpadding="8"> <tr><th>页面</th><th>元素</th><th>视口</th><th>预期宽度</th><th>实际宽度</th><th>误差</th><th>状态</th></tr> {rows} </table> </body></html>""" with open(output_path, "w", encoding="utf-8") as f: f.write(html)报告里除了结果表格,我还会在页面顶部加一个失败项明细列表,这样打开报告第一眼就能看到哪些宽度不对,不用在一大张表格里找红字。这套报告我们团队一直在用,前端按图索骥改样式,测试不用重新人肉量,效率提升非常明显。
4. 实战踩坑:这些宽度数据为什么总是不对
4.1 元素没加载完,采集到 0 或者半截宽度
用宽度对比脚本跑第一次全站巡检时,我遇到了大量元素宽度为 0 的失败项。排查下来基本都是同一个原因:页面进入时元素还没渲染完,脚本就已经开始抓宽度了。特别是图片懒加载、组件异步渲染、Tab 默认隐藏内容这些场景,元素要么还没出现在 DOM 里,要么已经出现在 DOM 里但bounding_box()返回 0。
解决办法分两层。第一层是等待策略,在采集之前统一调用locator.wait_for(state="visible"),确保元素可见。第二层是处理懒加载,如果元素在视口外,就先用scroll_into_view_if_needed()滚动到可视区域再等。这两层都做了之后,宽度为 0 的失败项基本消失。
还有一类特殊情况是页面用 JavaScript 动态渲染,domcontentloaded和networkidle都满足后,数据还在异步返回。这种场景我给每个页面配置了一个extra_wait字段,单位毫秒,在采集前额外休眠一下。虽然不够优雅,但稳定性优先。
4.2 动画和字体加载让宽度一直跳动
跑了几轮之后,我发现有个轮播图的宽度对比时好时坏。一开始以为是 Playwright 的bounding_box()不稳定,后来排查发现是轮播动画还没结束,元素宽度从一个值过渡到另一个值,脚本正好在过渡中间抓了数据。
处理思路是等待动画结束,但对每个元素都写等待逻辑太麻烦,我在采集函数里加了一个通用处理:对目标元素先判断它是否在动画中,如果在动画中,就轮询获取宽度,直到连续两次获取的值一致才算稳定。具体用 Playwright 的wait_for_function:
page.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); }); }""", arg=selector )这段代码通过两次间隔 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_locator:
frame = 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, tolerance=2): 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", timeout=5) 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 任务,里面加一个 stage:
stage('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 集成这些方向扩展。自动化是个逐步叠加的过程,宽度对比是我觉得最值得先做的那块基石。