用 Playwright 给纯前端页面做实测:控制台、DOM 数字与截图
纯前端页面有个尴尬之处:没有 API 可以请求,没有数据库可以查,逻辑全在画布和 DOM 里。测试很容易退化成「打开浏览器看一眼,没崩就行」。
看一眼不是测试。它不可重复,不能进 CI,也说不清「上次看的时候是什么样」。但只要把「看一眼」拆成固定步骤——启动、操作、读数、截图、收错误——它就能变成一个每次都能跑的脚本。
这篇用一个几何演示页做例子。页面有四个页签(凸包、三角剖分、Voronoi、自检),画布可以拖点,点集可以重新生成。脚本要做的是:每个页签都打开、截一张图、把页面上的关键数字读出来、在画布上做一次鼠标拖动、再跑一次 200 点的压力场景。
启动浏览器:先借系统的,再回退
Playwright 自带浏览器,但下载 Chromium 在一些环境里并不顺利。更省事的做法是优先用系统已经装好的 Edge 或 Chrome:
import sys
def launch_browser(pw):
channels = ["msedge", "chrome"] if sys.platform == "win32" else ["chrome"]
extra = [] if sys.platform == "win32" else ["--no-sandbox"]
for channel in channels:
try:
return pw.chromium.launch(headless=True, channel=channel, args=extra)
except Exception:
pass
return pw.chromium.launch(headless=True, args=extra)
这段逻辑有三层:
- Windows 上先试 Edge,再试 Chrome;其他平台只试 Chrome;
- 每个
channel都是一次真实的启动尝试,失败就换下一个,不把「系统里一定有某个浏览器」当成前提; - 全部失败才回退到 Playwright 自带的 Chromium。
--no-sandbox 只加在非 Windows 上。容器里跑 root 用户时,Chromium 的沙箱常常起不来;本机 Windows 不需要这个参数,加了反而多一层风险。
启动时还固定了两件事:headless=True(无头模式,不弹窗口,适合 CI 和远程机器),以及视口 1500×1000。视口不是 launch 的参数,而是在创建页面或上下文时用 viewport 指定。固定视口不是为了好看,是为了让截图和 DOM 断言有稳定的布局;视口一变,同一个页面的换行、滚动、元素位置都可能变,截图就没法比较了。
收集控制台错误
页面出错不一定表现为白屏。一个未捕获的异常可能只让某个页签停止更新,控制台里躺着一行红字,肉眼看不出来。所以脚本一打开页面就挂两个监听:
errors = []
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
page.on("pageerror", lambda e: errors.append(str(e)))
两个监听分工不同:
console事件覆盖console.error,包括库内部打的错误;pageerror覆盖未捕获的异常,这类错误往往连console.error都不会走。
只监听其中一个会漏。比如 requestAnimationFrame 回调里抛出的异常,通常进 pageerror;而代码里主动 console.error 的分支,不会进 pageerror。
收集之后还要断言它为空。这个脚本目前只是把 errors 打进 JSON,没有让它决定退出码——也就是说,控制台报错时脚本仍然算「跑完」。这是它和真正测试之间最大的一步差距,后面会单独说。
DOM 数字比截图可靠
截图能证明「页面画出来了」,但不能证明「画对了」。一张三角网的截图,人眼很难看出面积和是不是等于凸包面积;换一台机器、换一个字体,像素还会变。
更稳的判据是页面自己算出来的数字。这个演示页把统计放在 #stats 里,是标准的 dt/dd 结构(#stats、#sum-pass 这类写法就是 CSS 的 id 选择器;这里 page 是启动后创建的页面对象):
def stats():
keys = page.eval_on_selector_all("#stats dt", "els => els.map(e => e.textContent)")
vals = page.eval_on_selector_all("#stats dd", "els => els.map(e => e.textContent)")
return dict(zip(keys, vals))
切到三角剖分页读一次,就得到「三角形数、最小角、面积和、与凸包面积差、空圆性违规、边数」这些值;凸包页和 Voronoi 页各有自己的一组键。它们是文本,不是像素,可以直接比较、可以直接断言。
自检页更直接:页面会把每条判据的 PASS/FAIL 渲染成列表,脚本读 #sum-pass、#sum-fail 和每一行的标签、说明、结果。这样「14 项全过」不是靠看图数出来的,而是从 DOM 里读出来的。
这里有个取舍:DOM 断言依赖页面的结构(类名、id、层级),页面一改就可能失效。但它失效的方式是明确的——eval_on_selector_all 匹配不到元素时返回空数组,脚本本身不会报错,所以要显式校验读到的键数和行数;而截图失效的方式是隐蔽的——图还是能截,只是没人看得出哪里不对。
截图是证据,不是判据
脚本仍然截图,每个页签一张,外加外接圆、一次画布点击、200 点两张(剖分、自检)、三个预设各一张,合计 11 张。原因不是用截图做判断,而是留证据:
- 断言失败时,能回看当时页面长什么样;
- 布局类的问题(元素重叠、画布空白、文字截断)没法用数字断言,只能看图。
截图用 full_page=True 只给自检页,因为自检页比视口长;其他页签用默认视口截图,避免整页拼接带来的空白。
文件名按「序号 + 场景」组织,而不是按时间戳。固定名字的好处是每次跑完直接覆盖,目录不会膨胀;坏处是历史版本留不下来。需要留历史时,再在文件名里加版本号或日期,不要让「最新一张」和「某天那一张」混在一起。
交互怎么测
脚本里有一段鼠标操作,看起来像拖拽:
box = page.query_selector("#cv").bounding_box()
page.mouse.move(box["x"] + 300, box["y"] + 300)
page.mouse.down()
page.mouse.move(box["x"] + 360, box["y"] + 340, steps=6)
page.mouse.up()
但按当时的点集,落点附近并没有点:画布坐标换算成模型坐标后,离最近的点还有一百多个单位,拾取半径只有 14 个 CSS 像素。pointerdown 走的是「空白处加点」分支,dragIndex 始终是 -1,后面的 pointermove 直接返回——这段实际测的是加点,不是拖拽。
steps=6 本身是对的:它让移动分成多步,模拟真实拖拽产生的多次 pointermove。但前提是按下时真的命中了点;没命中时,steps 再多也不会触发拖动重算。
要真的拖一个点,得先知道某个点的屏幕位置。两种办法:
- 用确定性预设(网格、圆)算出某个点的模型坐标,再按边距和缩放换算成画布坐标;
- 在页面里暴露一个只读的调试接口,读出点集,选一个点再换算。
拿到屏幕坐标后,把鼠标移到点的位置,按下,再移到目标位置,抬起。这时才是在测拖拽。
即使真的拖到了点,现在也只截了一张图,没有断言位置。要变成测试,拖拽前后各读一次 #stats,断言某个数字变了;或者读调试接口,断言目标点的新坐标。前者只能证明「有变化」,后者才能证明「变对了」。
200 点压力场景
压力场景的做法不复杂:把滑块的值设成 200,派发一次 input 事件让页面更新计数,再点「生成」:
page.evaluate("""() => {
const r = document.querySelector('#rng-count');
r.value = '200';
r.dispatchEvent(new Event('input', { bubbles: true }));
}""")
page.click("#btn-gen")
page.wait_for_timeout(600)
随后切到三角剖分和自检页签各截一张图。为什么用 input 事件而不是直接改 value:页面监听的是 input,只改属性不会触发重算;bubbles: true 让事件能冒泡到页面级的监听器。
这个场景目前只留下截图,没有断言。要让它有意义,至少该在自检页断言「通过数等于总数」——200 个点更容易踩到退化路径,如果自检仍然全过,说明压力下没有产生非法三角形。耗时不适合写成硬断言:同一段代码在不同机器上能差好几倍,把毫秒数写死,换台机器就会红。
固定等待是权宜
脚本里到处是 wait_for_timeout(400)、(600)、(1500)。这不是好习惯,但在演示页上很实用:页面没有接口请求,重算同步完成,等一个固定时长足够稳定。
更稳的写法是等条件,而不是等时间:
- 等自检结果出现:
page.wait_for_selector(".check")(这些行是切到自检页后才渲染的); - 等某个值出现:
page.wait_for_function("document.querySelector('#sum-pass')?.textContent === '14'"); - 等两帧画完:
page.evaluate("new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)))")。
固定等待的问题是它把「机器快慢」写进了脚本:本机 400 毫秒够,CI 上可能不够;反过来,等 1.5 秒又让每次运行都变慢。条件等待没有这个问题,代价是要想清楚「什么条件才算完成」。
对画布类页面,「两帧」通常比「某个 DOM 出现」更可靠:DOM 更新了,不代表 canvas 已经重绘。这个脚本还没有用这一招,属于可以改进的地方。
让脚本能失败
一个只打印 JSON 的脚本,是「采集器」,不是「测试」。要让它进 CI,至少要补三件事:
- 断言控制台错误为空:
assert errors == [],并在失败时把错误列表打出来; - 断言关键数字:每个页签的统计至少有一条不变量,例如「与凸包面积差」为 0、空圆性违规为 0、自检通过数等于总数。注意页面上的数字是格式化过的文本:面积带千分位、保留两位小数,面积差单独显示 6 位小数。直接比字符串会把
0.000000和0混为一谈;要解析成浮点,并且让断言容差和显示精度对齐——比如要求面积差解析后不超过1e-6,而不是拿两个两位小数的面积去算相对误差; - 用退出码表达结果:断言失败时
sys.exit(1),让 CI 能红。
这三件事都不难,难的是选对不变量。断言「三角形数等于 35」很脆——换个种子就变了;断言「面积和等于凸包面积」「违规数为 0」则稳定得多,因为它们是由几何关系决定的,不是由某一次随机结果决定的。
一套可以复用的顺序
如果下次还要给另一个纯前端页面写实测,我会按这个顺序:
- 先让脚本能启动浏览器、打开页面、收集控制台错误;
- 找到页面上最稳定的几个数字,做 DOM 断言;
- 再补截图,作为失败时的证据;
- 最后加交互和压力场景,每个场景至少配一条断言。
先有「能失败」的判据,再有截图和交互。反过来做,很容易得到一堆好看的图,却说不清页面到底对不对。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)