把测试的前提改成“实际游玩时拍摄的存档”后,拍摄机器人发现了软锁。
テストの前提を「実際に遊んで撮ったセーブ」に替えたら、撮影ボットがソフトロックを見つけた
把让 AI 智能体编写的 E2E 测试的前置状态,从直接改写数值,改成了“实际游玩并拍摄下来的存档”。对象是 Web 回合制游戏、Godot 游戏、用于量产的 Web 游戏模板这 3 个。引入当天,就用真实数据确认了停滞近半年的存档格式迁移。在 Godot 那边,遇到了拍摄用 bot 无法脱身的状态(软锁)。这是由于测试用 bot 每次都会把状态重置为初始值,所以此前的测试中没有暴露出来。
其基础思路来自 unsoluble_sugar 的文章《基于存档数据快照的 E2E 测试基础设施》。把 Unity 手游中的做法,带到了使用 localStorage 的 Web 游戏和 Godot 中。
用改写数值制作的前置条件,并不能保证在真实存档中正常运行
Web 游戏里有这样一项 E2E 测试,概括来说如下。
await jumpTo(page, checkpointId); // 回復地点へワープ
await page.evaluate(() => store.__testSetHp(5)); // 体力の値を5にする
await page.evaluate(() => store.heal());
这是检查恢复处理是否生效的测试。不过,通过传送抵达的状态,与按正常流程推进后抵达的状态并不相同。途中获得的东西、持有物品的数量、上一场胜负的状态等都有差异。把强制胜利、强制失败、传送、注入持有物品都算上,这样的改写共有 14 个 spec、18 处。
因为可以一行创建前置条件,所以每次添加测试时都会使用这个 hook。
修复方针按照原文章的原则,即游玩路径(种子、选项的选择方式、输入)可以固定,但结果状态要由常规代码路径写入。
在每次测试中加载实际游玩时捕获的全部存档
改动的是以下3处。
- 录制:机器人只通过和玩家相同的操作游玩,并在分段处将 localStorage 整个写入文件
- 回归测试:让
fixtures/saves/<アプリの版>/<名前>.json无一遗漏地经过与产品相同的加载路径 - E2E:先将该文件放入 localStorage 再启动,以形成前置状态
Fixture 是这样的形式。
{
"manifest": {
"name": "first-checkpoint-reached",
"appVersion": "0.0.1",
"generation": "CAPTURE_FIXTURES=1 npx playwright test capture_fixtures",
"description": "2戦勝って最短路で最初の回復地点に着いた直後。体力は31/50まで減っている",
"invariants": { "currentNodeId": "n2_0", "itemCount": 12, "hp": 31 }
},
"entries": { "game.run.v3": "{\"version\":3, ...}" }
}
entries 中放入的是保存下来的原始字符串。invariants 是加载后仍应保留的值。回归测试通过现行代码的 API 读取这个值。这样即使迁移改变了字段名,也能用相同的名称进行比较。
回复测试改写成了从这个 Fixture 开始的形式。不再把体力设为 5,而是以“还有回复余地”为前提进行确认。
await bootFromFixture(page, "0.0.1/first-checkpoint-reached.json");
const before = await getState(page);
expect(kindOf(before), "前提: 回復地点にいる").toBe("checkpoint");
expect(before.hp, "前提: 回復の余地がある").toBeLessThan(before.maxHp);
如果没能读取 Fixture 而从头开始,就会在第一个 expect 处失败。
败北测试连 Fixture 都不需要。不再强制败北,而是让 bot 什么都不做、持续让出回合,从而因对手的攻击而落败。
把刚拍摄后的不变条件留空,让测试自己说出当前值
如果拍摄脚本自动填入 invariants,那就只是照抄“当时读到的值”,检查就失去意义了。
因此拍摄刚结束时先保持为 null,故意让回归测试失败。
前提: 0.0.1/first-checkpoint-reached.json の manifest.invariants が未記入。
いまロードすると {"currentNodeId":"n2_0","itemCount":12,"hp":31,...}。
撮ったときの画面と合っているか確かめてから書く
抄写是在把这个值与原始存档对照之后才进行的。这次也用原始 JSON 确认了相同地点、相同件数、相同体力一致之后才填写。
即使夹具(fixture)为 0 件时,也会以“前提:”判为失败。这是为了防止从删除夹具的那天起,明明什么都没检查却一直显示绿色。
停滞的 v4 迁移:得知可以读取 v3 的实际存档
Web 游戏的仓库里,从 4 月起仍有 34 个文件保持着未提交的更改。这是把存档格式从 v3 升级到 v4 的工作中途。
没有动这项工作,而是从 HEAD(v3)切出另一个 worktree 来抓取 fixture。之后,又把中途的 diff 应用到另一个临时 worktree 上,让它读取 v3 的真实存档,结果就是这样。
✓ tests/save_fixtures.test.ts (2 tests)
中途的 loader 把 v3 的键当作旧格式读取,并在保持不变量不变的前提下将其提升到 v4。此前的迁移测试只使用了手写的「像 v3 的数据」,而让实际游玩中写出的 v3 存档通过测试,这是第一次。重新开始工作时的完成条件也可以写成「这个 fixture 能够被读取」。
拍摄 bot 发现了隐藏在测试用 bot 初始化中的软锁
在 Godot 的游戏中,我把拍摄交给了子代理。原计划截取越过第一个节点后紧接着的自动存档,但 bot 无法推进到那里。尝试了 3 个种子,每一个都是挑战 60 次也无法越过第一个节点。
调查后发现,将体力变为 0 的状态恢复回来的处理,在游戏里没有任何地方调用。第一次挑战失败后,手牌会以体力 0 的状态残留下来。从那以后每次都是刚开始就输。这是玩家操作无法摆脱的状态。
既有的平衡测试中,这个问题一次都没有出现过。因为测试用 bot Bot.simulate 在挑战前每次都会用 restore() 把状态重置为初始值。
录制用的 bot 只依靠游戏本体的处理来游玩,所以没有这个初始化。因此,它停在了和玩家相同的地方。
修复方法是在游戏侧加入处理:结束挑战并返回时,恢复所有人的体力。由于存档只会在挑战之间写入,所以加载时也要经过同样的处理,把修复前的存档中仍残留为体力 0 的部分也恢复。测试有两项:“失败返回后所有人都已恢复”和“读取体力为 0 的存档后会恢复”。去掉修复后,这两项测试都失败了。
测试用 bot 的 restore() 也移除了。既然游戏侧已经有恢复处理,bot 就没有理由自行恢复。如果保留下来,下次游戏侧处理缺失时,又会以同样的方式被掩盖。
通过同一项工作还发现,现有测试在每次 verify 时都会在生产环境的 user:// 中创建存档槽,然后又将其删除。测试的保存位置已分离到 user://verify/tests/。
改写调用只减不增
这次修复的只有恢复和败北这两处,Web 游戏中还有 15 处改写。多数是强制胜利。
为了不让剩余数量增加,添加了统计调用次数的测试。
const INJECTION_CALL = /\b(forceVictory|forceDefeat|jumpTo|grantItem)\(|__test[A-Z]\w*\(/g;
const BASELINE = 15;
expect(total, `状態注入が ${total} 箇所(基準 ${BASELINE})`).toBeLessThanOrEqual(BASELINE);
expect(total, `${total} 箇所に減った。BASELINE を ${total} に下げて固定する`).toBe(BASELINE);
一旦增加就会失败,减少时也会要求降低基准值并失败。当代理在新的E2E中使用改写钩子时,会被这个测试拦住。
要拍摄指定的节点,需要针对每个游戏的机器人
Web 模板的拍摄只是用 smoke 测试中的随机输入来玩。可以指定玩到哪个按键被改写为止,但无法瞄准像“刚通关第3关之后”这样的节点。在 Web 游戏中之所以能到达恢复点,是因为让机器人掌握了获胜方式和路线选择。
Fixture 的存档是挤在一行里的 JSON 字符串,所以即使查看差异也难以评审。至于是什么存档,则可以通过 manifest 的说明和拍摄步骤读出来。
总结
我们把 E2E 的前提从改写数值改成了实际游玩并拍摄下来的存档。能够用旧版存档确认迁移,这一点正如所料。没有预料到的是,Godot 游戏的拍摄机器人停在了第一个区隔处,并发现了无法脱身的状态。
Web 游戏的改写钩子,以及 Godot 测试用机器人的初始化,都是为了更容易编写测试而加入的。两者都让只有测试走了玩家不会走的路径。现在,在添加可轻松构建前提的机制之前,我们会先确认玩家是否真的能够到达那个状态。
来源:zenn · 游戏开发社区 · zenn.dev