把测试的前提改成“实际游玩时拍摄的存档”后,拍摄机器人发现了软锁。
テストの前提を「実際に遊んで撮ったセーブ」に替えたら、撮影ボットがソフトロックを見つけた
AIエージェントに書かせたE2Eテストの前提状態を、値の直接書き換えから「実際に遊んで撮ったセーブ」に替えました。対象はWebのターン制ゲーム、Godotのゲーム、量産用のWebゲームテンプレートの3つです。入れたその日に、半年近く止まっていたセーブ形式の移行を実データで確かめられました。Godotのほうでは、撮影用のボットが抜けられない状態(ソフトロック)に突き当たりました。テスト用のボットが毎回状態を初期値に戻していたせいで、これまでのテストには出ていなかったものです。
元になった考え方は、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箇所ありました。
前提を一行で作れるので、テストを足すたびにこのフックが使われていました。
直す方針は元記事の原則のとおりで、遊ぶ道筋(シード、選択肢の選び方、入力)は固定してよいが、結果の状態は通常のコードパスで書かせる、というものです。
実プレイで撮ったセーブを、毎回のテストで全件ロードする
変えたのは次の3か所。
- 撮影: ボットがプレイヤーと同じ操作だけで遊び、区切りでlocalStorageを丸ごとファイルに書き出す
- 回帰テスト:
fixtures/saves/<アプリの版>/<名前>.jsonを1件残らず、製品と同じロード経路に通す - E2E: そのファイルをlocalStorageに入れてから起動し、前提状態にする
フィクスチャはこういう形です。
{
"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を通して読みます。マイグレーションでフィールド名が変わっても、同じ名前で比べられるようにするためです。
回復のテストは、このフィクスチャから始まる形に書き直しました。体力を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);
フィクスチャを読めずに最初から始まった場合は、1つ目の expect で落ちます。
敗北のテストは、フィクスチャすら要りませんでした。強制敗北をやめ、ボットが何もせずに手番を渡し続けて、相手の攻撃で負けるようにしました。
撮った直後の不変条件は空にして、テストに現在値を言わせる
撮影スクリプトが invariants を自動で埋めたら、「そのとき読めた値」を写すだけで、検査の意味が無くなります。
そこで撮影直後は null にしておき、回帰テストをわざと落とします。
前提: 0.0.1/first-checkpoint-reached.json の manifest.invariants が未記入。
いまロードすると {"currentNodeId":"n2_0","itemCount":12,"hp":31,...}。
撮ったときの画面と合っているか確かめてから書く
書き写すのは、この値を生のセーブと見比べてからです。今回は生のJSONでも同じ地点・同じ件数・同じ体力で、一致を確かめてから記入しました。
フィクスチャが0件のときも「前提:」で落とします。フィクスチャを消した日から、何も見ていないのに緑を出し続けるのを防ぐためです。
止まっていたv4への移行が、v3の実セーブを読めると分かった
Webのゲームのリポジトリには、4月から未コミットのままの変更が34ファイル残っていました。セーブ形式をv3からv4に上げる作業の途中です。
この作業には手を付けず、HEAD(v3)から別のworktreeを切ってフィクスチャを撮りました。そのあと、もう1つ一時的なworktreeに途中の差分を当て、v3の実セーブを読ませた結果がこれです。
✓ tests/save_fixtures.test.ts (2 tests)
途中のローダーは、v3のキーを旧形式として読み、不変条件を保ったままv4へ引き上げていました。それまでの移行テストは手書きの「v3らしいデータ」しか使っておらず、実際に遊んで書かれたv3のセーブを通したのはこれが初めてです。作業を再開するときの完了条件も「このフィクスチャが読めること」と書けるようになりました。
撮影ボットが、テスト用ボットの初期化に隠れていたソフトロックを見つけた
Godotのゲームでは、撮影をサブエージェントに任せました。最初の区切りを越えた直後のオートセーブを撮る予定でしたが、ボットがそこまで進めません。シードを3つ試し、どれも60回挑戦して最初の区切りを越えられませんでした。
調べると、体力が0になった状態を戻す処理が、ゲームのどこからも呼ばれていませんでした。最初の挑戦で負けると、手持ちが体力0のまま残ります。それ以降は毎回、始まってすぐに負けます。プレイヤーの操作では抜けられない状態です。
既存のバランステストには、これが一度も出ていませんでした。テスト用のボット Bot.simulate が、挑戦の前に毎回 restore() で状態を初期値に戻していたからです。
撮影用のボットはゲーム本体の処理だけで遊ぶので、この初期化がありません。そのため、プレイヤーと同じところで止まりました。
直し方は、挑戦を終えて戻ったときに全員の体力を戻す処理をゲーム側に入れることでした。セーブは挑戦の合間にしか書かれないので、ロードしたときにも同じ処理を通し、修正前のセーブに体力0のまま残っていた分も戻します。テストは「負けて戻ったら全員戻っている」と「体力0のセーブを読むと戻っている」の2本です。修正を外すと、どちらも落ちました。
テスト用ボットの restore() も外しました。ゲーム側に戻す処理がある以上、ボットが独自に戻す理由はありません。残しておくと、次にゲーム側の処理が抜けたときにも、また同じように隠れてしまいます。
同じ作業で、既存のテストがverifyのたびに本番の user:// にセーブスロットを作っては消していたことも分かりました。テストの保存先は user://verify/tests/ に分けています。
書き換えの呼び出しは、減らす一方にする
今回直したのは回復と敗北の2本だけで、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のゲームで回復地点まで行けたのは、勝ち方と道の選び方をボットに持たせたからです。
フィクスチャのセーブは1行に詰まったJSON文字列なので、差分を見てもレビューしにくいです。何のセーブかは、manifestの説明と撮影手順で読めるようにしています。
まとめ
E2Eの前提を、値の書き換えから実際に遊んで撮ったセーブに替えました。旧版のセーブで移行を確かめられるようになったのは、狙っていたとおりです。予想していなかったのは、Godotのゲームの撮影ボットが最初の区切りで止まり、抜けられない状態が見つかったことでした。
Webのゲームの書き換えフックも、Godotのテスト用ボットの初期化も、テストを書きやすくするために入れたものです。どちらも、プレイヤーが通らない道をテストにだけ通らせていました。いまは前提を楽に作る仕組みを足す前に、プレイヤーが実際にその状態まで来られるかを確かめるようにしています。
来源:zenn · 游戏开发社区 · zenn.dev