16.1 纯逻辑单测:vitest 与假时钟
到第 15 章为止,《方块冲刺》已经有了调试绘制、性能面板和控制台。但这些都是「人盯着看」的手段:你得手动跑起来、手动操作、用眼睛判断对不对。一旦代码改动,你很难保证昨天还对的跳跃今天还对不对。
这一节换一种更硬的验证方式——单元测试。目标不是「覆盖率数字好看」,而是把游戏里那些靠肉眼很难判断的逻辑,变成一行行可断言的事实。
本节给
mini2d装上 vitest,并用FakeTime假时钟把「时间」这个最大的不确定源控制住。你会看到五条真实用例:1 秒恰好 59 步、死亡螺旋上限 5 步、重力落地停在 y=40、跳跃只生效一次、金币只吃一次。它们全部来自src/__tests__/game.test.ts,可离线复现。
16.1.1 先让逻辑脱离浏览器
游戏代码难测的根源,是它天然和「浏览器、时间、渲染、输入」纠缠在一起。要让逻辑可测,第一步是把纯逻辑从这些外部依赖里切出来。
mini2d 的做法是把所有外部世界都抽象成接口:
| 外部依赖 | 抽象 | 测试里的替身 |
|---|---|---|
| 时间 | TimeSource | FakeTime |
| 渲染 | Renderer | RecordingRenderer |
| 输入 | game.input 三个布尔位 | 测试直接赋值 |
| 尺寸 | GameOptions.width/height | 构造参数 |
于是 Game 只依赖 Clock 与 Renderer 两个接口,不碰 DOM、不碰 canvas、不碰 performance.now()。这样的类可以在 Node 里实例化,逐步推进,然后断言。
export interface TimeSource {
/** 单调递增的毫秒时间戳(可以是小数)。 */
now(): number;
}
时间源是一个只有一个方法的接口。真实环境注入 realTime,测试里注入 FakeTime。这就是全书反复出现的「假模型」思路——假时钟、假渲染器,都是为了让不确定性消失。
vitest 的配置只有几行,把测试范围限定在源码目录:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
environment: 'node',
},
});
environment: 'node' 是刻意的:核心逻辑测试根本不需要 DOM,用 node 环境跑得最快,也顺带证明「逻辑确实脱离了浏览器」。
16.1.2 FakeTime:测试里不需要真的等 16 毫秒
FakeTime 是时间源的替身:它不读系统时钟,而是让你显式推进:
export class FakeTime implements TimeSource {
private t: number;
constructor(startMs = 0) {
this.t = startMs;
}
now(): number {
return this.t;
}
/** 推进毫秒数。 */
advance(ms: number): void {
this.t += ms;
}
}
注意 advance:测试想模拟「过了一帧」,就 time.advance(16.67);想模拟「卡了 5 秒」,就 time.advance(5000)。真实世界里这要真的等 5 秒,测试里是瞬时的,而且完全确定——同样的推进序列,永远得到同样的结果。
这就是假时钟的全部价值:把「等」变成「算」。
16.1.3 用例一:1 秒恰好 59 步
先看最能体现「确定性」的一条:
const STEP_MS = (1 / 60) * 1000;
it('固定步长把 1 秒切成 60 步', () => {
const time = new FakeTime();
const game = new Game({ time });
let total = 0;
for (let i = 0; i < 60; i += 1) {
time.advance(STEP_MS);
total += game.frame();
}
// 第一帧只用来对齐时间基准,不产生步进;剩下 59 帧各推进 1 步。
expect(total).toBe(59);
});
测试名说「切成 60 步」,断言却是 59——这不是笔误,而是固定步长累加器的必然结果。第一帧调用 tick 时 lastMs 还是 null,它只做一件事:把时间基准对齐到当前时刻,不产生任何步进。之后 59 帧每帧累积一个 STEP_MS,各消耗一个固定步。
为什么是精确的 59 而不是「约等于」?因为 Clock 用整数微秒累加(见 4.2 节)。如果用浮点秒累加,16.666666666666668ms / 1000 会偶尔比 fixedStep 小一个最小精度单位,60 帧可能只跑出 58 步。整数微秒把这个误差彻底消掉,所以这条断言不需要容差。
16.1.4 用例二:死亡螺旋上限 5 步
固定步长最危险的失效模式是「死亡螺旋」:某帧卡了很久,累加器积了一大坨时间,于是这帧要补几百步;补步又更慢,下一帧积得更多……最终卡死。
Clock 的防线是 maxSubSteps:
it('单帧卡顿不会触发死亡螺旋:最多补 5 步', () => {
const time = new FakeTime();
const game = new Game({ time });
time.advance(STEP_MS);
game.frame();
time.advance(5000); // 卡了 5 秒
expect(game.frame()).toBe(5);
});
卡了 5 秒,按 1/60 步长本该补 300 步;但 tick 里那句 Math.min(elapsedUs, maxElapsedUs) 把单帧可消耗的时间截断在 fixedStepUs * maxSubSteps。多出来的时间被直接丢弃——宁可让世界「丢帧」,也不能让它卡死。这是所有实时游戏引擎的共同取舍。
16.1.5 用例三:重力落地,断言「模型看到了什么」
前两条测的是时间,这一条测物理:
it('重力让方块下落,落到地面后停住', () => {
const time = new FakeTime();
const game = new Game({ time });
game.player.y = 200;
game.player.onGround = false;
for (let i = 0; i < 240; i += 1) game.step(1 / 60);
expect(game.player.y).toBe(40);
expect(game.player.onGround).toBe(true);
expect(game.player.vy).toBe(0);
});
这里有个值得强调的方法论:断言的是模型状态,不是像素。我们不去截图看方块画在哪,而是直接问「player.y 是多少」。地面高度是 40,所以落地后 y === 40、onGround === true、vy === 0——三条断言精确描述了「落到地面并停住」这个事实。
这正是「断言模型看到了什么」:把游戏对象当成一份状态机,测试只关心它从一个状态迁移到另一个状态的结果。像素是这份状态的投影,测状态比测像素稳定得多。
16.1.6 用例四与五:输入被消费、金币只吃一次
输入和收集是最容易出「重复触发」bug 的地方:
it('输入意图被正确消费:跳一下只跳一次', () => {
const game = new Game();
game.input.jump = true;
game.step(1 / 60);
expect(game.player.onGround).toBe(false);
expect(game.player.vy).toBeGreaterThan(0);
const vyInAir = game.player.vy;
game.step(1 / 60);
expect(game.player.vy).toBeLessThan(vyInAir); // 空中再按无效
});
注意这里没推进假时钟——直接调 game.step(1/60),因为 step 是纯逻辑,dt 是显式参数。第一帧按跳,onGround 变 false、vy 变正;第二帧还在空中,即使 input.jump 仍为 true,vy 也只受重力影响而减小。「跳一下只跳一次」被写成了断言。
金币同理:
it('碰到金币就吃掉并计分,同一个不会重复计分', () => {
const game = new Game();
const coin = game.coins[0]!;
game.player.x = coin.x - 4;
game.player.y = coin.y - 4;
game.step(1 / 60);
expect(game.score).toBe(1);
game.step(1 / 60);
expect(game.score).toBe(1); // 不会重复计分
});
把玩家直接挪到金币旁,跑一步,分数 +1;再跑一步,分数仍是 1——因为 c.taken 一旦置 true,后面的步就会 continue 跳过它。这类「同一个物体被重复结算」是结算管线里最经典的一类 bug,而它现在被一条三行断言锁死了。
16.1.7 RecordingRenderer:把渲染也变成断言
逻辑测完了,渲染呢?mini2d 的答案是再给渲染层造一个假模型:
it('记录绘制序列:清屏 → 金币 → 玩家 → 分数', () => {
const game = new Game();
const r = new RecordingRenderer();
game.render(r);
const ops = r.lastFrame.map((c) => c.op);
expect(ops[0]).toBe('clear');
expect(ops.filter((o) => o === 'rect')).toHaveLength(4); // 3 枚金币 + 1 个玩家
expect(ops[ops.length - 1]).toBe('text');
expect(r.lastFrame[0]).toEqual({
op: 'clear',
color: { r: 24, g: 28, b: 38, a: 1 },
});
});
RecordingRenderer 不产生像素,只产生一串 DrawCommand。「画了几次、按什么顺序画、清屏用什么颜色、画布多大」全部变成可断言的值。连「吃掉的硬币不再被画出来」也能测——吃一枚后 rect 从 4 条变成 3 条。
这就是分层的好处:同一个 Renderer 接口,浏览器里是 Canvas2D,测试里是记录器。渲染逻辑不再需要开浏览器就能回归。
16.1.8 怎么跑这些测试
一条命令跑全部:
rtk proxy ./node_modules/.bin/vitest run
run 是「一次性跑完就退出」的模式;不加 run 的默认行为是 watch 模式,会常驻监听文件改动。本地开发用 watch 方便,CI 里必须用 run,否则进程永远不退出。想只跑一组用例,用 -t 按名称过滤:
rtk proxy ./node_modules/.bin/vitest run -t '重力'
写测试时还有一个习惯值得养成:先让它红,再让它绿。新加一条断言后先跑一次确认它确实会失败(说明它真的在测东西),再改代码让它通过。如果一条测试从头到尾都是绿的,你无法确认它到底有没有在验证你想验证的行为——这正是「1 秒恰好 59 步」那种精确数字断言的价值所在。
16.1.9 Cocos 对照:把可测的部分挤出来
Cocos Creator 3.8 的脚本继承自 Component,生命周期由引擎调度,天然绑定节点树与编辑器。想测其中的纯逻辑,做法和 mini2d 一样——把不依赖引擎的算法抽成普通函数或纯类:
import { _decorator, Component } from 'cc';
const { ccclass } = _decorator;
/** 纯函数:不依赖引擎,可在 vitest 里直接断言。 */
export function integrateGravity(y: number, vy: number, dt: number): { y: number; vy: number } {
const g = -1800;
const nextVy = vy + g * dt;
return { y: y + nextVy * dt, vy: nextVy };
}
@ccclass('PlayerController')
export class PlayerController extends Component {
update(dt: number): void {
const r = integrateGravity(this.node.position.y, 0, dt);
this.node.setPosition(this.node.position.x, r.y, 0);
}
}
要点:integrateGravity 里没有一行 cc 代码,所以它和 mini2d 的 step 一样可测;PlayerController.update(dt) 只负责把引擎状态喂进纯函数、再把结果写回节点。能测的挤进纯函数,测不了的留给引擎——这是 Cocos 工程里唯一可持续的测试策略。
本节 Cocos 代码为 L2 片段,
Component、_decorator.ccclass已在cc.d.ts(Cocos Creator 3.8.8)中核对存在,不参与mini2d的测试。update(dt)的调度时机由引擎的ComponentScheduler决定,详见 6.2 节。
逻辑层现在有了确定性单测。但它测不到一件事:这段逻辑在真浏览器里到底有没有画出正确的像素。下一节用 Playwright 把「渲染」也纳入回归。
阅读导航:上一节:目录 · 下一节:16.2 渲染端到端测试:Playwright 截图 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。