ある iOS アプリは、オフラインの下書きを SQLite に保存している。ユーザーには「保存成功」と表示されたが、プロセスは次の書き込み中に異常終了した。再起動後、下書きは残っているのか。クラウド Mac 上の隔離したシミュレータなら、この疑問を再現可能な実験にできる。まず基準となるレコードをコミットし、次のトランザクションが未コミットの状態で意図的に異常終了させ、最後にデータベースを直接調べる。検証するのはトランザクションの境界であり、ビルド速度ではない。「アプリが再び起動した」ことをデータ確認の代わりにもしない。
合格条件を先に定める
id と phase の2列だけを持つテスト用テーブルを用意する。1|committed は異常終了前にコミットを完了させる。2|pending は新しく開始したトランザクション内に書き込むが、コミットしない。復旧後、整合性チェックは ok を返し、クエリでは最初のレコードだけが得られるはずだ。2件目も存在するなら、まず異常終了させる位置がコミット後になっていないか調べる。1件目が消えているなら、同じデータベースファイルに本当に書き込まれたか確認する。
WAL は「どの書き込みも失われない」という保証ではない。このテストで求めるのは、正常にコミットされたトランザクションが今回のプロセス異常終了後も読み取れ、未コミットのトランザクションが保存済みデータとして現れないことだけだ。停電、ストレージの破損、アプリの同期処理を検証するテストの代わりにはならない。
実験には使い捨てのシミュレータとテスト用バンドル ID を使う。最初に Xcode のバージョンとシミュレータのランタイムを固定し、xcodebuild -version の結果とシミュレータの識別子を記録する。各回とも同じ条件で実施する。普段の開発に使うシミュレータや実ユーザーのデータベースで障害を注入してはいけない。
コミット境界で障害を注入する
障害注入の入口はデバッグビルドに限定し、通常の実行で誤って起動しないよう、明示的な起動引数を必須にする。次の関数は、呼び出し側がすでに Documents/Recovery.sqlite を開いており、各 SQL 呼び出しの戻り値を確認することを前提としている。この例では準備段階の失敗を precondition で直ちに検出し、誤解を招くクエリ結果が出るまま処理を続けない。
#if DEBUG
import Darwin
import SQLite3
func runRecoveryProbe(_ db: OpaquePointer) {
func execute(_ sql: String) {
precondition(sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK)
}
execute("PRAGMA journal_mode=WAL")
execute("CREATE TABLE IF NOT EXISTS probe (id INTEGER PRIMARY KEY, phase TEXT NOT NULL)")
execute("INSERT OR REPLACE INTO probe VALUES (1, 'committed')")
execute("BEGIN IMMEDIATE")
execute("INSERT OR REPLACE INTO probe VALUES (2, 'pending')")
abort()
}
#endif
専用のテスト用フラグが起動引数に含まれる場合にだけ、この関数を呼び出す。テストの入口では、実験用データベースを使用していることと、先行する未完了のトランザクションがないことを確認する。PRAGMA journal_mode=WAL はトランザクションの外で実行する。abort() によって今回のプロセスは異常終了するため、その後に COMMIT は意図的に置いていない。実際のプロジェクトでデータベースのラッパー層を使っているなら、その層を通して同じコミット境界を作ること。ラッパー層を迂回しておきながら、本番の書き込み経路を検証したと主張してはいけない。
古いレコードの混入を防ぐ
毎回、使い捨てのシミュレータにあるテストアプリのデータをリセットするか、新しい専用シミュレータにインストールする。ほかの業務テーブルやファイルを残したまま、DELETE FROM probe だけを実行して済ませてはいけない。初回の実行前に、データベースが実際にアプリのデータコンテナ内にあることを確認する。障害注入の前後にデータベースのパス、ビルドバージョン、シミュレータの識別子を記録するが、実プロジェクトのデータを実験用コンテナへコピーしない。
異常終了後のデータベースをそのまま調べる
アプリを起動したら、まず想定した障害によって異常終了したことを確認し、それからクエリを実行する。起動コマンドが終了しただけでは、障害が発生した証拠にはならない。アプリがまだテストの入口に到達していない可能性がある。シミュレータ内のプロセス状態とクラッシュレポートを突き合わせて確認できる。異常終了の証拠がない回は「テスト未完了」と判定し、合格として扱わない。
プロセスの終了を確認したら、クラウド Mac のターミナルからアプリのデータコンテナを読み取る。変数を今回のシミュレータの識別子とテスト用バンドル ID に置き換える。
UDID="your-simulator-udid"
BUNDLE_ID="com.example.RecoveryProbe"
DATA="$(xcrun simctl get_app_container "$UDID" "$BUNDLE_ID" data)"
sqlite3 "$DATA/Documents/Recovery.sqlite" \
'PRAGMA integrity_check; SELECT id, phase FROM probe ORDER BY id;'
期待される出力は、順に ok と 1|committed だ。確認前に、データベースの隣にある -wal、-shm ファイルを削除したり、単独で移動したりしない。SQLite にそのままデータベースを開かせる。確認中は、再び書き込みを行うアプリのプロセスも起動しない。プロジェクトに元から複数のデータベースがある場合は、先にファイル名とコンテナのパスを照合し、「別のファイルを調べた」ことを復旧失敗と取り違えないようにする。
失敗を原因別に切り分ける
出力に 2|pending が含まれる場合は、自動コミットが発生していないか、ラッパー層がトランザクションを早期に終了していないか、あるいは障害注入フラグによって別のコード経路に入っていないかを優先して調べる。1|committed がない場合は、最初の書き込みが成功を返したか確認し、テストが同じ接続、同じファイルで実行されたか照合する。クエリで「そのテーブルがない」と表示される場合は、テスト用の入口を含むビルドがインストールされていることと、コンテナのパスが前回のシミュレータを指していないことを先に確認する。
integrity_check はアプリの要件を検証するアサーションではない。ok が返っても、各レコードの確認は必要だ。逆に、クラッシュレポートが証明できるのはプロセスの異常終了だけであり、トランザクションが正しく復旧したことではない。この2種類の証拠を、Xcode のバージョン、シミュレータの識別子、テスト用パッケージのビルド識別子とともに保存しておく。そうして初めて、失敗を再現した際に条件を比較できる。
CI に組み込む際も境界を守る
このテストにはシミュレータを専有させる。障害注入を終え、プロセスの終了を確認してから、読み取り専用の確認ステップを実行する。通常の UI テストと同じアプリのデータコンテナを共有してはいけない。並行して書き込まれると観測結果が変わる。障害注入の入口はデバッグビルドだけに存在し、リリースビルドには含めない。CI ではその点を明示的に確認する。
最後に、異常終了を注入しない対照テストも1回行う。通常の経路で2件目のレコードをコミットし、読み取れることを確認する。これにより「未コミットの書き込みがロールバックされる」ことに加え、テスト自体がすべての書き込みを妨げていないことも検証できる。この2組の結果がそろって初めて、偶然の再起動で見えた現象を、再検証可能な永続化の受け入れテストにできる。
よくある質問
通常のアプリ終了で異常終了を代用できますか?
できません。通常終了では書き込みが確定したり後処理が動いたりするため、確定前にプロセスを異常終了させ、その事実を確認します。
検査前に -wal や -shm を削除してよいですか?
削除しません。関連ファイルをそのまま残し、SQLite でデータベースを開いて整合性と記録を調べます。
次のXcodeビルドを専用物理マシンで実行
Apple Siliconのモデル、拠点、利用期間を選び、リモート接続して独自のビルド環境を実行できます。実際の提供条件は、ご注文時にコンソールでご確認ください。