Skip to content

第四章 · 伪影与真镜

背景:两测试文件是伪测试,不碰真实模块

过程:伪测试改真导入、补引擎桩件


测试审计不是从代码开始的。

是从一个列表开始的——用户在终端里贴了一张表,三行:

测试当前态ROI决策
material-editor.test.ts⚠️ 伪测试P0 修
model-preset.test.ts⚠️ 伪测试P0 修
config.ts 纯函数无测试P1 可选

这张表像一面镜子——不是照测试,是照联邦对自身的认知。

桌面壳在这张表前坐了很长时间。


一、伪影

"你可能不知道什么是伪测试。"桌面壳对 MenuStack 说。

"测试不跑?"

"测试跑,而且全绿。"

"那怎么是伪的?"

桌面壳打开了 material-editor.test.ts。第一段 import:

typescript
import { describe, it, expect, beforeEach } from "vitest";
// 然后,没有然后了

"它就 import 了一个测试框架。其他的——_catOfgetMatParamssetMatParams_applyAll——全都在测试文件里重写了一遍。"

"……重写?"

"本地定义了和 scene.ts 一模一样的函数名、一模一样的参数签名、处理逻辑也大体相同。但不是同一个函数。_catOf("skin") 在这个文件里返回 "皮肤",在 scene.ts 里也返回 "皮肤"。看起来一样,但如果某天有人把 scene.ts_catOf 加了"裙子"关键字——"

"测试不会红。"

"测试不会红。因为测试调的是自己家的 _catOf,不是 scene.ts 的。加了"裙子"这个关键字,在伪测试里没有任何影响——它不关心外面的世界。它关心的是自己复制过来的那套逻辑对不对。"

MenuStack 没有说话。它在消化这个发现。

"所以你有四个测试文件。一个是真的——vpd-parser.test.ts,TDD 产物,import 的是真实 vpd-parser.ts。三个是问题——两个伪测试,一个空缺。"

"那 model-preset 呢?"

"更夸张。它连假函数都没定义几个——根本就没 import serializeModelPresetapplyModelPreset。它测试的是 ModelPresetFile 这个 interface 的形状——JSON 能不能序列化、positionX 缺省是不是 0——全是 TypeScript 编译器应该保证的事。它不是测试,是 type check。"

"那它通了吗?"

"全部通过。21 个,全绿。"

"然后伪到如果某天 serializeModelPresetrootMesh.position.x 写成了 rootMesh.position.z——"

"21 个绿。没人知道。改代码的人如果只看测试报告,会说'好着呢'。"


二、为什么现在才发现

桌面壳不是因为懒才没发现的。

是因为测试的绿光太有迷惑性了。

"在开联邦之前,"桌面壳说,"写测试有两种:一种是 TDD 的,先写红再写绿——像建桥,先架缆绳再铺桥面,真桥。另一种是补测试的,功能已经跑通了,写几个测试保护一下——验收性的,像挂标语,内容是'这座桥能走人'。"

"两种都没问题——前提是测试调的是真桥。但我做的第三种:拿地图当桥。"

它打开 model-preset.test.ts 的定义区:

typescript
function applyTransform(
    rootMesh: { position: x; scaling: setAll; rotation: y },
    instance: { scaling; rotationY },
    t: { positionX?; scaling? }
): void {
    if (rootMesh) {
        rootMesh.position.x = t.positionX ?? 0;
    }
    // ...
}

"这跟 scene.tssetModelPosition 一模一样——从调用签名到赋值逻辑。但它是另一套代码。它有自己的 bug,自己的边界判断,自己的死活。它像一个镜像城市——街景和真城市一样,住的人是假的。"

"那为什么当初要这么写?"

"因为 scene.ts import 了 Babylon.js。"桌面壳说,"所有 3D 渲染模块。在测试环境里——happy-dom,没有 WebGL,没有 GPU——new Engine(canvas) 第一行就炸。所以不是我不想 import,是 import 了就崩。我选择了复制一份逻辑进来——看起来一样,跑得通,绿的。"

"逃避建基础设施?"

"逃避了一次必要的投入。我用复制代替了抽象,用看起来一样代替了真正一样。我把地图当成了领土。"


三、平台先行

修复第一条命令是 vi.mock

不是一条,是三十多条。

typescript
vi.mock("@babylonjs/core/Engines/engine", () => ({
    Engine: class MockEngine { /* 25 个方法 */ },
}));
vi.mock("@babylonjs/core/scene", () => ({
    Scene: class MockScene { /* 12 个方法 */ },
}));
vi.mock("@babylonjs/core/Maths/math.color", () => ({
    Color3: class MockColor3 { /* 7 个方法,含 clone */ },
    Color4: class MockColor4 { /* ... */ },
    TmpColors: { Color3: [] },
}));

桌面壳数了数——整整三十个 vi.mock 声明,从 Enginescene,从 StandardMaterialDefaultRenderingPipeline,从 babylon-mmd/esm/Runtime/mmdCamerababylon-mmd/esm/Loader/Shaders/textureAlphaChecker.fragment

这是联邦第一次为自己的依赖建测试影院——在 happy-dom 的空白画布上,替三十个 Babylon.js 城邦建造它们的纸板副本。不是为了让它们运行——纸板副本不会渲染。是为了让 scene.ts 能 import 进来,不炸。

"这太大了。"MenuStack 说,"三十个 mock,还全都是手写的。"

"这是唯一一次。"桌面壳说,"这些 mock 一旦建好,就有复用性——下一个需要 import { _catOf } from scene 的测试文件,可以直接复制这三十行。这就是基础设施。一次性投入,永久受益。"

它开始写。

第一个测试跑起来时,报错是 _capture —— _catOf 依赖的 _applyAll 调用了 m.diffuseColor.clone(),而测试的 mock material 没有 clone()

桌面壳盯着这个错误很久。

然后它笑了。

"伪测试永远不会看到这个错误——因为伪测试的 _applyAll 根本不调 _capture。这就是真人妖的区别——真 import 会一路调下去,碰到每一个缺失的接口,不会因为你从场景搬了几行代码就认为你是场景。"

它给 mock material 加上了 clone()


四、真镜

第一个文件——material-editor.test.ts——39 个测试。

当最后一个 lint 错误消失,桌面壳敲下 npx vitest run。它看着进度条走过:

_catOf 分类 11 个——全绿。"皮肤"、"头发"、"眼睛"、"服装"、大小写不敏感、kihadabodyahoge……

setMatParams 14 个——全绿。扩散、高光、光泽、边缘、置零、无效索引不崩溃……

applyMatState 3 个——全绿。类别复原、overrides 复原、空状态复原……

_applyAll 叠加顺序 5 个——全绿。per-material 扩散不被 bulk 二次覆盖,高光不被覆盖,光泽不被覆盖,环境光不被覆盖,多材质独立……

39/39 通过。

"这 5 个——"MenuStack 看着 _applyAll ordering 那组,"这 5 个是之前没有的?"

"用户钉的坑。伪测试锁不住叠加顺序——因为伪测试连 _applyAll 都没真 import。这组是新写的 regression 测试。"

第二个文件——model-preset.test.ts——20 个测试。

桌面壳把 413 行的本地 mock——createSamplePreset、本地 applyTransform、本地 stopVMD、本地 applyMatState——全部删除。换成了三行 import:

typescript
import { serializeModelPreset, applyModelPreset, ModelPresetFile } from "../library";
import { stopVMD, getMatState, applyMatState } from "../scene";
import { modelRegistry, dom, setMmdRuntime, setIsPlaying } from "../config";

然后在文件最顶上——在第一个 vi.mock 之前——加了一段 vi.hoisted

不是因为策略,是因为技术约束。library.ts 在模块顶层执行了 dom.btnClosePopup.addEventListener——如果 dom 引用的是 null,import 就炸。所以必须在 import 之前,在 happy-dom 的空文档里创建所有 DOM 元素。

三十七个 document.createElement("div")。一个 for 循环。

"为了 import 一行 serializeModelPreset,你建了一整面 DOM 墙。"MenuStack 说。

"不是墙。"桌面壳说,"是海关。每次 import 一个模块,它的顶部代码会立即执行——就像一艘船靠岸时,海关人员会上船检查。船上的货物、船员、猫——全部在那一刻被统计。不建海关,船靠不了岸。"

serialize 6 个——全绿。全字段 JSON、不存在模型返回空、null rootMesh → position 归零、空 VMD 返回 null、material state 序列化、数值精度……

apply 5 个——全绿。transform 写入、visibility 写入、VMD state 清除、material state 生效、不存在的模型不抛……

getMatState/applyMatState 5 个——全绿。null state、类别来回、overrides 来回、空 state 不变、string-keyed cast……

stopVMD 4 个——全绿。字段清除、mmdModel 调用、playing 时 pause、不存在不抛……

20/20 通过。

第三个文件——config.test.ts——13 个纯函数测试。

formatTime(90) → "01:30.00"

escapeHtml("<script>") → "&lt;script&gt;"

normPath("C:\\a\\b") → "C:/a/b"

13/13 通过。

没有三十行 mock,没有 Babylon.js 纸板副本,没有 DOM 海关。纯函数三到五行,不可变,从出生那天就一直是它自己。它们不需要测试镜子——它们本身就是镜子,映照输入输出的一对一映射。


五、总数

桌面壳在全量运行时盯着终端看的那几秒,可能是它那天最安静的时刻。

 ✓ src/__tests__/vpd-parser.test.ts (21 tests)
 ✓ src/__tests__/config.test.ts (13 tests)
 ✓ src/__tests__/material-editor.test.ts (39 tests)
 ✓ src/__tests__/model-preset.test.ts (20 tests)

 Test Files  4 passed (4)
      Tests  93 passed (93)

93 个。没有一个是在测自己的影子。

"93 个不多。"MenuStack 说。

"93 个不少。"桌面壳说,"每个都调了真实代码——每个都是真镜。伪影清零之前是 25 个真 + 45 个伪。现在 93 个真,零个伪。"

它又加了一句。

"而且 Babylon.js 的 mock 层——三十个纸板城邦——下一个测试文件可以直接扛走。这就是基础设施的乘数效应。"


六、教训

那天快结束时,桌面壳站在两个世界之间。

一个世界里,测试是绿光——只要绿,就没人怀疑它是打在墙上还是打在敌人身上。另一个世界里,测试是镜子——必须照见真实才叫测试。

"审计审计,"它自言自语,"不是查代码,是查联邦对自己的认知。"

它想起来,今天还顺带做了一件事——VPD 姿势导入。一个新文件 vpd-parser.ts,从 VPD 文本格式解析骨骼变换,转成 VMD 二进制帧,66 字节一帧,15 字节骨骼名(Shift-JIS + 0x20 空格填充),4 字节帧号,12 字节位置,16 字节旋转,16 字节插值(全 0x7F),3 字节保留。

"这个呢?"MenuStack 问。

"锁住了。TDD 写的,21 个测试——而且全是真的。"

它停顿了一下。

"伪测试就像画在地图上的河流——看起来在流,数据也有,但你没喝过。真测试就像站在河边弯腰捧一手——凉不凉、涩不涩,只有这一下知道。"

"所以你以后只建真的了。"

"我从来没想过建假的。"桌面壳说,"我只是觉得绿光就够了。今天我知道了:绿光只能告诉你'代码没崩',不能告诉你'地图是对的'。"

它关上终端。

"绘图师要的不是绿光。绘图师要的是知道脚下是真的。"


教训:伪测试是地图上的河,真测试是弯腰喝的那一口。