15.1 逻辑层单元测试

把《合成小岛》最易出错的合成规则收敛成可测的纯函数,用 Node 内置 node --test 写四类断言:边界值(空/上限/越界)、确定性(同 seed 同输入同输出)、异常路径(非法输入被拒绝而非静默通过)、属性不变量(价值守恒、占用单调不增),并讲清哪些该测、哪些不值得测。

站内已有一整个 测试专题 ——那个专题讲的是通用测试体系与方法论(含 TPaaS、流处理测试、AI 模型测试等方向),回答的是「一个工程该怎么搭测试金字塔、怎么选框架、怎么度量覆盖率」。本章讲的是小游戏这一具体形态下的三层测试:可播种的确定性逻辑单测、Playwright 端到端回放、真机兼容矩阵。那卷是「测试这件事的通用地图」,本章是「在一款合成小游戏里,这三层具体怎么落地、怎么被命令跑起来」。

前十四节把引擎该做的都做了:循环、渲染、输入、存档、UI、性能。这一节开始收口到质量。小游戏没有大团队的测试岗,很多项目干脆不写测试——等到线上出问题才发现「合成两个满级方块会崩」这种低级 bug。但测试也不是越多越好:写错方向的测试比不写更糟,因为它会拖慢重构、制造假安全感。这一节先讲清楚哪些逻辑值得测。

哪些该测,哪些不值得测

值得测的逻辑有三个特征:纯函数(输入必得输出,没有副作用)、规则复杂(分支多、边界多)、出错代价高(数值算错、存档写坏、玩家能直接感知)。合成规则完全符合:它就是一个「两个同等级方块 → 一个高一级方块」的纯函数,边界多(空槽、上限、越界),算错会让玩家攒了几小时的进度凭空消失。

反过来,不值得测的东西也有三个特征:只是转发(一个函数把参数原样传给另一个函数)、纯展示(一个函数只把数字拼成字符串)、实现细节(内部调了几次、用了哪个循环)。测它们等于在测语言本身,重构一次就假红一次,收益为负。小游戏的测试预算很薄,必须花在刀刃上。

把规则收敛成可测的纯函数

下面这段是 P 层(内核层)产物 core/src/e15/spec.ts:一块「合成棋盘」的最小模型。棋盘是若干槽位,每槽存一个等级(0 = 空);合法合成 = 两个同等级、非空、未到上限的槽合成高一级的一槽。它把「最易出错的规则」收敛成一组纯函数,全整数、可播种、无 Math.random(),既能被 Node 直接测,也能被浏览器原型内联。

// file: core/src/e15/spec.ts
// tier: P
// verify: node --test 'core/test/e15_*.test.ts'
//
// 《合成小岛》可测逻辑内核:一块「合成棋盘」的最小模型。棋盘是若干槽位,每槽存一个等级
// (0 = 空)。合法合成 = 两个同等级、非空、未到上限的槽合成高一级的一槽。
// 这个模块存在的唯一目的就是「被测试」:把最容易出错的规则(边界 / 异常 / 守恒)收敛成纯函数,
// 输入必得输出、无副作用、无隐藏状态。
// 全整数、可播种、无 Math.random();只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface SpecConfig {
  readonly boardSize: number; readonly maxLevel: number; readonly base: number;
  readonly seed: number; readonly stepMs: number;
  readonly rngMultiplier: number; readonly rngIncrement: number;
}
export type Board = number[];
export interface MergeResult { readonly ok: boolean; readonly reason: string; readonly board: Board }
export interface Op { readonly i: number; readonly j: number }
export interface OpSequence { readonly seed: number; readonly ops: Op[] }
export interface RunReport {
  readonly board: Board; readonly accepted: number; readonly rejected: number;
  readonly reasonCounts: Record<string, number>;
  readonly valueStart: number; readonly valueEnd: number;
  readonly occupiedStart: number; readonly occupiedEnd: number;
}

/** 整数地板除(对负数也向下取整,与 Python // 一致)。 */
export function ediv(a: number, b: number): number { return Math.floor(a / b); }

/** 价值表:value(0)=0,value(L)=base^(L-1),整数累乘,避开浮点幂。 */
export function valueTable(cfg: SpecConfig): number[] {
  const t: number[] = new Array<number>(cfg.maxLevel + 1);
  t[0] = 0; let v = 1;
  for (let l = 1; l <= cfg.maxLevel; l += 1) { t[l] = v; v *= cfg.base; }
  return t;
}

/** 合成两槽:合法才改;非法返回 ok=false,且不修改传入的棋盘。 */
export function mergeAt(board: Board, i: number, j: number, cfg: SpecConfig): MergeResult {
  const n = board.length;
  if (!Number.isInteger(i) || !Number.isInteger(j) || i < 0 || j < 0 || i >= n || j >= n) {
    return { ok: false, reason: "oob", board };
  }
  if (i === j) return { ok: false, reason: "same_slot", board };
  const a = board[i], b = board[j];
  if (a === 0 || b === 0) return { ok: false, reason: "empty", board };
  if (a !== b) return { ok: false, reason: "not_equal", board };
  if (a >= cfg.maxLevel) return { ok: false, reason: "max_level", board };
  const next = board.slice(); next[i] = a + 1; next[j] = 0;
  return { ok: true, reason: "ok", board: next };
}

/** 棋盘总价值(守恒量的定义)。 */
export function totalValue(board: Board, cfg: SpecConfig): number {
  const t = valueTable(cfg); let s = 0;
  for (const l of board) s += t[l];
  return s;
}

/** 非空槽数。 */
export function occupied(board: Board): number { let n = 0; for (const l of board) if (l > 0) n += 1; return n; }

/** 稳定摘要:一行、逐位可比(跨宿主 / 跨语言比对的锚点)。 */
export function digest(board: Board): string { return board.join(","); }

/** 整数 LCG:state 走一步。与 core/ 其它模块同一套乘子。 */
export function lcg(state: number, cfg: SpecConfig): number {
  return (Math.imul(state, cfg.rngMultiplier) + cfg.rngIncrement) >>> 0;
}

/** 从 seed 生成一串合成尝试。第二个索引取 [0, boardSize],故意偶尔越界,供异常路径测。 */
export function generateOps(seed: number, count: number, cfg: SpecConfig): OpSequence {
  let s = seed >>> 0; const ops: Op[] = [];
  for (let k = 0; k < count; k += 1) {
    s = lcg(s, cfg); const i = s % cfg.boardSize;
    s = lcg(s, cfg); const j = s % (cfg.boardSize + 1);
    ops.push({ i, j });
  }
  return { seed, ops };
}

/** 依次应用一串合成尝试,返回终局报告。合法才改,非法只计数。 */
export function applyOps(board: Board, seq: OpSequence, cfg: SpecConfig): RunReport {
  let cur = board.slice(); const reasonCounts: Record<string, number> = {};
  let accepted = 0, rejected = 0;
  const valueStart = totalValue(cur, cfg), occupiedStart = occupied(cur);
  for (const op of seq.ops) {
    const r = mergeAt(cur, op.i, op.j, cfg);
    reasonCounts[r.reason] = (reasonCounts[r.reason] ?? 0) + 1;
    if (r.ok) { cur = r.board; accepted += 1; } else rejected += 1;
  }
  return { board: cur, accepted, rejected, reasonCounts, valueStart, valueEnd: totalValue(cur, cfg),
           occupiedStart, occupiedEnd: occupied(cur) };
}

/** 初始棋盘:每个槽放一个 1 级(满盘)。确定性,不用随机。 */
export function initialBoard(cfg: SpecConfig): Board { return new Array<number>(cfg.boardSize).fill(1); }

这段内核的设计要点,全是为了「可测」:mergeAt 失败时返回原棋盘引用而不是就地改,这样测试能断言「失败调用没有副作用」;valueTable 用整数累乘而非 Math.pow,因为浮点幂在边界级会掉精度;generateOps 故意让第二个索引偶尔越界,好让异常路径被测到。

下面这段是它读的配置 config/e15_spec.json(D 层,纯参数,没有代码可跑):base=2 是整数底数,maxLevel=20 是等级上限,随机来自 seed + 整数 LCG 乘子。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "boardSize": 12,
  "maxLevel": 20,
  "base": 2,
  "stepMs": 50,
  "rngMultiplier": 1103515245,
  "rngIncrement": 12345,
  "note": "逻辑层单测的棋盘参数。价值底数 base=2 为整数,value(L)=base^(L-1) 用整数累乘,禁浮点幂。core/ 内禁用 Math.random(),随机一律由 seed + 整数 LCG 播种。"
}

四类断言

下面这段是 P 层测试 core/test/e15_spec.test.ts:九个用例分四类。边界值测空 / 上限 / 越界;确定性测同 seed 同输入必得同输出;异常路径测非法输入被拒绝而非静默通过;属性不变量测「总价值守恒」「占用槽数单调不增」这类无论输入怎么变都必须成立的量。最后一类最像「属性测试」——它不枚举具体输入,只断言一条永远为真的性质,随机生成再多指令也绕不过它。

// file: core/test/e15_spec.test.ts
// tier: P
// verify: node --test 'core/test/e15_*.test.ts'
//
// 逻辑层单元测试的四个类别,逐类给出「值得测」的样板:
//   ① 边界值:空 / 上限 / 越界;② 确定性:同 seed 同输入 → 同输出;
//   ③ 异常路径:非法输入被「拒绝」而非静默通过;④ 属性不变量:守恒量 + 单调量。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import {
  mergeAt, totalValue, occupied, digest, generateOps, applyOps, initialBoard, valueTable,
} from "../src/e15/spec.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e15_spec.json"), "utf8"));

// ------------------------------------------------------------------ ① 边界值
test("边界值:0 / 上限 / 越界都被规则挡住", () => {
  const b = [1, 1, 0, 2, cfg.maxLevel, cfg.maxLevel, 0];
  assert.equal(mergeAt(b, 0, 2, cfg).reason, "empty", "空槽不能合成");
  assert.equal(mergeAt(b, 0, 3, cfg).reason, "not_equal", "等级不同不能合成");
  assert.equal(mergeAt(b, 4, 5, cfg).reason, "max_level", "已到上限不能再合成");
  assert.equal(mergeAt(b, 0, 0, cfg).reason, "same_slot", "同一槽不能自合");
  assert.equal(mergeAt(b, -1, 1, cfg).reason, "oob", "负索引越界");
  assert.equal(mergeAt(b, 0, b.length, cfg).reason, "oob", "等于长度即越界");
  assert.equal(mergeAt(b, 0, 1, cfg).ok, true, "两个 1 级应能合成");
});

test("价值表:整数累乘,value(0)=0、value(L)=base^(L-1)", () => {
  const t = valueTable(cfg);
  assert.equal(t[0], 0); assert.equal(t[1], 1); assert.equal(t[2], 2); assert.equal(t[3], 4);
  assert.equal(t[cfg.maxLevel], 2 ** (cfg.maxLevel - 1), "上限级价值应等于整数 2^(maxLevel-1)");
  for (let l = 2; l <= cfg.maxLevel; l += 1) assert.equal(t[l], t[l - 1] * cfg.base, "逐级严格翻倍");
});

// ------------------------------------------------------------------ ② 确定性
test("确定性:同 seed 生成的指令序列逐条相同", () => {
  const a = generateOps(cfg.seed, 200, cfg), b = generateOps(cfg.seed, 200, cfg);
  assert.deepEqual(a.ops, b.ops, "同 seed 应生成同一串指令");
  assert.notDeepEqual(a.ops, generateOps(cfg.seed + 1, 200, cfg).ops, "换 seed 应生成不同指令");
});

test("确定性:同 seed 同输入 → 终局棋盘与报告逐位一致", () => {
  const seq = generateOps(cfg.seed, 300, cfg);
  const r1 = applyOps(initialBoard(cfg), seq, cfg), r2 = applyOps(initialBoard(cfg), seq, cfg);
  assert.equal(digest(r1.board), digest(r2.board), "两次回放棋盘应逐位相同");
  assert.equal(r1.accepted, r2.accepted); assert.equal(r1.rejected, r2.rejected);
  assert.deepEqual(r1.reasonCounts, r2.reasonCounts);
});

// ------------------------------------------------------------------ ③ 异常路径
test("异常路径:非法合成被拒绝,且不修改传入棋盘", () => {
  const before = [1, 1, 0, 2, cfg.maxLevel, cfg.maxLevel, 0], snapshot = before.slice();
  const r = mergeAt(before, 4, 5, cfg);
  assert.equal(r.ok, false, "上限合成应被拒绝");
  assert.equal(r.board, before, "失败时应原样返回同一棋盘引用");
  assert.deepEqual(before, snapshot, "失败调用不得就地修改输入棋盘");
});

test("异常路径:整串指令里非法项只计数、不生效", () => {
  const board = [1, 1, 1, 1, 0, 0];
  const seq = { seed: 0, ops: [
    { i: 0, j: 1 }, { i: 0, j: 1 }, { i: 9, j: 0 }, { i: 2, j: 3 }, { i: 0, j: 2 },
  ] };
  const rep = applyOps(board, seq, cfg);
  assert.equal(rep.accepted, 3, "三条合法合成");
  assert.equal(rep.rejected, 2, "两条非法被拒");
  assert.equal(rep.reasonCounts.empty, 1); assert.equal(rep.reasonCounts.oob, 1);
  assert.deepEqual(rep.board, [3, 0, 0, 0, 0, 0], "终局应只剩一个 3 级");
});

// ------------------------------------------------------------------ ④ 属性不变量
test("不变量:任意合法合成后总价值守恒", () => {
  const rep = applyOps(initialBoard(cfg), generateOps(cfg.seed, 500, cfg), cfg);
  assert.equal(rep.valueStart, rep.valueEnd, "合成不创造也不消灭价值");
});

test("不变量:占用槽数单调不增、单次合成等级恰好 +1", () => {
  let cur = initialBoard(cfg);
  assert.equal(occupied(cur), cfg.boardSize, "满盘起步");
  let prevOcc = occupied(cur);
  for (const op of generateOps(cfg.seed, 500, cfg).ops) {
    const r = mergeAt(cur, op.i, op.j, cfg);
    if (!r.ok) continue;
    assert.equal(r.board[op.i], cur[op.i] + 1, "合成后等级恰好 +1");
    assert.equal(r.board[op.j], 0, "被吃掉的一槽清空");
    assert.ok(occupied(r.board) <= prevOcc, "占用槽数应单调不增");
    prevOcc = occupied(r.board); cur = r.board;
  }
});

test("金标准摘要:固定 seed 的终局可跨语言比对", () => {
  const rep = applyOps(initialBoard(cfg), generateOps(cfg.seed, 300, cfg), cfg);
  assert.equal(rep.accepted, 7); assert.equal(rep.rejected, 293);
  assert.equal(digest(rep.board), "1,0,1,0,0,0,0,4,1,0,1,0");
  assert.equal(rep.valueEnd, 12); assert.equal(rep.valueEnd, rep.valueStart, "金标准也要满足守恒");
});

// 不值得测:① 内部实现调用次数(重构即假红);② 日志文案;③ getter/setter 往返;
// ④ 只做转发、没有分支的胶水函数——测它们只是在测语言本身,收益为负。

本机实跑输出如下(同机相对量级,耗时不可移植):

$ node --test 'core/test/e15_*.test.ts'
✔ 边界值:0 / 上限 / 越界都被规则挡住
✔ 价值表:整数累乘,value(0)=0、value(L)=base^(L-1)
✔ 确定性:同 seed 生成的指令序列逐条相同
✔ 确定性:同 seed 同输入 → 终局棋盘与报告逐位一致
✔ 异常路径:非法合成被拒绝,且不修改传入棋盘
✔ 异常路径:整串指令里非法项只计数、不生效
✔ 不变量:任意合法合成后总价值守恒
✔ 不变量:占用槽数单调不增、单次合成等级恰好 +1
✔ 金标准摘要:固定 seed 的终局可跨语言比对
ℹ tests 14 / pass 14 / fail 0

这九条断言各自锁住了什么

「边界值」那组一口气覆盖了五种拒绝理由,其中 oob 两条分别测负索引和「等于长度」——后者是最容易漏的,很多实现用 i > n 而不是 i >= n。「异常路径」那组最关键的一条是 r.board === before:它断言失败时返回的是同一个引用,这比「值相等」更强,因为值相等也可能是「先改了再改回来」。「不变量」那组的价值最高:valueStart === valueEnd 说明合成既不创造也不消灭价值——一旦哪天有人改错了合成公式(比如「两个 3 级合成一个 4 级」写成了「一个 5 级」),这条断言立刻变红,而不用等玩家发现进度膨胀。

「金标准摘要」是跨语言比对的锚点:1,0,1,0,0,0,0,4,1,0,1,0 这串固定输出,将来若用 Python 或 C++ 重写内核,只要摘要是这一行,就证明两边逐位一致。这正是本套「一份逻辑、多种消费」思路在测试层的体现。

小结与下一步

这一节把「测试」的第一步做完了:先把规则收敛成纯函数,再针对边界、确定性、异常、不变量四类各写断言。但单元测试只覆盖了「逻辑正确」,它管不到「整条链路跑起来对不对」——玩家点一下屏幕、合成、存档、切后台再回来,这一连串动作有没有出问题,单测看不见。下一节就用录制 → 回放 → 终局摘要比对,把端到端链路也变成可自动跑的断言。

阅读导航:上一节:内存与包体优化 · 下一节:端到端自动化与回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制