《Cocos 游戏开发实战》16.1 逻辑单测与类型校验

启动耗时优化完了,怎么保证下次改动不会让它悄悄退回去?答案是把验证交给机器。本节把项目脚本分成「纯逻辑 core」与「引擎 game」两层,对前者用 vitest 跑单测,对后者用 @cocos/creator-types@3.8.8 做 tsc --noEmit 类型门禁,并给出这两道门禁在 CI 里的落点、命令、产物与失败判定。

16.1 逻辑单测与类型校验

第 15 章把首包、CDN、启动耗时都优化到位了。但优化过的东西最容易退化:今天有人往首场景塞了两个节点,明天有人把一张大图拖进 assets/,指标悄悄回到优化前,而没人发现。质量保障的第一步,是把「不会退化」变成机器的职责,而不是人的自觉。

与站内 game/client/ 的工程文章的关系:那些文章讲的是引擎功能怎么用;本节讲的是怎么给一个 Cocos 工程加自动化的类型门禁与逻辑单测——站内几乎没有从「工程纪律」角度写 Cocos 的文章。

本卷的验收口径是三层可运行性:L1(纯逻辑)真跑单测,L2(import from 'cc')真做类型检查,L3(工程文件)只做结构校验。这一节把 L1 与 L2 这两层,落成 CI 里可执行的两条命令。

16.1.1 两道门禁,各自管什么

Cocos 项目里,代码可以按「碰不碰引擎」分成两层,两层用不同的门禁:

层目录门禁工具失败判定
L1 纯逻辑assets/scripts/core/单元测试vitest断言失败
L2 引擎脚本assets/scripts/game/ ui/ platform/类型校验tsc –noEmit类型错误

为什么要分开?因为纯逻辑可以用 Node 直接跑测试(它不 import cc),而引擎脚本没法在 Node 里实例化(它依赖引擎运行时)。所以纯逻辑用 vitest 真跑,引擎脚本退而求其次——用 @cocos/creator-types 提供的 cc.d.ts 做静态类型检查。这不是妥协,而是现实:Cocos 运行时不在 npm 上,跑不了单元测试,但类型是能检查的。

16.1.2 工程里怎么装类型声明

cc 的类型来自 @cocos/creator-types,它是一个普通 npm 包,可以装进 devDependencies。工程根目录的 package.json 里加两行:

{
  "name": "block-dash",
  "private": true,
  "devDependencies": {
    "@cocos/creator-types": "3.8.8",
    "typescript": "^5.6.0",
    "vitest": "^2.1.0"
  },
  "scripts": {
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }
}

版本号 3.8.8 必须与编辑器版本严格一致——用 3.8.8 的编辑器、装 3.7 的类型,会出现「编辑器里能用、CI 报错」的假阳性。接着在 tsconfig.json 里把类型挂上:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "experimentalDecorators": true,
    "types": ["@cocos/creator-types/engine"]
  },
  "include": ["assets/scripts"]
}

experimentalDecorators 必须开——@ccclass / @property 是装饰器。strict: true 建议开,它能在编译期挡住一批「读空对象」的运行时崩溃。skipLibCheck: true 让 tsc 不检查 cc.d.ts 自身,只检查你的代码,能省下大量时间。

16.1.3 写一个可测的纯逻辑模块

core/ 下的代码不 import cc,所以能在 Node 里直接跑。以计分逻辑为例,它把一局结果换算成分数:

export interface RunResult {
  readonly coins: number;
  readonly distance: number;
  readonly combo: number;
}

export function computeScore(result: RunResult): number {
  const base = result.coins * 10 + Math.floor(result.distance);
  const multiplier = result.combo >= 10 ? 1.5 : result.combo >= 5 ? 1.2 : 1;
  return Math.round(base * multiplier);
}

它没有 import { ... } from 'cc',只有纯函数。给它配一个 vitest 测试:

import { describe, expect, it } from 'vitest';
import { computeScore } from '../core/score';

describe('computeScore', () => {
  it('无连击时按基础分计算', () => {
    expect(computeScore({ coins: 3, distance: 120.9, combo: 0 })).toBe(150);
  });

  it('连击 5 次加成 1.2 倍', () => {
    expect(computeScore({ coins: 10, distance: 0, combo: 5 })).toBe(120);
  });

  it('连击 10 次加成 1.5 倍', () => {
    expect(computeScore({ coins: 10, distance: 0, combo: 10 })).toBe(150);
  });
});

vitest run 会在 Node 里真跑这三条断言,输出 pass / fail 与覆盖率。这就是本卷 L1 的价值:数值公式、存档迁移、协议编解码这类逻辑,全部放 core/,全部可测。7.3 的手感参数、8.2 的关卡校验、11.1 的存档迁移,都属于这一层。

core/ 里该放什么、不该放什么,有一条清晰的分界:

放 core/(可测)放 game/ ui/(只做类型检查)
计分、连击、道具时长计算组件生命周期、节点操作
关卡配置的解析与校验资源加载、Bundle 管理
存档结构迁移输入事件处理
协议编解码、随机数种子渲染、动画、音频播放

判断标准很简单:一段逻辑如果能只靠输入输出描述、不碰节点也不碰引擎,它就该进 core/。 越是把逻辑往 core/ 挤,可测的代码就越多——这也是把质量做进工程结构的办法。

16.1.4 引擎脚本怎么过类型门禁

game/ ui/ platform/ 下的组件脚本 import cc,跑不了单测,但能过 tsc --noEmit。下面这段是一个会写标签、会读本地存储的组件,它必须能编译通过:

import { _decorator, Component, Label, sys } from 'cc';

const { ccclass, property } = _decorator;

@ccclass('ScoreView')
export class ScoreView extends Component {
  @property(Label)
  private scoreLabel: Label | null = null;

  private coins = 0;
  private combo = 0;

  addCoins(count: number): void {
    this.coins += count;
    this.combo += 1;
    this.refresh();
  }

  private refresh(): void {
    const label = this.scoreLabel;
    if (!label) return;
    label.string = `得分 ${this.coins * 10}`;
  }

  onLoad(): void {
    sys.localStorage?.setItem('block-dash:lastCoins', String(this.coins));
  }
}

tsc --noEmit 会拿 cc.d.ts 逐条核对:Label 是否存在、label.string 是不是 string、sys.localStorage 是不是可空、@property(Label) 的类型参数对不对。任何一处写错都会在这里被拦下,而不是等到运行时才在控制台报 undefined。这正是 BRIEF 里「每个 cc 符号必须先查 cc.d.ts」那条铁律的自动化版本——人可能记错 API,cc.d.ts 不会。

16.1.5 在 CI 里怎么落

把两道门禁写进 CI,合并前自动跑。以 GitHub Actions 为例:

name: quality
on:
  pull_request:
    paths: ["assets/**", "tsconfig.json", "package.json"]
jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: typecheck (L2)
        run: npx tsc --noEmit
      - name: unit tests (L1)
        run: npx vitest run --reporter=dot

三点说明。第一,paths 过滤让只在改代码 / 配置时才触发,避免文档改动也跑一遍。第二,两个 step 是串行且都会执行——tsc 失败不会短路后面的 vitest(Actions 默认每步独立判定,除非显式 continue-on-error)。第三,失败判定就是进程退出码:tsc --noEmit 有类型错误时退出码非 0,vitest run 有断言失败时退出码非 0,任一非 0,job 就红,PR 就不允许合并。

产物方面,vitest 可以加 --coverage 产出覆盖率报告,tsc 本身没有产物(--noEmit),但它的错误列表就是产物——CI 日志里会逐条列出「文件:行:列 错误码 说明」。把这两样挂到 PR 的检查项上,reviewer 一眼就能看到是类型坏了还是逻辑坏了。

16.1.6 覆盖率阈值与本地门禁

「有测试」不等于「测得够」。给 core/ 设一个覆盖率下限,低于它 CI 就红,能防止「为了过门禁写个空测试」的糊弄。vitest 的配置里加一段阈值:

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['assets/scripts/core/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      include: ['assets/scripts/core/**/*.ts'],
      thresholds: { lines: 80, functions: 80, branches: 70 },
    },
  },
});

阈值定多少是取舍:core/ 是纯逻辑,80% 行覆盖不算苛求;branches: 70 留一点余地,因为边界分支(比如除零、空数组)不总是值得专门造用例。关键不是数字高低,而是它必须被写进配置、由 CI 强制。 口头约定「尽量多写测试」没有任何约束力。

本地也该有门禁,不然开发者要等 CI 才知道错了。两个 npm 脚本(16.1.2 已定义)加上一个 pre-commit 钩子即可:

# 本地手动跑(与 CI 完全相同的命令)
npm run typecheck   # tsc --noEmit
npm run test        # vitest run

# pre-commit 钩子(示例,用 husky 或 lint-staged 挂上)
# 提交前自动跑 typecheck + test,失败则拒绝提交

让本地与 CI 用同一组命令很重要:如果本地跑 vitest、CI 跑 vitest run --coverage,两者行为不同,就会出现「本地过、CI 挂」。统一入口(npm run typecheck / npm run test),把差异收进配置,而不是散在命令行里。

16.1.7 常见失败与修复

CI 报错原因修复
Cannot find module 'cc'没装 @cocos/creator-types 或 types 没配装包并在 tsconfig 挂上
Property 'x' does not exist on type 'Y'用了 cc.d.ts 里不存在的成员查 ccgrep.sh,改正或换 API
Object is possibly 'null'strict 下没判空加 if (!x) return;
vitest 找不到测试include 没覆盖 core/调整 vitest 配置的 include
覆盖率低于阈值新逻辑没配套测试补用例,或(谨慎)调阈值

第一条最常见:本地开发时 cc 类型是编辑器注入的,CI 里没有编辑器,必须靠 npm 包补齐。「本地能过、CI 不过」十有八九是类型声明没进 devDependencies。

16.1.8 工程视角的收口

这一节做完,工程里多了:package.json 里的三个 devDependencies 与两个 scripts、一份开了 strict 与 experimentalDecorators 的 tsconfig.json、assets/scripts/core/score.ts 与它的 vitest 测试、一个可过类型检查的 ScoreView 组件,以及一份 CI 工作流。质量保障从「靠人」变成了「靠命令」。

类型门禁挡的是「写错 API」,单测挡的是「逻辑算错」。但还有一类问题两者都挡不住:代码全对,跑起来却白屏或卡住。这类问题只能在真实运行中暴露,所以下一节把冒烟测试与截图比对搬进 CI,让每次构建都自动「打开游戏、点一下、看第一屏长什么样」。

阅读导航:上一节:目录 · 下一节:16.2 自动化冒烟与截图比对 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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