4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第7章「テストと品質管理」
第37回:セーブ処理をVitestでどこまでテストするか
1. 今回のテーマ
ここまで、
セーブ処理はかなり複雑になってきました。
扱っているのは、
SaveData validation
saveSchemaVersion
Migration
future schema
current
backup
quarantine
Recovery
IndexedDB
です。
これらを、
変更のたびに、
すべて手作業で確認するのは現実的ではありません。
そこで今回は、
セーブ処理のどこまでをVitestで自動テストするのか
を整理します。
2. まず「全部E2Eで確認する」は避けたい
セーブ処理には、
ブラウザ操作を伴うものもあります。
そのため、
最初から、
Playwrightで全部確認すればよい
とも考えられます。
しかし、
すべてをE2Eにすると、
実行時間が長い
原因切り分けが難しい
テスト準備が大きい
細かな分岐を網羅しにくい
という問題があります。
そこで、
小さいロジックはVitest、ユーザー操作全体はPlaywright
と役割を分けます。
3. Vitestで確認したいのは「ロジック」
Vitestで確認しやすいのは、
例えば、
入力
↓
判定
↓
期待する結果
が明確な処理です。
セーブ機能なら、
SaveData validation
schema判定
Migration
future schema判定
Recovery条件判定
などです。
4. UIを通さずに直接テストする
例えば、
壊れたSaveDataを検証したい場合、
毎回、
ゲーム起動
↓
セーブ画面
↓
データを壊す
↓
ロード
までやる必要はありません。
Vitestでは、
validation関数へ直接、
入力データを渡せます。
以下は考え方を説明するため、実装を簡略化しています。
const result =
validateSaveData(candidate);
そして、
expect(result.status).toBe("invalid");
のように確認します。
5. validationはVitest向き
第34回では、
セーブデータを、
valid
migratable
unsupported
invalid
へ分類しました。
この種の判定は、
Vitestと相性がよい部分です。
例えば、
正常なSchema 3
↓
valid
正常なSchema 2
↓
migratable
Schema 99
↓
unsupported
必須項目欠落
↓
invalid
という入力と期待結果を、
個別にテストできます。
6. JSON破損も分けて確認する
例えば、
{"slotId":
のような、
JSONとして壊れた文字列があります。
これは、
SaveData validationより前の、
parse段階で失敗します。
つまり、
parse error
と、
validation error
を分けて確認できます。
7. 「読めない」と「形式が違う」を分ける
例えば、
JSON parse失敗
と、
future schema
は、
どちらも通常ロードできません。
しかし、
意味は違います。
そこでVitestでも、
JSON破損
↓
invalid
future schema
↓
unsupported
と、
別の期待値を持たせます。
8. MigrationもVitest向き
第31回では、
Schema 2
↓
Schema 3
へのMigrationを扱いました。
Migrationは、
入力と出力が明確です。
例えば、
v2 SaveData
↓
migrateV2ToV3()
↓
v3 SaveData
です。
この処理もVitestで確認できます。
9. Migrationでは何を確認するか
単に、
saveSchemaVersion === 3
だけを見るのでは足りません。
例えば、
slotIdが維持されている
appVersionが保持されている
GameStateが正しく引き継がれている
新しい必須項目が適切に補われている
migratedFromSchemaVersionが設定されている
などを確認します。
10. 「意味が変わっていないか」を見る
Migrationでは、
型が合っているだけでなく、
ゲーム上の意味が保たれているか
が重要です。
例えば、
questProgress
eventProgress
unlockedTownIds
playerStatus
などが、
Migration前後で意図どおり維持されているかを確認します。
11. future schemaもVitestで固定できる
第32回で扱ったfuture schemaも、
Vitestで確認できます。
例えば、
saveSchemaVersion = 99
を与えて、
unsupported
になることを確認します。
12. 「ロード不可」だけではなく「上書き不可」も見る
future schemaでは、
通常ロード拒否
だけでなく、
通常上書き拒否
も重要でした。
そのため、
判定関数があるなら、
canLoad = false
canOverwrite = false
になることを、
Vitestで固定できます。
13. Recovery条件もVitestで確認する
第36回では、
current invalid
+
backup valid
↓
Recovery
を扱いました。
この条件分岐も、
Vitestで確認できます。
例えば、
current valid
backup valid
↓
Recoveryしない
current invalid
backup valid
↓
Recoveryする
current invalid
backup invalid
↓
Recoveryしない
です。
14. Recovery結果を直接確認する
RecoveryをRepositoryレベルまで含めてテストするなら、
例えば、
current invalid
backup valid
という状態を用意します。
その後、
Recovery処理を実行して、
currentが正常になった
元currentがquarantineへ残った
他slotは変わっていない
ことを確認します。
15. currentだけ確認して終わらない
例えば、
Recovery後に、
currentがvalid
だけなら、
一見成功です。
しかし、
元の破損currentが、
消えてしまっている可能性があります。
そのため、
current
backup
quarantine
それぞれの状態まで確認します。
16. backupなしも別ケースにする
例えば、
current invalid
backupなし
があります。
期待する結果は、
Recoveryしない
currentを通常利用しない
quarantineへ保持
slotを再利用可能
です。
正常backupありの場合とは、
別のテストケースとして持ちます。
17. 他スロットが変わらないこともテストする
3スロットある場合、
例えば、
slot_1 valid
slot_2 invalid
slot_3 valid
という状態を用意します。
slot_2をRecoveryした後、
slot_1 unchanged
slot_3 unchanged
も確認します。
18. 「壊れた1件だけ」を直すことを保証する
このテストにより、
slot_2を直したら
slot_1まで書き換わった
といった事故を防ぎやすくなります。
つまり、
Recoveryの正しさだけでなく、
影響範囲
もテストします。
19. IndexedDBをどうVitestで扱うか
現在の正式公開版では、
保存先として、
IndexedDB
+
Dexie.js
を使っています。
しかし、
Vitest実行環境では、
ブラウザのIndexedDBがそのまま使えない場合があります。
そこで、
正式版では、
fake-indexeddb 6.2.5
を利用しています。
20. fake-indexeddbの役割
fake-indexeddbを使うことで、
テスト環境でも、
IndexedDB相当のAPIを使えます。
これにより、
Repository
Migration
Save / Load
backup
quarantine
などを、
ブラウザE2Eだけに頼らず確認できます。
21. Dexie.jsも実際のRepository経由で確認する
例えば、
RepositoryがDexie.jsを使っているなら、
テストでも、
できるだけ、
Repository
↓
Dexie.js
↓
fake-indexeddb
という経路を通します。
これにより、
単なるモック以上に、
実際の永続化層に近い動作を確認できます。
22. 何でもmockすればよいわけではない
例えば、
Repository自体を完全にmockすると、
Save Service
↓
mock
だけになります。
これでは、
Dexie.js側の、
書き込み
読み込み
更新
削除
の実装ミスを見つけられません。
そのため、
どこを本物で動かすか
を意識します。
23. テスト対象ごとに境界を変える
例えば、
純粋なvalidationなら、
Storageは不要です。
SaveData
↓
validateSaveData()
だけで十分です。
一方、
Recoveryなら、
Repository
↓
fake-indexeddb
まで使う価値があります。
24. 単体テストと統合テストを分ける
整理すると、
Vitestの中でも、
Unit Test
↓
小さい関数
Integration Test
↓
複数の処理やRepository
に分けられます。
例えば、
schema判定
↓
Unit
current / backup / quarantine Recovery
↓
Integration寄り
です。
25. テストデータは小さく作る
GameState全体は、
項目が多くなっています。
毎回巨大なJSONを、
手書きでテストへ貼ると、
変更に弱くなります。
そこで、
正常な最小SaveDataを作る
↓
必要な箇所だけ変える
方が扱いやすくなります。
26. factoryを使う
例えば、
以下は考え方を説明するため、実装を簡略化しています。
function createValidSaveData(
overrides: Partial<SaveData> = {}
): SaveData {
return {
saveSchemaVersion: 3,
appVersion: "1.6.0",
slotId: "slot_1",
savedAt: "2026-08-29T00:00:00Z",
state: createValidGameState(),
...overrides,
};
}
これなら、
テストごとに、
変更したい部分だけ指定できます。
27. 壊れたデータも「差分」で作る
例えば、
const save =
createValidSaveData({
slotId: "slot_99" as SaveData["slotId"],
});
のように、
正常データから、
意図的に一部分だけ壊します。
これにより、
何をテストしているのかが分かりやすくなります。
28. ただし型に邪魔されることもある
破損データをテストするときは、
TypeScriptから見ると、
そんな値は作れない
ケースがあります。
これは当然です。
本番コード側では、
その値を作らないよう型で守っているからです。
29. invalidテストではunknownを使う
そのような場合は、
正常なSaveData型として作るのではなく、
unknown
として、
外部入力を再現します。
以下は考え方を説明するため、実装を簡略化しています。
const candidate: unknown = {
saveSchemaVersion: 3,
slotId: "slot_99",
};
これをvalidationへ渡します。
30. テストコードまで型安全にしすぎない
invalidデータのテストで、
無理に、
as SaveData
としてしまうと、
本来、
外部入力として異常な値を確認したいのに、
意図が分かりにくくなります。
そのため、
正常系
↓
SaveData
異常系
↓
unknown
と分ける方が自然です。
31. parameterized testも使える
似たvalidationケースが多い場合は、
表形式でまとめる方法もあります。
以下は考え方を説明するため、実装を簡略化しています。
it.each([
["slot_99", "invalid"],
["slot_1", "valid"],
])(
"slotId=%s => %s",
(slotId, expected) => {
// test
}
);
同じ構造のテストを、
大量に重複させずに済みます。
32. テスト名は仕様として読めるようにする
例えば、
test1
test2
test3
ではなく、
future schemaはunsupportedになる
currentがinvalidでbackupがvalidならRecoveryする
backupなしの破損currentはquarantineされる
のようにします。
テスト名を見るだけで、
仕様が分かる状態を目指します。
33. テストが仕様書の一部になる
セーブ処理は、
分岐が多くなります。
そのため、
テストケース自体が、
この場合はこう動く
という仕様の記録になります。
特に、
unsupported
invalid
migratable
のような、
似た状態を区別するときに役立ちます。
34. 何をVitestで確認しないか
逆に、
Vitestだけでは十分でないものもあります。
例えば、
画面から実際にセーブできる
ボタン操作でロードできる
画面遷移後も状態が正しい
ブラウザ再読み込み後も続きから遊べる
です。
これらは、
UI・Router・Storage・GameStateがつながった状態を確認する必要があります。
35. それはPlaywright側へ任せる
そこで、
小さなロジック
↓
Vitest
ユーザー操作全体
↓
Playwright
という分担にします。
同じ内容を、
両方で完全に重複させる必要はありません。
36. ただし重要な仕様は層をまたいで確認する
例えば、
current破損
+
backup正常
↓
Recovery
は重要です。
そのため、
Vitestでは、
Recoveryロジック
を確認し、
Playwrightでは、
実際にゲームから再開できる
ことを確認します。
37. 同じ仕様を違う角度から見る
つまり、
Vitest
↓
ロジックが正しいか
Playwright
↓
ユーザー操作として成立するか
です。
重複というより、
異なる層で同じ品質を確認する
と考えます。
38. Vitestで確認したい主なセーブケース
ここまでをまとめると、
Vitest側では、
例えば、
- 正常Schema 3のvalidation
- 旧Schemaのmigratable判定
- future schemaのunsupported判定
- 不正データのinvalid判定
- v2→v3 Migration
- current invalid+backup validのRecovery
- current invalid+backupなし
- current invalid+backup invalid
- quarantineへの退避
- 他スロット非干渉
- IndexedDB Repositoryのsave / load
などを確認できます。
39. すべての組み合わせを無限に増やさない
とはいえ、
全項目
×
全schema
×
全slot
×
全Recovery状態
を網羅すると、
テスト数は際限なく増えます。
そこで、
設計上の分岐点
を中心にテストします。
40. 仕様上意味が変わる境界を優先する
例えば、
Schema 3
↓
compatible
Schema 2
↓
migratable
Schema 99
↓
unsupported
は、
意味が変わる境界です。
同様に、
backupあり
backupなし
backup invalid
も、
Recoveryの結果が変わります。
こうした境界を優先します。
41. happy pathだけでは足りない
ゲーム開発では、
まず、
正常にセーブできる
正常にロードできる
を確認したくなります。
しかし、
今回のような品質基盤では、
むしろ、
壊れている
古い
新しすぎる
復旧元がない
といったケースが重要です。
42. 異常系こそ自動化の価値が高い
正常系は、
ゲームを遊んでいれば、
比較的確認しやすい部分です。
一方、
future schema 99を作る
currentだけ壊す
backupだけ正常にする
といった状態は、
毎回手作業で作るのが面倒です。
だからこそ、
自動テストに向いています。
43. 正式公開版では68テストまで増えた
最終的な正式公開版では、
Vitestは、
7/7 files・68/68 tests
をPASSしています。
ここで重要なのは、
数字そのものではありません。
セーブ互換性やRecoveryを含めて、
公開前に繰り返し確認できる状態にしたこと
です。
44. テスト数を目標にしない
例えば、
100テストある
こと自体が、
品質を保証するわけではありません。
同じような正常系を100個並べても、
重要な分岐が抜けていれば、
問題は残ります。
見るべきなのは、
重要な仕様分岐が
テストで固定されているか
です。
45. 変更するとテストが守ってくれる
例えば、
将来、
SaveDataの項目を変更したとします。
その結果、
MigrationやRecoveryへ影響が出た場合、
既存テストが失敗すれば、
どこかの互換性が壊れた
ことに気づけます。
46. テストは「壊さないための記憶」になる
人間は、
以前なぜその分岐を入れたのかを、
時間とともに忘れることがあります。
しかし、
future schemaは通常上書きしない
というテストが残っていれば、
将来の変更時にも、
そのルールを守れます。
47. 今回のポイント
今回のポイントは3つです。
- Vitestでは、validation・schema判定・Migration・Recoveryなど、入力と結果が明確なロジックを中心に確認する
- IndexedDBを使う処理はfake-indexeddbを使い、Repositoryまで含めた統合テストも行う
- UI全体はPlaywrightへ任せ、VitestとE2Eで役割を分ける
整理すると、
Pure Logic
↓
Vitest
Repository / IndexedDB
↓
Vitest + fake-indexeddb
User Flow
↓
Playwright
です。
テストの目的は、
テスト数を増やすことではなく、変更しても守りたい仕様を自動で確認できるようにすること
です。
48. 次回
次回は、
手動確認していた冒険をPlaywrightでE2Eテストにする【第38回】
です。
ここまでVitestでは、
validation
Migration
Recovery
Repository
を確認しました。
しかし、
プレイヤーが実際に行うのは、
タイトル
↓
キャラクター作成
↓
町
↓
依頼
↓
セーブ
↓
ロード
↓
再開
という一連の操作です。
次回は、
これをPlaywrightで、
実際のブラウザ操作として自動化する
ところへ進みます。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第36回 壊れたcurrentをbackupから復旧する――Recoveryの実装フロー
次の記事
第38回 手動確認していた冒険をPlaywrightでE2Eテストにする
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
手動確認していた冒険を、E2Eテストにする #21
noteでは、これまで人が画面を操作して確認していた流れを自動化し、VitestとPlaywrightで役割を分けた背景を制作側の視点から書いています。