《Cocos 游戏开发入门》16.1 纯逻辑单测:vitest 与假时钟

逻辑对不等于画面对,但逻辑错一定全盘皆错。本节用 FakeTime 假时钟与固定步长,把《方块冲刺》的核心逻辑变成可断言事实:1 秒恰好 59 步、死亡螺旋上限 5 步、重力落地停在 y=40、金币只吃一次,再用 RecordingRenderer 断言绘制序列,最后说明 Cocos 脚本为何难单测。

16.1 纯逻辑单测:vitest 与假时钟

到第 15 章为止,《方块冲刺》已经有了调试绘制、性能面板和控制台。但这些都是「人盯着看」的手段:你得手动跑起来、手动操作、用眼睛判断对不对。一旦代码改动,你很难保证昨天还对的跳跃今天还对不对。

这一节换一种更硬的验证方式——单元测试。目标不是「覆盖率数字好看」,而是把游戏里那些靠肉眼很难判断的逻辑,变成一行行可断言的事实。

本节给 mini2d 装上 vitest,并用 FakeTime 假时钟把「时间」这个最大的不确定源控制住。你会看到五条真实用例:1 秒恰好 59 步、死亡螺旋上限 5 步、重力落地停在 y=40、跳跃只生效一次、金币只吃一次。它们全部来自 src/__tests__/game.test.ts,可离线复现。

16.1.1 先让逻辑脱离浏览器

游戏代码难测的根源,是它天然和「浏览器、时间、渲染、输入」纠缠在一起。要让逻辑可测,第一步是把纯逻辑从这些外部依赖里切出来。

mini2d 的做法是把所有外部世界都抽象成接口:

外部依赖抽象测试里的替身
时间TimeSourceFakeTime
渲染RendererRecordingRenderer
输入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 截图 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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