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 };
}
关键在三点:
- 用
FakeTime,不用真实时钟。这样renderAt(90)永远推进 90 个固定步,与机器快慢无关。 - 输入是写死的:一直按右,每 40 步跳一次。没有随机,没有人工操作。
- 返回可断言的状态(分数与玩家高度),截图之外还多一层状态校验。
于是「确定性」从逻辑层一路贯穿到像素层:相同的 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 确定性回放与回归 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。