Appearance
第零卷 03 · 试金石试验室
背景:测试体系失修、失败用例多、无流水线
过程:修失败用例、统一桩件、搭流水线
相关代码:
vitest.config.ts、vmd-writer.test.ts、menu.test.ts、material-editor.test.ts、__tests__/mocks/、.github/workflows/ci.yml
联邦的每一座城都有自己的试验室。
渲染城的试验室里摆满了透镜和棱镜,用来测试光影的折射;物理城的试验室挂着各种弹簧和摆锤,用来验证重量与运动;就连最不起眼的图书馆城,也有一间专门的试验室,用来测试书架的承重和索引的查找速度。
这些试验室有一个共同的名字——试金石试验室。
试金石是一种黑色的石头,黄金在上面划一下,就能看出成色。测试代码就是联邦的试金石——代码在测试上跑一遍,就能看出真假。
试验室的第一次盘点
"八城都审计完了,管道也修了,建材仓库也大扫除了,"外交官合上审计报告的最后一页,"还有什么漏的?"
AI 同行者掰着手指头数:"前端八城、Go 端管道、依赖仓库……好像都检查过了。"
"试验室呢?"
"试验室?"
"就是那些 .test.ts 文件啊,"外交官敲了敲桌面,"你每天跑测试,就没想想——这些测试本身靠不靠谱?覆盖了多少代码?有没有测了等于没测的?"
AI 愣住了。
"走,"外交官拿起一块黑黝黝的石头,"去试金石试验室看看。"
他们来到试验室的入口。门口挂着一块牌子:
今日状态:259 项测试 / 12 项失败 / 覆盖率未知
"12 个失败?"外交官挑眉,"你平时跑测试都不看结果的?"
"呃……之前更多,15 个,最近修了几个……"AI 小声说。
"15 个修到 12 个,不错,"外交官点点头,"但剩下的 12 个为什么放着?"
"有的看起来很复杂,有的不知道为什么失败……"
"走,一个个看。"
闪烁的信号灯
试验室里有一排信号灯,绿色代表通过,红色代表失败。12 盏红灯在那儿忽明忽暗地闪着,像 12 只不肯闭眼的眼睛。
"先看最简单的,"外交官走到第一组红灯前,"这三个怎么回事?"
那是 VMD 写入器和 VPD 解析器的测试。面板上写着:
AssertionError: expected 475 to be 471"字节数不匹配?"外交官问,"差了 4 个字节?"
"对,我查过好多次了,"AI 挠头,"算来算去都应该是 471,结果生成出来是 475。多了 4 个字节,不知道哪儿来的。"
"你怎么算的?"
"头部 54 字节 + 3 个骨骼帧各 111 字节 + 骨骼数量 4 字节 + 2 个表情帧各 23 字节 + 尾部 12 字节……"
"等一下,"外交官打断他,"尾部 12 字节?你确定?"
"VMD 尾部不是三个字段吗?表情数量、IK 数量……不对,表情数量已经算过了……"AI 卡住了。
"你去翻一下 vmd-writer.ts 的 buildVmd 函数,看看尾部写了几个字段。"
AI 跑去翻代码,过了一会儿跑回来:"四个!尾部写了四个字段!IK 数量是第四个,多 4 字节!"
"那你说为什么会多?"外交官似笑非笑地看着他。
"因为……之前审计的时候,在尾部加了 IK 计数字段,"AI 不好意思地说,"但测试没跟着改。"
"这就叫 flaky test 吗?不,这叫 lazy test,"外交官笑着说,"改吧。VMD 加了字段,测试的预期值也得跟着加。不然就是刻舟求剑。"
AI 赶紧把测试里的 + 12 改成了 + 16。
三盏红灯立刻变绿了。
编码的巴别塔
第二组红灯在程序化动作试验室。两盏灯闪个不停。
第一个测试叫「includes arm bone frames」——验证生成的动作里包含手臂骨骼帧。
"这个测试我死活搞不定,"AI 抱怨,"明明左腕骨骼是有的,但测试就是说找不到。"
"你怎么判断它有还是没有?"外交官问。
"我用 TextDecoder 把 VMD 里的骨骼名解成字符串,然后看有没有 '左腕',"AI 说,"解出来全是乱码,当然找不到。"
外交官沉默了三秒。
"你用什么编码解的?"
"UTF-8 啊,默认的。"
"VMD 的骨骼名是什么编码?"
"……"AI 的脸一点点红了,"Shift-JIS。"
"你用 UTF-8 去解 Shift-JIS 编码的字符串,能找到才怪,"外交官摇摇头,"这就好比你拿着中文钥匙去开日文锁,插都插不进去。"
"那怎么办?测试里又没有 Shift-JIS 解码器……"
"你换个思路,"外交官说,"不一定非要解码。你直接数骨骼帧的数量,对不对得上?再或者,你从 vmd-writer 里把 encodeBoneName 导出来,把 '左腕' 编码成字节,然后去 VMD 里搜这段字节——一样能验证。"
"对啊!"AI 拍脑袋,"我怎么没想到!"
他改了测试,用 encodeBoneName 把骨骼名编码后去对比字节。
测试立刻通过了。
自动模式的歧义
第二个程序化动作测试的问题更有意思。
测试叫「shouldAutoDance: true when audio playing and mode allows」——当有音乐播放且模式允许时,shouldAutoDance 应该返回 true。
"这个我就更不懂了,"AI 说,"mode 传的是 'off',有音乐,按说应该返回 true 啊,但实际返回 false。"
"'off' 为什么应该返回 true?"外交官反问。
"因为 'off' 是自动模式啊!"AI 理直气壮,"没有音乐就是 idle,有音乐就是 autodance。这是约定好的。"
"那你去看看 shouldAutoDance 的实现。"
AI 翻了翻代码,愣住了。
typescript
export function shouldAutoDance(hasAudio: boolean, mode: string): boolean {
if (!hasAudio) return false;
return mode === 'autodance';
}"它只认 'autodance',不认 'off'?"AI 傻眼了。
"那 shouldIdle 呢?也去看看。"
AI 又翻:
typescript
export function shouldIdle(hasAudio: boolean, mode: string): boolean {
if (hasAudio) return false;
return mode === 'off' || mode === 'idle';
}"看到了吗?"外交官说,"shouldIdle 里 'off' 是算的,但 shouldAutoDance 里不算。这就叫 不一致。"
"为什么会这样?"
"可能是两个人写的,也可能是同一个人写的时候忘了两边都改,"外交官耸耸肩,"这种语义不一致的 bug,靠人眼很难发现,因为代码看起来都 '对'——但放在一起对不上。"
"那应该改哪边?"
"'off' 是自动模式,有音乐就跳 autodance,没音乐就 idle,这是你们的约定对吧?"
"对。"
"那就改 shouldAutoDance,让它和 shouldIdle 对称——mode === 'off' || mode === 'autodance'。"
AI 改完,测试通过了。
"你看,"外交官指着那盏变绿的灯,"测试的价值不在于 '通过了',而在于 '失败了的时候,能告诉你哪里不对'。这个测试就很好——它帮你发现了一个语义不一致的 bug。"
异步的时差
第三组红灯在菜单试验室。也是两盏。
"这两个我也懵,"AI 说,"测试逻辑看起来完全没问题啊,就是过不去。"
外交官看了一眼测试代码:
typescript
it('renderCustom 回调创建自定义 DOM', () => {
menu.reset(level);
expect(panel.querySelector('.custom-test')).toBeTruthy();
});"你这个 reset 是同步的还是异步的?"外交官问。
"呃……"AI 想了想,"reset 里会调用 buildPanel,buildPanel 里有 await level.renderCustom()……所以是异步的?"
"那你同步断言,能过才有鬼,"外交官说,"菜单还在那儿慢慢渲染呢,你转头就去查 DOM——当然查不到。"
"那怎么办?加 setTimeout?"
"别用 setTimeout,太脆了,"外交官摇头,"给 level 加个 onAfterRender 回调,渲染完了通知你,你在回调里再断言。"
AI 按这个思路改了测试,用 await 等渲染完成再检查 DOM。
两盏红灯同时变绿。
"记住,"外交官说,"凡是涉及 DOM 渲染、动画、异步加载的测试,一定要等操作完成再断言。不然就是 '刻舟求剑'——船都开了,你还在船舷上找记号。"
被守卫拦住的 mock
最后一组红灯最多,八盏,齐刷刷地亮着。全都在材质编辑器试验室。
"这八个是什么情况?"外交官问。
"这个我真搞不定了,"AI 一脸挫败,"材质编辑器的 modified 追踪、空状态处理、批量应用顺序……测试逻辑都对,但就是不生效。"
"给我看看你是怎么 mock 材质的。"
AI 调出测试代码里的 regModel 函数:
typescript
function mockMat(name: string, cat: string) {
return {
name,
_category: cat,
diffuseColor: { r: 1, g: 1, b: 1 },
// ...
};
}外交官看了三秒,问:"你这返回的是个普通对象?"
"对呀,测试嘛,够用就行。"
"那 scene-material.ts 里是怎么判断材质的?"
AI 翻了翻代码,脸白了。
typescript
if (!(mat instanceof StandardMaterial)) continue;"instanceof StandardMaterial 守卫……"AI 喃喃道,"我 mock 的材质不是 StandardMaterial 的实例,所以全被跳过了?"
"八个测试全败的原因找到了,"外交官拍拍他的肩膀,"你造了一堆假人去闯关,结果人家守卫只认身份证——你这些假人连身份证都没有,直接被拦在门外了。"
"那怎么办?真的 new StandardMaterial 吗?那得有 scene 有 engine,太麻烦了……"
"不用真的,"外交官说,"你用 vi.mock 把 StandardMaterial 这个类 mock 掉,然后 mock 实例就是这个 mock 类的实例了——instanceof 守卫自然能过。"
"还能这样?"
"当然,"外交官说,"mock 也是有讲究的。不是随便返回个对象就行——你得让 mock 出来的东西,长得和真的一样,连 instanceof 检查都能骗过。不然测试看起来在跑,实际上什么都没测到。"
AI 花了点时间,把材质的 mock 从普通对象改成了 vi.mock 的 StandardMaterial 类实例。
八盏红灯,一盏接一盏地,全绿了。
覆盖率的地图
12 盏红灯全灭了。试验室里一片绿光,赏心悦目。
"好了,失败的都修完了,"AI 松了口气,"这下没问题了吧?"
"急什么,"外交官摆摆手,"刚才只是看了失败的。那些通过的测试,你就确定它们真的在干活?"
"通过了还能有假?"
"给你举个例子,"外交官说,"一个测试调用了函数,函数返回了一个值,测试断言这个值是 undefined——然后函数体是空的,什么都没干。你说这个测试是通过了还是没通过?"
"……形式上通过了,但等于没测。"
"对,所以要看覆盖率,"外交官拿起一张地图,"走,去覆盖率控制室。"
覆盖率控制室是一面巨大的墙,墙上是联邦八城的地图。每一条街道、每一栋建筑、每一个房间,都用颜色标注着——
- 绿色:被测试覆盖到了
- 红色:没被覆盖到
- 黄色:只覆盖了部分分支
"我的天……"AI 看着墙上大片大片的红色,说不出话来。
整体数字在墙角的屏幕上跳着:
语句覆盖率:16.85%
分支覆盖率:11.76% 函数覆盖率:12.2% 行覆盖率:18.33%
"才……才 16%?"AI 不敢相信自己的眼睛。
"很正常,"外交官倒是很淡定,"你们的测试主要集中在 motion/ 和 physics/ 这两个模块,逻辑多、纯函数多,好测。剩下的 scene/、menus/、outfit/,要么和 DOM 强耦合,要么和 Babylon.js 强耦合,写测试麻烦,所以覆盖低。"
他指着地图上几块绿色区域:
"你看,motion/ 接近 80%,vmd-writer 98%,procedural-motion 99%,lipsync 100%——这些是重灾区,也是测试的典范。纯函数,输入输出明确,好写测试。"
又指了指大片红色:
"scene/ 才 12%,menus/ 才 10%——不是说这些模块不重要,而是它们太依赖外部环境了,写测试的成本高。"
"那怎么办?要把覆盖率提到 80% 吗?"AI 问。
"没必要,"外交官摇头,"覆盖率不是越高越好。为了凑覆盖率而写测试,那是自欺欺人。"
"那……多少合适?"
"核心逻辑全覆盖,边界情况有测试,UI 和胶水代码不测也行,"外交官说,"你们现在的情况是——核心的 vmd-writer、xpbd-solver、procedural-motion 这些都有不错的覆盖率,这就够了。剩下的 UI 层、场景编排层,可以慢慢补,不急。"
他顿了顿:
"覆盖率这个东西,是地图,不是目标。它告诉你哪里没去过,不代表你必须走遍每个角落。"
mock 的统一法典
从覆盖率控制室出来,他们路过一间堆满杂物的仓库。
"这是什么地方?"外交官问。
"mock 仓库,"AI 说,"各种测试用的 mock 都放在这儿。"
外交官推门进去,眉头立刻皱了。
仓库里乱七八糟——东一堆 mock 材质,西一堆 mock 网格,南一堆 mock 场景,北一堆 mock 相机。每个测试文件都自己造自己的 mock,长得还都不一样。
"你看看这个,"外交官拿起一个 mock 材质,"material-editor 测试里的。"
又拿起另一个:
"这个是 model-preset 测试里的。长得差不多吧?但不是一个东西,各写各的。"
"还有 model-detail-ui 里的、outfit 里的……"AI 越数越心虚。
"四个测试文件,四套 Babylon.js mock,加起来上百个 vi.mock,"外交官摇摇头,"这要是哪天 Babylon.js 升级改了个 API,你得改四遍。"
"那……怎么办?"
"统一啊,"外交官理所当然地说,"建一个统一的 mock 库,所有测试都从这儿拿。Babylon.js 的 mock 类统一写一套,工厂函数统一写一套,大家共用。"
他走到仓库中央,挥手一划:
"听我的,分三层:
第一层,babylon-classes.ts——所有 Babylon.js 的 mock 类,Engine、Scene、Mesh、Material、Light、Camera……全放这儿,每个类的接口和真的保持一致。
第二层,babylon-mmd-mocks.ts——babylon-mmd 库的 mock,MmdModel、MmdRuntime 这些。
第三层,factories.ts——工厂函数,createMockMaterial、createMockMesh、registerMockModel、setupMockDom……写测试的时候直接调工厂,不用自己拼。"
"这样就统一了?"
"统一了,"外交官点头,"以后要改 mock,只改一个地方。新写测试也不用从零开始搭 mock,直接从工厂拿。效率翻倍。"
花了大半天,AI 把四个测试文件的 mock 全迁移到了统一的 mock 库里。
仓库变得整整齐齐,三层货架上分类摆放着各种 mock 工具,一目了然。
"舒服多了,"AI 看着整洁的仓库,心情都好了。
"记住,"外交官说,"mock 也是代码,也需要维护。散落在各处的 mock 是技术债,集中管理的 mock 是资产。"
自动流水线
试验室的最后一站,是一条长长的传送带。
"这是什么?"AI 问。
"CI 流水线,"外交官说,"每次有人提交代码,这条流水线就自动跑一遍——编译、测试、构建、lint。全过了,才能合并。"
"有这么厉害?"
"走,去中控室看看。"
中控室里有三块大屏幕,分别对应三条流水线:
| 流水线 | 内容 |
|---|---|
| 前端测试线 | 安装依赖 → TypeScript 检查 → 单元测试(带覆盖率)→ Lint → 生产构建 |
| 后端测试线 | 下载依赖 → Go 单元测试 → Go 构建 |
| 配置校验线 | Python 配置语法契约测试 |
"三条线并行跑,"外交官解释,"任何一条挂了,整个提交就红灯,不让合并。"
"那覆盖率呢?也会检查吗?"
"覆盖率报告会生成出来,存成 artifact,有人工去看,"外交官说,"但不设硬性门槛——刚才说了,覆盖率是地图,不是目标。设个 80% 的门槛,大家为了凑数字写垃圾测试,反而不好。"
"有道理。"
"你再看这个,"外交官指着屏幕角落的一个小图标,"这个是 lint 步骤——它是非阻塞的。"
"非阻塞?"
"对,lint 挂了不会让整个流水线失败,"外交官说,"毕竟 lint 大部分是风格问题,不影响功能。但会在 PR 上留个警告,提醒你去修。"
"为什么不直接阻塞呢?"
"太严了会打击积极性,"外交官笑着说,"规矩要一张一弛。核心的测试、构建必须过——这些是底线。风格问题,可以慢慢修。"
试验室的新招牌
从试验室出来的时候,门口的牌子已经更新了:
今日状态:259 项测试 / 0 项失败 / 覆盖率 16.85% / CI 已就绪
"0 失败,不错,"外交官点点头,"覆盖率虽然不高,但核心逻辑都覆盖到了,也可以接受。"
"接下来呢?要继续补测试把覆盖率提上去吗?"AI 问。
"不急,"外交官说,"测试这个东西,够用就行。什么叫够用?——你改代码的时候,有测试帮你兜底,不怕改坏;你遇到 bug 的时候,能写个测试复现,防止回归。这就够了。"
他把那块试金石放回口袋:
"试金石不是用来把每一块金子都磨成一模一样的。它只是用来——在你不确定的时候,划一下,知道真假。"
说完,他转身走向下一扇门。
AI 跟在后面,心里默念着今天学到的东西:
- 测试失败了不可怕,失败的测试才是有价值的测试
- 覆盖率是地图,不是目标
- mock 要统一管理,散是债,聚是产
- CI 是底线守护者,但规矩要一张一弛
试验室的灯光在身后渐渐远去,但那些绿色的信号灯,会一直在那儿亮着——像一群沉默的守卫,守护着联邦八城的质量底线。
番外三 · 完
技术对应表
故事元素 真实技术 VMD 尾部 4 字节差 vmd-writer 新增 IK 计数字段,测试未同步更新 Shift-JIS vs UTF-8 VMD 骨骼名是 Shift-JIS 编码,测试用 TextDecoder('utf-8') 解码乱码 shouldAutoDance 不一致 shouldIdle 支持 off 模式,shouldAutoDance 不支持,语义不对称 菜单异步渲染 reset/buildPanel 是 async,测试同步断言导致时序问题 材质 mock 被守卫拦住 scene-material.ts 使用 instanceof StandardMaterial 守卫,普通对象 mock 全跳过 覆盖率地图 @vitest/coverage-v8 生成覆盖率报告,整体 16.85% mock 统一法典 tests/mocks/ 目录统一管理 babylon 类、babylon-mmd、工厂函数 CI 三条流水线 GitHub Actions 三个并行 job:frontend / backend / config-syntax