系列收尾篇,讲一个不太性感但救过命的话题:给游戏写自动化测试。
动机来自一次真实崩溃。坦克大战的子弹互消逻辑写了一行 bullets.splice(Math.max(o, b))——熟悉 JavaScript 的朋友可能已经笑了:splice 只传一个参数时是”从这个下标删到数组末尾”,不是”删一个”。于是每一次敌我子弹相向抵消,数组被整段清空,下一轮循环读到 undefined 抛出异常——而游戏循环的最后一行才是”预约下一帧”,异常一抛,预约中断,整个机台画面永久冻结。这种路径手测根本扫不全:你得恰好让两颗子弹在正确的相位相遇。能扫全的只有机器。
一、游戏测试的三层金字塔
街机厅的测试分三层,从便宜到昂贵:
| 层级 | 测什么 | 怎么测 |
|---|---|---|
| 单元测试 | 纯逻辑函数(牌型判定、状态转移表) | 直接 require 进 Node 断言 |
| 无头冒烟 | 整局游戏跑几千上万帧不崩溃 | 伪造 ctx,在 Node 里跑 |
| 遥测 bot | 玩法行为正确(能赢、能进商店、能通关) | 从绘制记录反推状态,让 bot 精确操作 |
二、第一层:把逻辑做成”可测试的形状”
测试的第一课不是写测试,而是把代码写成容易测的形状。小丑杯的牌型判定函数被刻意放在文件顶层、不碰任何全局状态:
// games/jokerbrew.js —— 顶层纯函数:输入牌,输出牌型
function jokerbrewDetect(cards) {
// ... 同花顺/葫芦/顺子等 9 种判定,只依赖入参
}
// 供 Node 单元测试(浏览器环境无 module,自动跳过)
if (typeof module !== 'undefined' && module.exports) {
module.exports = { jokerbrewDetect: jokerbrewDetect, /* ... */ };
}
于是 Node 里可以直接 require 这个文件(不需要浏览器、不需要画布)做断言:
const M = require('./js/games/jokerbrew.js');
function c(r, s) { return { r: r, s: s }; }
// A2345 是顺子(A 可作低牌)
console.assert(M.jokerbrewDetect([c(14,0), c(2,1), c(3,2), c(4,3), c(5,0)]).key === 'straight');
// 同花顺优先于普通顺子判定
console.assert(M.jokerbrewDetect([c(5,2), c(6,2), c(7,2), c(8,2), c(9,2)]).key === 'sf');
27 个这样的用例,把”A2345 算不算顺子”“葫芦和四条谁优先”这些规则钉死。之后每次改动跑一遍,规则被改坏会立刻尖叫。
纯函数 = 同样的输入永远得到同样的输出,且不碰外部状态。
输入牌组 → 输出牌型 就是典型的纯函数,它天然可测;而"读取键盘、改写全局分数、顺便播放音效"的函数几乎没法测。所以架构上我们主动把可测的逻辑挤压进纯函数(判定、计分、生成调度),把不可测的部分(渲染、输入)留在外壳——测试友好不是靠测试技巧,是靠设计。
三、第二层:无头冒烟——在 Node 里跑整局游戏
单测覆盖不了”整局游戏跑 60 帧会不会炸”。但 update/draw 依赖 canvas——Node 里没有。解法是桩(stub):造一个假的 ctx 对象,它把游戏发来的每一条绘图指令记录下来,别的什么都不做:
function makeCtx(rec) {
return new Proxy({}, {
get: function (t, k) {
if (k === 'canvas') return { width: 480, height: 360 };
if (k === 'fillRect') return function (x, y, w, h) { rec.rects.push([rec.style, x, y, w, h]); };
if (k === 'fillText') return function (txt, x, y) { rec.texts.push([String(txt), x, y]); };
return function () {}; // 其余方法一律空操作
},
set: function (t, k, v) { t[k] = v; rec.style = v; return true; }
});
}
Proxy 让任何属性访问都有响应——游戏完全察觉不到自己在”空气画布”上作画。于是冒烟测试在 Node 里长这样:
var g = games.barista.create(api); // 直接实例化游戏
for (var f = 0; f < 60000; f++) {
g.update(); g.draw(); // 无头跑 6 万帧 ≈ 16 分钟游戏时间
if (乱按) g.onKey(' ', true); // 同时乱按按键、乱点指针
}
// 断言:全程无异常;gameOver 恰好触发一次
战果清单(全部是冒烟先于人工发现的):坦克的 splice 崩溃、小丑牌”出杯时下标越界”(order.phases[phaseIdx] 读 undefined)、浓缩时序暂停按键泄漏、以及一个把三条调用链搅在一起的浮字数组命名不一致。
桩 = 用一个"假装是真的"的对象替换真实依赖,让被测代码在假环境里运行。JS 的
Proxy 可以拦截对象的读写,天然适合当桩:游戏调用 ctx.fillRect(...),我们截下来记到数组里,顺便让函数返回 undefined——反正 draw 的返回值没人看。这套思路就是"上一说的依赖注入"的回报:因为 ctx 是注入的,所以它可以被换掉。
四、第三层:遥测机器人——从绘制记录反推游戏状态
冒烟只能证明”不崩”,还要证明”玩法正确”:bot 得真的会玩。难点是游戏状态藏在闭包里,测试代码读不到。解法优雅得有点无赖:游戏既然把状态画在了画布上,就从绘制记录里把它读回来。
以浓缩时序为例,判定条的位置被 fillRect 画了出来,桩记下了每个矩形的坐标、宽高和填充色——bot 按”绘制签名”反查:
for (rect of rec.rects) {
if (y === 134 && w <= 3) needle = x + 1; // 2px 白条 = 针的位置
if (style 是半透明绿 && y === 144) { zoneC = x + w/2; zoneH = w/2; } // 绿区中心与半宽
}
// 遥测值就是这一帧的真实判定值:距核心 ±4px 内按下,必然 PERFECT
if (Math.abs(needle - zoneC) <= 4) g.onKey(' ', true);
这个”从画布遥测反推状态”的 bot 强到什么程度?它能走进小丑牌的商店、用对子+高牌踢脚策略打到 ANTE 5、每次都成功购买小丑——顺便在回归测试里发现了一个真实 bug(出杯动画期间数组下标越界)。断言也讲究策略:比起”通关了没”,“gameOver 恰好触发一次”“全程零异常”这类恒真式更稳定,不随难度波动而误报。
五、可视化:看一个遥测 bot 是怎么”作弊”的
下面是一个针在判定条上扫动的小游戏,右边的 bot 只做一件事:每一帧从”绘制遥测”里读出针和判定区的位置,距离核心 ±4px 内就按下。左侧是它看到的原始遥测记录——正式因为读的是同一份数据,它的每次按下都是确定性的 PERFECT:
真实冒烟测试里的 bot 与此同构,只是”遥测”来自 stub 记录的 fillRect 而不是实时画布,且运行 6 万帧、三个 bot 同时工作。
六、跟着复现:把无头冒烟跑起来
// smoke.js —— 与仓库里 /tmp 脚本同构的最小版本
global.ARCADE = { register: function (d) { games[d.id] = d; } };
var games = {};
// 1) 造桩 ctx(见第三节),W/H/panel/gameOver 全部给假实现
// 2) require 游戏文件,从注册表取回定义
// 3) var g = games['jokerbrew'].create(api);
// 4) for (f = 0; f < 50000; f++) { g.update(); g.draw(); 乱按(); }
// 5) 断言:无异常、gameOver 恰好 1 次
console.log('PASS');
复现的关键顺序:先注册表后游戏文件(游戏文件加载时就要调用 ARCADE.register)、桩要先于 require 就位。跑起来后你可以随手改坏一处逻辑(比如把判定半径 8 改成 80),看测试如何在下一秒尖叫。
七、小结:可测性是一种设计
- 纯函数外置:判定/计分/调度写成顶层纯函数,单测直接覆盖
- 依赖注入的回报:ctx 是注入的,所以可以被 Proxy 桩替换
- 绘制带语义:遥测 bot 靠”绘制签名”反推状态——渲染即日志
- 恒真式断言:优先断言”零异常、gameOver 恰好一次”,比”必须通关”稳定
- 测试不是质量部门的作业,是重构的勇气来源——本系列(七)~(十)篇里的每一次修复,都是先有测试兜底再动刀的
至此五篇讲完街机厅从骨架到质检的全过程。六台机器、一万多行代码、五个真实事故,全部源码就在仓库 js/arcade.js 与 js/games/ 里——最好的复现方式,是给它们加一台属于你的新机器。
本篇所属系列:博客开发指南 · 上一篇:(十)随机不等于坑 · 回到系列首篇:(一)项目架构与核心页面解析