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 自动化冒烟与截图比对 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。