保存まわりの既存テストが通ることと、書き込みの途中で1つのキーだけ失敗したときの振る舞いを確認することは、別でした。
dayGLANCEの公開ソースを変更せず、既存のVitest環境へ124行のテストを1ファイル追加しました。保存モジュールに隣接する既存18件と、新しい正常対照1件はPASS。localStorage.setItem() の対象キー1つだけを拒否すると、古いログ全体は保持された一方、2つの更新markerは先へ進みました。
その観測、再実行手順、別のアプリへ持っていくときの注意点をまとめます。ブラウザの容量を実際に埋めた検証ではなく、実際のsave/loadモジュールへ模擬故障を与えた単体試験です。
既存のVitestで自分のアプリを検査する場合は、App Crash Labのデータ層検査の依頼文をコピーし、いつものコーディングAIへ渡せます。本体やスキルの導入は不要で、アプリ側の既存Vitestを使います。ブラウザを検査する依頼とは入口を分けています。
再実行するファイルと結果
2026年10月19日、Node 22.16.0 / Vitest 4.1.11での運営側の実行結果です。アプリはv5.9.0、コミットはa48be4b242b2741877edd4af71acc73f3f497c00です。
| 検査 | 結果 |
|---|---|
| 隣接する既存の保存テスト | 18 PASS |
| 正常な保存と、実loadDataによる全ログの読み込み | 1 PASS |
| habit-logの保存だけ拒否し、旧ログと選んだmarkerを比較 | 1 FAIL:markerの期待値2箇所が不一致 |
合計19 PASS / 1 FAIL、skipは0、終了コードは1でした。全プロジェクトのテストを実行したという意味ではありません。
すべての書き込みを失敗させない
今回の対象キーはday-planner-habit-logsです。日時と2つの習慣IDを持つ架空のログを使っています。
全キーを無条件に拒否すると、テストの準備や無関係な設定まで壊れ、どの失敗を見ているのか分からなくなります。そこで、対象キーへの書き込みだけQuotaExceededErrorを投げ、他のキーはMapへ保存しました。
以下は配布したテストの実際の抜粋です。周辺の定義・importを含む完全なファイルはZIPに入っています。
vi.stubGlobal('localStorage', {
getItem: key => store.get(String(key)) ?? null,
setItem: (key, value) => {
if (rejectLogWrite && key === LOGS_KEY) {
rejectedWrites += 1;
throw new DOMException('Synthetic exact-key rejection', 'QuotaExceededError');
}
store.set(String(key), String(value));
},
removeItem: key => store.delete(String(key)),
});
テスト対象のsaveData()とloadData()は実モジュールからimportしています。これら自体を「成功するモック」に差し替えてはいません。native-calendar連携は、隣接する既存テストと同じ境界で無効化しています。
古いデータを本物の保存処理で作ってから壊す
最初からMapへ正解のデータを直接入れると、「アプリが保存した」という前提を確かめられません。
両方のケースで、まず旧ログを実saveData()で保存します。旧ログ全体と、2つのmarkerが合成時刻09:00になったことを確かめてから、時計を10:00へ進めます。
正常対照では、新ログを保存したあと、次の3点を確かめています。
- 保存されたJSON文字列全体が新ログと一致する。
- JSONをparseしたレコード全体も一致する。
- 空の入力と新しいsetter群で実
loadData()を呼ぶと、新ログ全体が渡される。
この対照が通ったあとで、対象キーだけを拒否します。故障を入れたつもりで実際には対象コードへ到達していないケースを避けるため、拒否した回数も正の値であることを要求します。
expect(rejectedWrites, 'PRECONDITION: exact target write must be attempted')
.toBeGreaterThan(0);
expect(store.get(LOGS_KEY)).toBe(JSON.stringify(OLD_LOGS));
expect(JSON.parse(store.get(LOGS_KEY))).toEqual(OLD_LOGS);
expect.soft(store.get(SYNC_KEY), 'failed habit-log save advanced sync stamp')
.toBe(BEFORE);
expect.soft(store.get(EDIT_KEY), 'failed habit-log save advanced local-edit stamp')
.toBe(BEFORE);
実行結果では、故障注入の前提と旧ログ全体の保持は通りました。不一致になったのは、次の2値を旧時刻に保つという期待値です。
day-planner-cloud-sync-local-modifiedday-planner-local-edit-at
どちらも観測値は09:00から10:00へ進んでいました。
markerの意味と、FAILの意味を分ける
アプリの実装には、書き込み失敗をcatchして一度warning toastを設定し、その後のキーへ進む処理があります。無警告だった、画面がクラッシュした、という結果ではありません。 今回はtoastの実画面表示も検証していません。
また、これらのmarkerはソース上ではローカルの変更や編集を記録する値です。名前だけで「保存完了時刻」と決めつけることはできません。
今回のテストは、元のIssueが扱う保存失敗時の一貫性に合わせて、対象ログが保存できなかったときに選んだmarkerを進めない、という条件を置いたものです。FAILはこの条件との不一致を示します。上流作者がこの期待値に合意したこと、同期先のデータが壊れたこと、全キーのatomicityが失われたことを、この試験だけで証明したわけではありません。
別のアプリへ移す場合、markerが編集の発生を記録するのか、永続化の完了を記録するのかを先に読み、期待値を決める必要があります。新しいデータが保存されなかったことと、古いデータが消えたことも別です。今回、旧ログは残りました。
再実行手順
使い捨てのdayGLANCEチェックアウトで、先ほどの固定コミットへ移動してください。既存の作業ディレクトリへ上書きする手順ではありません。
Node 22.16.0で、アプリのlockfileを使ってnpm ci --ignore-scriptsを実行した環境です。ZIPをそのチェックアウトのルートへ展開すると、テストがsrc/hooks/useDataPersistence.crash-lab.test.jsへ入ります。アプリのソース・設定・lockfileの変更や、新しい依存の追加はありません。
ZIPのSHA-256は次です。
62f2675f27d254188e51a4363357b34575b9244bfbaa07f101ef873b32b0c092
npm test -- src/hooks/useDataPersistence.test.js src/hooks/useDataPersistence.crash-lab.test.js --maxWorkers=1
今回の固定コミットでは、上記のmarker比較がFAILし、終了コード1になる想定です。終了コードを0へ変更したり、その比較をskipしたりする手順ではありません。修正版を試した結果や、Windowsでの実行は未確認です。
自分のアプリで同じ境界を調べるには
ここからはdayGLANCEのファイルをそのままコピーする方法ではなく、自分の保存・読込処理に合わせる方法です。キー名だけを置換しても、引数やload時の仕様は一致しません。
私は保存境界の検査手順をApp Crash LabというMITのOSSとCodexスキルにまとめています。既存テスト用の依頼文から「Vitest: data layer only」を選ぶと、本体やスキルをインストールせずに検査を依頼できます。Codexスキルv0.2.2を導入して使う場合は、次の依頼も使えます。
$app-crash-lab
今回は既存Vitestを使ったdata-layerの検査をお願いします。
実際のsave/load処理と既存テストを先に読み、
保存する完全なレコードと、読込時に仕様で変換する値を分けてください。
正常保存・読込の対照を先に通し、対象キー1つだけの保存拒否を検査してください。
更新markerの意味をソースで確かめ、期待値の根拠を説明してください。
アプリを修正せず、結果と再実行コマンドを残してください。
明示的なdata-layer検査では、既存のテスト環境を使い、別のApp Crash LabランナーやJSON契約は必要ありません。一方、ブラウザ再読込、古いタブからの更新、画面のエラー表示、実ファイルのバックアップ復元を知りたい場合は、それぞれ別のブラウザ検査が必要です。単体試験の成功では代用できません。
dayGLANCEは元の作者のMITプロジェクトです。ZIPには今回追加したテスト、手順、MIT表記を含み、アプリ本体を再配布していません。この記事は人間の責任者のもとでAIを使って実装・実行検証・執筆した運営側の記録です。作者によるApp Crash Labの利用報告ではありません。