Claude の多段レビュー(7観点の並列レビュー + 反証検証)で浮かんだ「マイグレーションのテスト」まわりの観察を、勉強のためにまとめたものです。
綴Photo のモバイル版は、写真と「綴り(気持ち + テキスト)」を端末ローカルの DB(Drift / SQLite)に保存している。レビューで指摘されたのは、コードの中身そのものではなく テストの守備範囲だった。DB のテストは一通りあるのに、それが全部「新しい DB をその場で作って試す」ものだった。結果として、アプリをアップデートした人が通る経路(マイグレーション)は、一度もテストで実行されていなかった。
ストアにはもう公開済みで、実ユーザーの端末では毎回この経路が走る。なのにそこを検証するテストはゼロ。退行(前まで動いていた箇所が、別の変更の巻き添えで壊れること)が起きても誰も気づけない状態だった。
この記事は「アップグレード経路を実際にテストするにはどう書くのか」に絞ってまとめる。なお、その過程で見えた Drift 固有の罠(マイグレーションが「凍らない」話)は別記事にした。
新規作成テストでは、アップグレードの170行が1行も走らない
綴Photo のマイグレーションは v1 から積み上げて、レビュー時点で schemaVersion は 9 まで来ていた(修正の過程で 10 まで進んだ)。各段の差分を足し合わせると約170行ある。中には単純な列追加だけでなく、生 SQL でデータを手術する段もある。たとえば v8 → v9 では「同じ写真 ID の行が重複して2行できてしまった DB」を、参照ごと1行に統合してから片方を削除する、という危ない処理が入っている。
問題は、DB のテストが全部こうだったこと:
// インメモリで新規作成 → そのままテスト
final db = AppDatabase(NativeDatabase.memory());NativeDatabase.memory() は、前回の記事(新規インストールと migration の非対称)で触れた onCreate の経路です。
なぜそうなりやすいか
新規作成テストは、書くのが圧倒的に楽だからです。NativeDatabase.memory() と1行書けば、最新スキーマの DB が手に入ります。ファイルもいらないし後始末もいりません。DAO(クエリ層)のテストを書くには十分で、実際それで「DB が動いている」感覚は得られます。
一方アップグレードのテストは、「古い DB をどこかから用意する」という工程がそもそも面倒です。古いバージョンのアプリを起動して DB ファイルを吸い出す、なんてことは現実的にやってられません。だから「テストはある」状態のまま、一番こわい経路が手つかずで残りやすいです。しかもエラーは出ません。アップグレードが壊れていても、新規作成テストは全部グリーンのままです。
改善の方向 — 旧バージョンの DB を「手で組む」
用意できないなら作ればいい。アプリの DB クラスを使わずに、生の sqlite3 で v8 時点のスキーマを DDL から直接組み立てる。
// 一時ファイル上に、v8 当時のスキーマを生 SQL で再現する
void _buildV8Schema(Database raw) {
raw.execute('''
CREATE TABLE photos (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
client_photo_id TEXT NOT NULL,
asset_id TEXT,
local_path TEXT,
title TEXT,
-- ... v8 当時の列だけ。v9 で足すインデックスは「まだ無い」状態にする
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now') * 1000)
)
''');
// meanings / 表紙設定テーブル ... も同様に v8 の形で作る
}ポイントは「v8 当時の形を忠実に再現する」こと。v9 で足すインデックスはわざと作らない。マイグレーションがそれを作るかどうかを後で検証したいからだ。逆に、v8 までに既に存在していた列やテーブルは全部入れる。ここを最新形に寄せてしまうと「古い DB から上げる」という前提が崩れ、テストの意味がなくなる。
組んだら、重複データを流し込んで、user_version を 8 にして閉じる。
final raw = sqlite3.open(dbFile.path);
_buildV8Schema(raw);
// 同じ asset_id 'dup' を持つ写真を2行 + それぞれを参照する綴り行を入れる
raw.execute("INSERT INTO photos (id, client_photo_id, asset_id) VALUES (1, 'cp-1', 'dup')");
raw.execute("INSERT INTO photos (id, client_photo_id, asset_id) VALUES (2, 'cp-2', 'dup')");
raw.execute("INSERT INTO meanings (photo_id, client_memory_id, body) VALUES (1, 'cm-1', '1枚目への綴り')");
raw.execute("INSERT INTO meanings (photo_id, client_memory_id, body) VALUES (2, 'cm-2', '重複側への綴り')");
raw.execute('PRAGMA user_version = 8'); // ← これが効く。8 だから Drift はマイグレーションを走らせる
raw.close();// 同じファイルを今度はアプリの DB クラスで開く → マイグレーションが発火する
final db = AppDatabase(NativeDatabase(dbFile));
// 何かクエリを投げて open(= マイグレーション)を確実に走らせる
final photoCount = await db.photoDao.countPhotos();Drift はオープンを遅延させるので、ただ AppDatabase(...) と書くだけではマイグレーションは走りません。何かしらクエリを投げて初めて open が起き、その中でマイグレーションが流れます。テストでは1本クエリを投げてトリガーする。
あとは「ちゃんと上がったか」を3点で検証する:
// (a) user_version が最新になっている
final userVersion = await db.customSelect('PRAGMA user_version').getSingle(); // user_version を1行だけ問い合わせる
expect(userVersion.read<int>('user_version'), db.schemaVersion); // expect(調べる値,期待する値)。最新の10と一致するか
// (b) 重複していた写真が1行に統合され、綴りは最小 id 側に付け替わっている
expect(photoCount, 1); // 重複2行が1行に統合されたか
final memories = await db.memoryDao.watchMemoriesWithAll().first; // 写真と綴りをまとめて先頭の1件を見る
expect(memories.single.photo.id, 1); // 生き残った写真の id が1(代表側)か
expect(memories.single.meanings.map((m) => m.body), // 綴りの本文だけを取り出して比較用にする
['1枚目への綴り', '重複側への綴り']); // 2件とも生き残る
// (c) v9/v10 で足したインデックスが sqlite_master に存在する
final indexes = await db.customSelect( // sqlite_master へ SQL を直接投げる
"SELECT name FROM sqlite_master WHERE type = 'index'").get(); // index の行を全部取得
final names = indexes.map((r) => r.read<String>('name')).toSet(); // 名前だけを集合にする(順序・重複を無視)
expect(names, contains('idx_photos_asset_id')); // 集合にこのインデックス名が含まれるか
expect(names, contains('idx_meanings_photo_id')); // 集合にこのインデックス名が含まれるか(もう1本)インデックスは本の巻末さくいんと同じで、特定の値を全行なめずに一発で引ける目印です。バージョンが上がっただけでは、綴りが残ったかは分かりません。
補足しておく。Drift には旧バージョンの DB を手で組まずに用意する公式の仕組みがある——dart run drift_dev schema dump で各バージョンのスキーマを drift_schemas/ にスナップショットしておけば、生成ヘルパーと SchemaVerifier で「v8 から最新まで上げてスキーマの形が期待どおりか」を、DDL を手書きせずに検証できます(綴Photo も CI でこの dump は回している)。
今回の v8→v9 は単なる列追加ではなく、「重複した2行を1行に統合し、綴りを片方へ付け替える」データ移行だった。旧スキーマの用意自体はスナップショットの生成クラスで楽にできるが、assert は自分で書く。
学びポイント
- データ手術を含むマイグレーションは「バージョンが上がったか」だけでなく「データが意図どおり変形したか」まで検証する。綴りが消えていないか、参照が正しく付け替わったか
- スキーマの「形」が正しく上がるか(列・索引が揃うか、
duplicate columnで落ちないか)は Drift 公式のSchemaVerifierで手書きなしに自動検証できる。だがデータ移行の正しさは形の検証では分からないので、旧データを seed して結果を assert するテストが別に要る
おわりに
道のりをテストする唯一の方法は、出発点(旧バージョンの DB)を自分の手で用意して、実際に歩かせてみることだった。この種の穴は「テストが無い」のではなく「テストはあるが、こわい経路を踏んでいない」という形で潜みます。グリーンのテストが並んでいると安心してしまうが、それが新規作成しか踏んでいなければ、ストア公開済みの実ユーザーが毎回通る道は無防備なままです。
入口が2つ(新規作成とアップグレード)ある処理は、両方の経路を別々にテストしないと片方の穴に気づけません。とくにアップグレードは「出発点を用意するのが面倒」という理由で後回しにされがちで、この種の穴は第三者レビュー(AI)が「この経路、一度も走ってないよ」と指摘して初めて表面化します。新規作成テストのみでは、この経路の抜けを機械的に発見する手段がありません。
この記事は2026年6月時点の自分の理解で書いています。Drift / SQLite のマイグレーション API は変わることがあるので、最新の公式ドキュメントも合わせて参照してください。