← 全部文章

测试全绿,功能没接上:一批门禁是怎么骗我的

本文目录
  1. 一、最贵的一个坑:管道吞掉了退出码
  2. 二、“全绿”和”真的好了”之间隔着一个棘轮
  3. 三、假红:一大片”回归”其实是环境问题
  4. 四、还有一类假红:前一步把状态文件写脏了
  5. 五、测试脚本自己也会撒谎
  6. 六、测试绿了,但玩家的路没人走
  7. 七、让门禁没法被糊弄:四个我固定用的手法
  8. 八、成本账
  9. 九、这篇其实在讲什么

项目里现在有一百多个门禁脚本,全量跑一遍二十多分钟。

这篇不是讲”我写了多少测试”,是讲这些测试怎么骗我。


一、最贵的一个坑:管道吞掉了退出码

这个坑到现在我还觉得是全场最值钱的。

构建脚本里我写了这么一句:

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 处悬空引用,就被固化成了一条”通过”。套件全绿,缺口从待办清单里整体消失,几个月后有人来看这个项目,会得出结论”数据完整性没问题”。

后来我给它补了三条补丁,我觉得这三条比门禁本身还重要:

  1. 基线数字必须在仓库里有份人能读的镜像(写进 HANDOFF 文档逐类列出)。只活在脚本常量里的数字,没人会把它当待办——它会一直等于当前值。
  2. 等值即告警”。 当前 == 基线的时候,显式打印”N 处未修复、基线未下调”,不要静默通过。脚本原本的提示是”比基线少 N 处、请下调基线吧”——它只在真修了的时候出现;一处不修的时候,它一言不发。
  3. 建完必须证明它会红。 注入一条悬空引用,或者把某个基线数字临时下调 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 倍)> 数据懒加载。

但我故意没开并行。 因为前面第四节那个状态文件污染的坑还没从结构上解决——并行只会把”假红”变成随机的假红。

教训:优化测试速度之前,先确认测试之间没有共享可写状态。否则你优化出来的是随机性。


九、这篇其实在讲什么

把上面的东西抽象一层:

  1. 先怀疑测量工具,再怀疑被测对象。 这一篇里超过一半的内容,根因都在”验证这件事本身”。
  2. “没报错”和”成功”是两件事。 管道会吞退出码,脚本会静默空跑,改动可能根本没落盘。
  3. 门禁的价值等于它判红的能力。 没验过会红的门禁,是一张假的完成证明。
  4. 棘轮防的是恶化,不是缺失。 那个差额必须有名字,否则它会消失。
  5. 钩子能跑不代表玩家能玩。 这句话我在这个项目上说了很多遍。
  6. 红之前先分流:环境、期望值、还是代码。 顺序反了就是白查一轮。

写到这里我才意识到,这篇和上一篇其实是同一个题目的两面。

上一篇讲的是代码安静地差一点点;这篇讲的是测试安静地告诉你一切正常。

两个都不报错。两个都要你先学会怀疑,才能开始查。