项目里现在有一百多个门禁脚本,全量跑一遍二十多分钟。
这篇不是讲”我写了多少测试”,是讲这些测试怎么骗我。
一、最贵的一个坑:管道吞掉了退出码
这个坑到现在我还觉得是全场最值钱的。
构建脚本里我写了这么一句:
bash build.sh | tail -20
意图很朴素:构建输出太长,只想看最后 20 行。
某天我盯着这个命令看了一会儿,忽然意识到一件事:这条命令的 $? 是 tail 的退出码,不是 build.sh 的。
也就是说——编译失败的时候,这条命令返回成功。
这个坑掩盖了多久我不知道。它的可怕之处在于:它不制造错误结论,它删掉你发现错误的机会。构建挂了 → 构建脚本报”完成” → 测试继续跑 → 跑的是上一次的 exe → 你看到的是旧结果。
修法是把管道拆掉,用真实退出码:
bash build.sh > log 2>&1; rc=$?
if [ "$rc" -ne 0 ]; then echo "BUILD EXIT: $rc"; exit 1; fi
或者至少在脚本开头写 set -o pipefail。
但比修法更重要的是我后来定的规矩:构建必须打印出 BUILD EXIT: 0 才算过。
为什么不一件事说两遍?因为”命令没报错”和”命令成功了”是两件事,而人眼在滚动的日志里分辨不出这两件事。
教训:任何验证/构建管线,先从”失败的时候它真的会失败吗”查起。测试能跑、脚本能返回 0,都不代表它在工作。
二、“全绿”和”真的好了”之间隔着一个棘轮
数据驱动的东西最怕的不是崩溃,是悬空引用——配方说要 3 个不存在的材料、建造产出指向一个没有定义的地形。这些东西不会报错,只会在游戏里表现成”这个配方永远做不了”。
所以我写了个门禁扫全部引用面:配方、建造、采集、拆解、屠宰、畜牧、种植、掉落、燃料,九类各一个数字。
第一次跑出来的结果,我到现在还记得:
161 处悬空引用。 建造 48、配方 80、掉落 30、种植 3。
161 处不可能一次修完,所以我把它们变成了棘轮(ratchet)基线:跑的时候只报告不失败,把当前的实测值写进脚本常量;此后哪个类别超出基线就 exit 1。
意思很明确:旧的缺口不阻塞,新的缺口立刻拦。
这个设计本身没问题。问题在它有个安静的副作用——
当”当前值 == 基线”恒成立的时候,门禁照样 exit 0。
于是这 161 处悬空引用,就被固化成了一条”通过”。套件全绿,缺口从待办清单里整体消失,几个月后有人来看这个项目,会得出结论”数据完整性没问题”。
后来我给它补了三条补丁,我觉得这三条比门禁本身还重要:
- 基线数字必须在仓库里有份人能读的镜像(写进 HANDOFF 文档逐类列出)。只活在脚本常量里的数字,没人会把它当待办——它会一直等于当前值。
- 等值即告警”。 当前 == 基线的时候,显式打印”N 处未修复、基线未下调”,不要静默通过。脚本原本的提示是”比基线少 N 处、请下调基线吧”——它只在真修了的时候出现;一处不修的时候,它一言不发。
- 建完必须证明它会红。 注入一条悬空引用,或者把某个基线数字临时下调 1,跑一次确认 exit 1。
第三条值得单独说:没验过判红能力的门禁,等于没有门禁。 而它的存在比”没有门禁”更糟——因为它会被当成”这张卡已经完成”的证据。
一句话口径:棘轮的下限是”不再恶化”,不是”已完成”。这两者之间的差额必须有个名字留在文档里。
三、假红:一大片”回归”其实是环境问题
有一段时间套件跑出来一片红,几十项。第一反应当然是”我刚改坏了什么”,然后开始翻自己的改动。
这类误判我后来统计出三种固定的环境性假红,现在跑之前先排除:
① 解释器缺 PIL → 整片假红
几十个门禁失败,日志里是 No module named 'PIL'。原因是 shell 的 PATH 里的 python 是另外一个 Python 环境,没有 Pillow——而这个项目大量的门禁是像素级判据,全都要 PIL。
实测:同一步换解释器跑,一边 14 项全红,一边全绿。是环境不是代码。
现在的做法是让”用哪个 Python”有单一真源(OUTLAND_PYTHON → 项目既定路径 → PATH 里第一个能 import PIL 的),缺 PIL 的时候套件直接中止并告诉你怎么修。判据是”别拿 PATH 里的 python 直接跑门禁”。
② exe 秒退 0xC0000142 → 100+ 步假红
症状很吓人:套件跑到某一步之后,从这一步起每一步都返回 3221225794,且耗时 0.0 秒。
这个码是 STATUS_DLL_INIT_FAILED——进程在创建阶段就死了,一个字都没执行。跟游戏代码毫无关系。
排除过程也走了弯路:我一度怀疑是杀软、是并发太密。实测并发 12 组 × 30 次 = 360 次启动全部 rc=0,全清白。真正的规律是”跑满一批之后才出现”,属于累积会话/资源耗尽这类问题。
现在的做法是套件开头先起一次最小实例探针,命中就主动中止并明说”这不是代码回归”。
这条给了一条很好用的直觉:看到 0.0 秒的秒退红,先当环境问题查。
③ 改了源码但跑的还是旧 exe
症状更阴:报告显示 BUILD EXIT: 0,但 exe 的时间戳没变,改动”没生效”——门禁依然红,或者依然绿,让你误以为通过了。
判据是”构建只花了 3 秒 = 没重编”。改完源码要 touch 再构建,并且比对 exe 时间戳确认真的重编了。
四、还有一类假红:前一步把状态文件写脏了
有一批精灵/像素类门禁会集体红,报的都是”差异 0 / tiles=0 / 回退 ASCII”。
排查半天,根因是一个运行期状态文件(记录”用图块还是用 ASCII”)被上一步的门禁写脏了,后面每一步读到错的模式。
修法是在套件里给每一步做隔离——把它移走,而不是写 0/1。因为”写 0”和”文件不存在”在这个项目里走了两条不同的默认分支,前者会让 6 项门禁假红。
反过来说,单独跑某个门禁没有这层保护,你必须自己记得移走它。
教训:测试之间共享可写状态文件 = 假红的定时炸弹。这条也是我到现在都没敢打开”套件并行”开关的原因。
(顺带一提,同样的问题在这项目的窗口采样上也出现过一次:我去检查”测试有没有弹窗打扰我”,第一版判据是”前台窗口是不是 Outland”,结果一次全量套件报出 86 次假弹窗——因为 GetForegroundWindow() 在窗口创建/销毁的瞬间会返回隐藏窗口。判据收紧到”只认可见窗口”之后归零。这不是放宽强度,是把判据改对。)
五、测试脚本自己也会撒谎
这一类最难查,因为你会去怀疑被测代码,而真正有问题的是测量工具。
① 静默空跑
有个测试辅助函数 debug_give(id, n=0)。用例里写 --give <物品> 不带数量时,循环 0 次、没有任何报错,整轮测试变成空跑。症状是”刚 give 的物品数量是 0”,看起来像功能坏了。
② 断言了绝对初值
我写”买入后背包里有 1 发 9mm 子弹”——必红。因为玩家开局自带 24 发(初始装备)。
正确形状是跑两遍取差分:不带成交动作的那遍取基线(钱 100、子弹 24),带动作的那遍取结果(钱 98、子弹 25),断言位移(钱 −2 且物 +1)。
凡初值不为零的量——背包、金钱、属性、世界里已有的 NPC——都得这么办。
③ 读结论别只看尾部
套件末尾会打印失败清单。我用 tail -5 看,觉得只有 4 项失败;实际 grep 全量日志是 9 项红。
而且日志里大量 FAIL 行是负例夹具的自报(故意做的反例),不是套件失败。权威取法只有一个:从全量日志里 grep 出 exit=1 的步骤清单。
④ 退出码 2 不等于”门禁红了”
门禁脚本名和套件的步骤名是两套名字。我按步骤名猜脚本名,点到不存在的文件,得到 rc=2(文件不存在),却在报告里写成”门禁红了/已知偶发假红”——把一个真问题整轮掩盖掉。
rc=2(文件不存在)和 rc=1(断言失败)语义完全不同。拿不准就直接跑全量套件,别自己拼门禁清单。
⑤ 改文件的脚本报错 = 改动根本没落盘
整段 Python 在编译期抛 SyntaxError 时一行都不会执行,而你会以为”已经改了”。实测:一次”修了但没修”,让我把已经修好的行为当成未修,白跑两轮门禁。
规矩:改完文件立刻回读校验(grep 新片段);写补丁脚本一律 assert old in src 前置断言,别默默 replace 一个不存在的字符串。
六、测试绿了,但玩家的路没人走
这条是我觉得最值得写进任何项目的。
引擎的输入有两条路:
--sim脚本 → 走一个硬编码的字符映射表- 真人按键 → 走键位表(只查表,不回落字符映射)
后果是:某个键只写进了字符映射、没写进默认键位表时——
--sim 门禁全绿,真人按键完全没反应。
实测就发生了一次:帮助页上写着「长等待菜单 (5)」,而键位表里只有小键盘的 KP_5。没有小键盘的玩家,这个功能根本不可达。 而为它写的门禁一直只跑 sim 路径,所以一直没发现。
修法有两层:键位表单独加静态断言(门禁同时钉住”表里有这条绑定”和”行为生效”);以及一条更通用的规矩:
“钩子能驱动”不等于”玩家能达到”。凡断言”玩家能做到 X”,先确认 X 走的那条路,就是钩子走的那条路。
推论还有一条:自动化回归全绿,不能替代真人实机验收。这个项目上已经出现过好几次”钩子路径全绿、用户一玩就报障”。
七、让门禁没法被糊弄:四个我固定用的手法
① 判据用差分,不写死数值
固定 seed,只改一个自变量,断言两条命令的输出”相等/不等”。
写死数字的断言会随内容调整永久恒红——项目里就有这么坏掉的一条。
② 每条差分断言配一条对照组
这是防”假绿”的。举个实例:我断言”某攻击打在高护甲目标上伤害更低”。但如果整条护甲通路坏掉了,“两者相同”这个断言照样成立——它会开开心心给你一个绿。
对照组的作用是:在一个两个世界都必须通过的前提下,证明通路真的在工作。
③ 反向自检必须真能证伪
写完门禁,把实现临时改回旧路径 → 重建 → 门禁必须红 → 还原 → 复跑绿。
而且”改回旧路径”本身也有个坑:判据两边如果读的是同一个文件,“删一条数据”证伪不了它。 两边同变、判据照样相等。必须改另一个输入(比如往禁用列表里塞一条)才会红。
写反例之前先问一句:我改的这个量,判据两边是不是都在读它?
④ 门禁报红先分流:期望值过期 vs 代码真的坏了
这条救过我好几次。门禁红了,第一步不是改代码,而是把这条断言当初针对的规则从 git 里捞出来看:
git log --oneline -- tools/verify_X.py # 找写它的提交
git show <那个提交>^:src/game/game.cpp # 读当时的实现
十有八九是:期望值写在某次重构之前,重构之后真值改由共享控件给出了——期望值过期了,代码没坏。
把过期的期望值当成”功能回归”去回退功能,是这类维护里最贵的误判。反过来,更新期望值时必须由独立实测推导,不允许照抄被测值(照抄就变成”测量工具和被测对象手拉手”)。
八、成本账
最后说点工程上的实话。
全量套件 20~30 分钟。我算过它花在哪:
- 约 79% 的时间是反复冷启动游戏进程
- 单次启动约 1.5 秒,大头是开局重读全部数据表(配方门槛、建造、家具、拆解……)
- 全库约 859 个进程调用点 → 累计约 1288 秒
- 实测全量约 1866 秒(31 分钟出头),当时的步数记录是 104 步——门禁只会越加越多,这个数字也只会更长
最贵的一步是一个缩放类门禁,约 112 次启动、135 秒。
加速方向按性价比排:门禁内批处理(一个断言起一次进程,可以合并成一次启动跑多个场景,收益最大、风险最低)> 套件级并行(实测 4 路 2.5 倍)> 数据懒加载。
但我故意没开并行。 因为前面第四节那个状态文件污染的坑还没从结构上解决——并行只会把”假红”变成随机的假红。
教训:优化测试速度之前,先确认测试之间没有共享可写状态。否则你优化出来的是随机性。
九、这篇其实在讲什么
把上面的东西抽象一层:
- 先怀疑测量工具,再怀疑被测对象。 这一篇里超过一半的内容,根因都在”验证这件事本身”。
- “没报错”和”成功”是两件事。 管道会吞退出码,脚本会静默空跑,改动可能根本没落盘。
- 门禁的价值等于它判红的能力。 没验过会红的门禁,是一张假的完成证明。
- 棘轮防的是恶化,不是缺失。 那个差额必须有名字,否则它会消失。
- 钩子能跑不代表玩家能玩。 这句话我在这个项目上说了很多遍。
- 红之前先分流:环境、期望值、还是代码。 顺序反了就是白查一轮。
写到这里我才意识到,这篇和上一篇其实是同一个题目的两面。
上一篇讲的是代码安静地差一点点;这篇讲的是测试安静地告诉你一切正常。
两个都不报错。两个都要你先学会怀疑,才能开始查。