一个 Godot 多人游戏的测试打印出"所有测试通过",退出码 0。而同一次运行里有六条 SCRIPT ERROR。其中两条写着 Compilation failed,还有一条是 Trying to assign value of type 'CanvasLayer' to a variable of type 'character_select_panel.gd'。runner 看的是退出码,退出码是 0,没人再往下看。
一个不可能失败的测试,比没有测试更糟。没有测试,你知道自己不知道。有一个假绿灯,你以为自己知道。
一个绿灯里的六个错误
失败的那段代码会实例化整个关卡场景,用来数它声明了多少可拾取物。那个场景拖着两块 UI 区域,它们的脚本用到了 autoload,而用 --script 启动引擎时那些 autoload 并不存在。脚本编译不过,节点变成了 CanvasLayer 而不是预期的类型,Godot 把错误吐到 stderr 上,进程却没有倒下。
难看的地方不是这个错误,而是测试继续往下跑,后面的断言是在一棵没搭完的树上求值的。
修复办法是不再实例化。场景改用 PackedScene.get_state() 读取,它能给出节点名、instance= 和被覆盖的属性,而不执行任何 _ready,也不加载那些区域的脚本。前置条件仍然锚在生产文件上:如果有人从关卡里拿掉一个物件,测试就会变红。
那条同义反复的断言
同一个测试还有一个更糟的问题,因为它不会在 stderr 上发出任何声音。前置条件说"关卡里有 N 个模板",而 N 是从场景本身推导出来的,然后再去检查转换结果是否返回 N。这永远成立。它检查的不是"转换了实际存在的那些模板",而是"你刚读出来的那个数字等于它自己"。
现在那个数字是手写的:
const PLAYGROUND_TEMPLATE_COUNT := 7 # 在编辑器里放置的 7 个拾取物
而每一份转换出来的 payload 都按位置和 .tscn 里的声明一一对上,允许 1 px 的容差——因为 host 在转换它们之前已经积分了一帧重力——再加上它的类型和数量。把 7 手写死,正是"转换所有存在的"和"转换存在的那 7 个"之间的区别。
怎么验证红灯是可能的
判断一个测试有没有用的办法,是故意把代码弄坏,看它会不会察觉。在那次修复里我把试过的三个变异都写下来了:从拆下模板的那个函数里去掉 remove_child,三条断言变红;把 payload 里的数量改成 1,两条断言变红;从场景里删掉一个物件,前置条件变红。
每个变异三十秒,而这是"一个测试真的在保护什么"的唯一实证。一个你从没见它红过的测试,只是一个假设。
让双实例测试通过的那个竞态
有个脚本会拉起两个游戏实例,host 和 client 通过 localhost 上的 ENet 连接,并捕获两边的状态。它是绿的。它同时有三个假绿灯,而我在动手之前把三个都复现了:
- host 中途死掉却返回 0。 client 已经捕获到了带本地玩家的帧,于是脚本认为万事大吉。现在还额外要求 host 必须是被脚本自己的
SIGTERM杀死的;测到的退出码是 143。 pkill -f把没转义的路径塞进了一个 ERE。 路径里带+就匹配不上,host 一直活到超时:三分钟,而不是二十秒。路径里带.则可能杀掉另一次运行的 Godot。现在按直接子进程的 PID 来杀。- client 靠帧号猜 host 什么时候存在。 而这是猜不了的,因为在开始 host 之前 Godot 会放一个
OfflineMultiplayerPeer:在菜单里get_unique_id()返回 1、is_server()返回 true,在那个将要当 client 的实例上也一样。唯一真实的"正在 host"标记,是它的 JSON 里有一个带is_local的玩家。
顺带还发现 autopilot 的那些 at 数的是绘制帧数,不是秒:开着 vsync 时,一个被遮挡或失去焦点的窗口会等合成器,掉到 1-3 fps。实测是 90 秒 85 帧。加上 --disable-vsync --max-fps 60 后,稳定 51 fps。
测你分发的那个产物,不是你构建的那个
最难的一次是在一个桌面转录应用上。CI 是绿的,而它发布出去的 .deb 麦克风是死的,因为从来没有任何东西去运行它构建出来的东西。
PyInstaller 打包的是 sounddevice wheel 里的 PortAudio,编译时没有 PulseAudio 后端,还有一个缺了 alsa-lib 模块的 libasound.so.2。在 bundle 内部,这会抹掉 pulse、pipewire 和 default 这些 PCM,只留下独占访问的 hw:。而麦克风已经被 PipeWire 占着,所以录音失败,报 Invalid sample rate [PaErrorCode -9997]。
它只在安装好的 .deb 上复现:开发环境链接的是系统的 PortAudio,那个是带 Pulse 的。修复办法是把 libportaudio、libasound 和 libjack 排除出 bundle,反正它们本来就写在 Depends 里。
CI 里改了什么,比这个修复更重要。现在在打包之前,会检查 bundle 里没有音频库,并且二进制能在 xvfb 下启动。另外 .deb 里声明的是 libc6 (>= 2.43),这个下限在任何 Ubuntu 上都装不上:它来自取 bundle 里 GLIBC 标签的最大值,也就是来自从构建机复制过来的库。它描述的是构建机,不是这个应用。现在它按它本来的性质手写声明:2.35,也就是 runner 的版本,是一个发布决策。
我还把 Windows 的构建从 CI 里去掉了:整段注释掉,并把 build-windows 从 release 的 needs 里移出来。维持一个没人测试的产物是绿的,成本高于它带来的价值。
现在面对一个新测试我会问什么
两件事,只要有一件不成立,我就不再相信它。第一:我有没有见过它因为一个真正破坏行为的改动(而不是破坏形状的改动)而变红?第二:期望值是来自被检验代码之外的地方,还是我从它身上推导出来的?
还有给 CI 的第三条:你测的那个东西必须是别人将要下载的那个文件,而不是你 run 出来的那个。
