用 Playwright 给纯前端页面做实测:控制台、DOM 数字与截图

举报
岚枫 发表于 2026/11/04 07:47:00 2026/11/04
【摘要】 纯前端页面没有接口可测,最容易退化成「打开看一眼」。用 Playwright 把「看一眼」拆成可重复的步骤:启动浏览器、收集控制台错误、读 DOM 里的统计数字、按页签截图、跑一次 200 点压力。这篇讲这套脚本怎么搭,以及它现在还缺哪一步才算真正的测试。

纯前端页面有个尴尬之处:没有 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)

这段逻辑有三层:

  1. Windows 上先试 Edge,再试 Chrome;其他平台只试 Chrome;
  2. 每个 channel 都是一次真实的启动尝试,失败就换下一个,不把「系统里一定有某个浏览器」当成前提;
  3. 全部失败才回退到 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 再多也不会触发拖动重算。

要真的拖一个点,得先知道某个点的屏幕位置。两种办法:

  1. 用确定性预设(网格、圆)算出某个点的模型坐标,再按边距和缩放换算成画布坐标;
  2. 在页面里暴露一个只读的调试接口,读出点集,选一个点再换算。

拿到屏幕坐标后,把鼠标移到点的位置,按下,再移到目标位置,抬起。这时才是在测拖拽。

即使真的拖到了点,现在也只截了一张图,没有断言位置。要变成测试,拖拽前后各读一次 #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,至少要补三件事:

  1. 断言控制台错误为空:assert errors == [],并在失败时把错误列表打出来;
  2. 断言关键数字:每个页签的统计至少有一条不变量,例如「与凸包面积差」为 0、空圆性违规为 0、自检通过数等于总数。注意页面上的数字是格式化过的文本:面积带千分位、保留两位小数,面积差单独显示 6 位小数。直接比字符串会把 0.000000 和 0 混为一谈;要解析成浮点,并且让断言容差和显示精度对齐——比如要求面积差解析后不超过 1e-6,而不是拿两个两位小数的面积去算相对误差;
  3. 用退出码表达结果:断言失败时 sys.exit(1),让 CI 能红。

这三件事都不难,难的是选对不变量。断言「三角形数等于 35」很脆——换个种子就变了;断言「面积和等于凸包面积」「违规数为 0」则稳定得多,因为它们是由几何关系决定的,不是由某一次随机结果决定的。

一套可以复用的顺序

如果下次还要给另一个纯前端页面写实测,我会按这个顺序:

  1. 先让脚本能启动浏览器、打开页面、收集控制台错误;
  2. 找到页面上最稳定的几个数字,做 DOM 断言;
  3. 再补截图,作为失败时的证据;
  4. 最后加交互和压力场景,每个场景至少配一条断言。

先有「能失败」的判据,再有截图和交互。反过来做,很容易得到一堆好看的图,却说不清页面到底对不对。

(完)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。