《Cocos 游戏开发入门》16.2 渲染端到端测试:Playwright 截图

单测证明逻辑对,却证明不了画面对。本节把《方块冲刺》搬进无头浏览器:用 vite 打单文件 bundle,用确定性入口 renderAt(stepCount) 保证同一步数同一张图,讲清 waitUntil: 'networkidle' 为何必需,并用像素采样与页面错误断言补上端到端这一环。

16.2 渲染端到端测试:Playwright 截图

16.1 的单测证明了逻辑对,但它有一个盲区:逻辑对不等于画面对。RecordingRenderer 断言的是一串 DrawCommand,可如果 CanvasRenderer 把矩形画到了屏幕外、颜色写反、或者 beginFrame 忘了设置画布尺寸,单测全绿、玩家看到的却是一片黑。

要堵住这个盲区,必须让真实的浏览器真的画一帧,然后检查像素。这一节用 Playwright 做这件事。

本节把 mini2d 从「Node 里能测」推进到「浏览器里能测」。核心是一个确定性渲染入口 renderAt(stepCount):同一个步数必然得到同一张图,于是「截图比对」这件事本身变得确定。所有代码可在本机 vite build + 无头 Chromium 上复现。

16.2.1 端到端测试到底端到端在哪

先分清三类测试的边界:

层次输入断言对象工具
单元测试纯逻辑 + 假时钟模型状态 / 绘制指令vitest
端到端测试真实浏览器 + 真实 canvas像素Playwright
手工验证人 + 眼睛主观体验人

端到端测试的价值就在于它跨过了「假渲染器」这一层,让真正的 Canvas2D 参与进来。它验证的是整条链路:打包 → 载入 → 引擎初始化 → 逻辑推进 → 绘制 → 像素。任何一环断掉,测试都会红。

16.2.2 为什么不直接在 Node 里画 canvas

一个自然的想法是:既然 RecordingRenderer 是假的,那就换一个真的在 Node 里光栅化的实现,问题不就解决了?这条路在实践里走不通。

jsdom 提供了 document、window 等 DOM 骨架,但它不实现 Canvas2D 的光栅化——getContext('2d') 要么返回 null,要么返回一个所有绘制都无效果的桩。你在它上面 fillRect,再用 getImageData 读回来,得到的永远是全透明。也就是说,jsdom 只能测「调用有没有发生」,测不了「像素对不对」,那和 RecordingRenderer 没有本质区别。

另一条路是 node-canvas(canvas 包):它基于 Cairo 真的画,但需要本地编译原生模块,CI 上装起来很重,而且它的光栅化结果和浏览器并不逐像素一致——字体、抗锯齿、颜色空间都可能有细微差别。

结论很清楚:只有真实的浏览器才是可信的渲染参照。所以渲染 E2E 的底座必然是「无头浏览器 + 真 canvas」,而不是「Node 里假装有个 canvas」。这也正是 Playwright 存在的意义。

16.2.3 第一步:把代码打成浏览器能载入的 bundle

源码是 TS,浏览器不认。先用 vite 打成单文件产物:

import { defineConfig } from 'vite';

export default defineConfig({
  base: './',
  build: {
    outDir: 'dist',
    target: 'es2022',
    // 单文件产物,方便 Playwright 直接 file:// 载入
    assetsInlineLimit: 100_000_000,
    cssCodeSplit: false,
    codeSplitting: false,
  },
});

base: './' 让产物用相对路径,assetsInlineLimit 设得极大是为了把资源都内联进单个 JS,codeSplitting: false 禁掉代码分割。合起来的效果是:dist/ 里几乎只有一个 index.html 和一个 index.js,Playwright 无论是走 file:// 还是本地静态服务器都好载入。

target: 'es2022' 则保证产物语法和你写的 TS 基本一一对应,方便对照排查——E2E 出问题时,你看到的报错行号和源码不会差太远。

16.2.4 确定性渲染入口 renderAt(stepCount)

E2E 测试最大的敌人还是不确定性。截图要能比对,前提是同一份代码每次画出同一张图。mini2d 为此专门导出一个入口:

export function renderAt(stepCount: number): { score: number; playerY: number } {
  const time = new FakeTime();
  const game = new Game({ time });
  for (let i = 0; i < stepCount; i += 1) {
    game.input.right = true;
    game.input.jump = i % 40 === 0;
    game.step(game.clock.fixedStep);
  }
  game.render(renderer);
  return { score: game.score, playerY: game.player.y };
}

关键在三点:

  1. 用 FakeTime,不用真实时钟。这样 renderAt(90) 永远推进 90 个固定步,与机器快慢无关。
  2. 输入是写死的:一直按右,每 40 步跳一次。没有随机,没有人工操作。
  3. 返回可断言的状态(分数与玩家高度),截图之外还多一层状态校验。

于是「确定性」从逻辑层一路贯穿到像素层:相同的 stepCount,必然得到相同的画面。

浏览器端还要把这个入口挂到 window 上,Playwright 才能从页面里调它:

declare global {
  interface Window {
    mini2d: { renderAt: typeof renderAt; run: typeof run };
  }
}

window.mini2d = { renderAt, run };

// 默认直接画一张静态帧,页面打开即有内容。
renderAt(90);

renderAt 是给测试用的确定性入口,run 是给人肉打开页面看动画用的实时循环,两者共存互不干扰。页面加载时先 renderAt(90) 画一张静态帧,即使脚本没跑循环,打开也能看到画面。

16.2.5 无头浏览器载入与 waitUntil: ’networkidle'

接下来是 Playwright 脚本:

const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 960, height: 540 } });

const url = process.env.MINI2D_URL || 'http://127.0.0.1:8791/index.html';
await page.goto(url, { waitUntil: 'networkidle' });

const a = await page.evaluate(() => window.mini2d.renderAt(90));
await page.screenshot({ path: 'shot-90.png' });

waitUntil: 'networkidle' 这一句经常被当成可选项,其实是必需的。goto 默认在 load 事件就返回,但 load 只保证 HTML 与静态资源加载完,不保证模块脚本已经执行完。browser.ts 是 type="module" 的脚本,它要等模块图解析、执行到 window.mini2d = { renderAt, run } 那一行,renderAt 才存在。

如果不等 networkidle 就调 page.evaluate(() => window.mini2d.renderAt(90)),你会随机拿到 Cannot read properties of undefined——这类偶发失败是最难查的测试 bug。networkidle 等到网络静默,模块脚本通常已经执行完,入口就绪。

viewport 设成 960x540 是刻意的:canvas 在 HTML 里被 CSS 放大到 960×540 显示,而它的内部分辨率是 renderAt 里 Game 的 480×270。截图截的是 CSS 尺寸下的画面,所以采样坐标要按显示尺寸来算。

更严谨的写法是显式等待入口出现:await page.waitForFunction(() => Boolean(window.mini2d))。生产项目里建议两者都上,既等网络静默,也等具体条件成立。

16.2.6 同一 stepCount 必然同一张图

E2E 的核心断言其实很朴素——同一步数,两次渲染必须一模一样:

const a = await page.evaluate(() => window.mini2d.renderAt(90));
await page.screenshot({ path: 'shot-90.png' });
const b = await page.evaluate(() => window.mini2d.renderAt(90));
await page.screenshot({ path: 'shot-90b.png' });
const c = await page.evaluate(() => window.mini2d.renderAt(240));
await page.screenshot({ path: 'shot-240.png' });

console.log('deterministic:', JSON.stringify(a) === JSON.stringify(b));

a 与 b 是两次独立的 renderAt(90),各自新建 Game、各自新建 FakeTime,理论上互不影响。它们返回的状态对象如果不同,说明渲染或逻辑里藏了全局状态、时间依赖或随机数——这正是我们想抓的东西。renderAt(240) 则是另一个步数,用来确认画面确实随步数变化(否则「确定性」可能是「永远画同一张死图」的假象)。

把 shot-90.png 与 shot-90b.png 存成两份,还可以做像素级 diff:两份完全相同的图,diff 结果应当为零。一旦未来某次改动让 renderAt 变得不确定,diff 立刻非零。

16.2.7 像素采样断言

截图存下来之后,还要证明真的画了东西,而不是一片黑:

const sample = await page.evaluate(() => {
  const cv = document.getElementById('stage');
  const ctx = cv.getContext('2d');
  const at = (x, y) => Array.from(ctx.getImageData(x, y, 1, 1).data);
  return {
    background: at(2, 2),
    center: at(240, 135),
    topLeftText: at(16, 18),
  };
});

getImageData(x, y, 1, 1) 取一个像素的 RGBA。三个采样点各有用途:

采样点位置含义期望验证的能力
(2, 2)背景角落清屏色 rgba(24,28,38)clear 生效
(240, 135)画面中心前景色(玩家/金币)前景被画出来
(16, 18)左上角文字区亮色文字像素drawText 生效

像素级断言比「截图看起来对」硬得多:它把「视觉正确」翻译成了数字比较。真做视觉回归时会用「截图对截图」的像素 diff,但对《方块冲刺》这种纯色矩形游戏,采样几个点已经足够抓回归。要点是采样点要选在有语义的位置——背景角落、必经之路、文字区,而不是随便挑三个坐标。

16.2.8 页面错误也算断言

最后一层保险是把浏览器报错也当成失败:

const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
// ...跑完渲染后
console.log('page errors  :', errors.length === 0 ? '(none)' : errors);

pageerror 抓未捕获异常,console 的 error 级别抓显式报错。一个渲染没崩但控制台刷红的页面,视觉测试可能照样通过——只有把错误也断言进去,E2E 才是真的「整条链路健康」。脚本结尾还要 process.exit(1) 让 CI 能感知失败:

if (errors.length > 0) process.exit(1);
await browser.close();

16.2.9 把整条链路串成一次运行

单看脚本还不够,要把「构建 → 起服务 → 截图 → 判定」串成一条命令:

rtk proxy ./node_modules/.bin/vite build                      # 1. 打成单文件产物
python3 -m http.server 8791 --directory dist &                # 2. 起一个静态服务器
MINI2D_URL=http://127.0.0.1:8791/index.html node shot.cjs     # 3. 跑截图脚本

三步的顺序不能颠倒:vite build 产出 dist/,静态服务器把它挂到 8791 端口,脚本再从这里载入页面。MINI2D_URL 用环境变量传入,脚本默认值就是本机地址,本地跑不用传;CI 里换成临时端口也不会写死。

如果 CI 机器上装的是全局 Playwright(而不是项目依赖),脚本里可以用 createRequire 从全局路径取:

const { createRequire } = require('module');
const req = createRequire('/opt/homebrew/lib/node_modules/');
const { chromium } = req('playwright');

这样无论 Playwright 装在项目里还是全局,脚本都能找到它。

16.2.10 Cocos 对照:渲染测试的两难

在 Cocos 里做等价的像素测试要麻烦得多:引擎初始化、资源加载、场景激活都由 director 与 game 在内部驱动,你没法像 renderAt 那样「喂一个假时钟、推进 90 步、画一帧」。

能做的通常是退一步——在浏览器里跑真工程,用 director.getTotalFrames() 读帧数、用 sys.isBrowser 判断环境,再配合截图:

import { director, game, sys } from 'cc';

export function dumpRuntimeInfo(): { frames: number; browser: boolean; fps: string | number } {
  return {
    frames: director.getTotalFrames(),
    browser: sys.isBrowser,
    fps: game.frameRate,
  };
}

director.getTotalFrames() 返回已渲染帧数,game.frameRate 读写目标帧率,sys.isBrowser 判断是否运行在浏览器。它们都真实存在,但读到的都是「真实运行」的瞬时值——想让两次运行帧数完全一致,除非你的游戏本身是确定性的。

所以 Cocos 的渲染回归,务实做法是「固定设备、固定场景、跑 N 帧后截图,用容差比对」,而不是 mini2d 这种「喂固定步数、断言精确相等」。这是引擎替你接管了主循环之后必然付出的代价。

本节 Cocos 代码为 L2 片段,director.getTotalFrames、game.frameRate、sys.isBrowser 已在 cc.d.ts(Cocos Creator 3.8.8)中核对存在,不参与 mini2d 的测试。

单测锁住逻辑,E2E 锁住像素。但还有一类最棘手的东西没被覆盖——玩家报的「偶发 bug」。下一节用确定性回放,把「某次奇怪的输入序列」变成一条可重复的测试。

阅读导航:上一节:16.1 纯逻辑单测:vitest 与假时钟 · 下一节:16.3 确定性回放与回归 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画