綴Photo の勉強ノートに戻る
技術ブログ2026.07.28

5日前に書いた「これが落ちたら合図」が、ちゃんと鳴った

テスト設計riverpod綴Photo

症状だけ抑えた対処を入れる時に、後始末の手順を同じ場所へ置いておいたら、後から効いた、という記録です。

ここで言う「web」は、綴ったものを web でも見られるように開発中のアプリ(紡Book)のことです。

実機で「2枚目を web に公開すると、表示が1枚目に戻る」という現象が出た。詳細画面は左右にスワイプして同じ月の写真を見ていける作りで、公開ボタンを押すとページ位置が最初に戻ってしまいます。

原因はデータの読み込みの作りにあった。詳しくは後述するが、その時は根っこまで直さず、表示側で1行のフラグを立てて症状を抑えた。

dart
return monthlyAsync.when(
  // 読み込み中 / データ / エラー の3状態を画面に振り分ける
  skipLoadingOnReload: true,   // 再読み込み中に「読み込み中」へ落とさない
  data: (monthly) { /* ... */ },
);

急ぎで直す判断としては妥当だった。ただ、これは対症療法なので、根っこを直した後には要らなくなる可能性があります。

「これが落ちたら何を意味するか」をテストに書いた

その時に書いたテストがこれ。

dart
/// `memory_detail_page` の `skipLoadingOnReload: true` を守るためのテスト。
///
/// `monthlyDetailProvider` は anchor 写真の監視を watch しているので、その月の写真に
/// 何か書き込むと provider ごと作り直され、前の値を持ったまま一瞬 loading 扱いになる。
/// 既定の `when` はそこで loading 側を描き、ページ送りの State ごと捨てるため、
/// 「2枚目を web に公開すると1枚目に戻る」という形で出ていた。
///
/// ここで固定するのは「その中間状態が実在すること」。在る限り `skipLoadingOnReload` は
/// 外してはいけないし、将来 provider の作りが変わってこれが消えたら、この失敗が
/// 「フラグを見直してよい」の合図になる。
test('別の写真を公開すると、詳細の provider が「値を持ったまま loading」を通る', () async {

普通のテストは「こう動くべき」を書きます。これは違って、「今こう動いている」を書いて、それが変わったら教えてくれと言っています。

最後の一文が肝で、テストが落ちた時に後で読む自分が何をすればいいかまで書いてあります。

5日後に、根っこを直した

Codex と Claude にコードレビューを一通り回した回で、この現象の根っこが言語化された。

写真の行は端末 SQLite の photos。係の値とページ位置はメモリ。ページ位置は保存しない。photos テーブル別の写真を公開監視はテーブル単位他の行でも通知SQLite に保存する閉じても残る起点1枚の係行を取り直す等価判定が無い必ず「変わった」メモリ。保存しない月ぜんぶの係月クエリを張り直す値を持ったまま読み込み中になるメモリ。保存しないページ位置読み込み中を描くページ送りの Stateごと捨てられる画面のメモリ保存しない

直し方は、依存の途中に1枚挟むことです。「その月の写真ぜんぶ」が必要としているのは、起点の写真の行全体ではなく 年月だけ。

dart
/// anchor 写真が「どの月のものか」だけを取り出す派生 provider。
final memoryMonthKeyProvider =
    Provider.family<AsyncValue<({int year, int month})?>, int>((ref, anchorPhotoId) {
  return ref.watch(memoryDetailProvider(anchorPhotoId)).whenData((anchor) {  // 起点の写真を見張る。変わったらここだけ計算し直す
    if (anchor == null) return null;
    final keyDate = memoryKeyDate(anchor);
    return (year: keyDate.year, month: keyDate.month);
  });
});
年月の組はメモリ上の係が持つ。保存しない。写真の行は端末 SQLite の photos。photos テーブル通知は今も来るテーブル単位は変わらないSQLite に保存する閉じても残る起点1枚の係行は取り直すここまでは同じメモリ。保存しない年月だけの係年と月の組だけ返す中身が同じなら等価同じ月ならここで止まるメモリ。保存しないページ位置張り直しが来ない見ているページが残る画面のメモリ保存しない

読み取り用のデータに == を実装する案もあった。採らなかったのは、列が増えるたびに書き足す規律が要るから。人が覚えて守る形はいつか壊れます。1枚挟むほうは構造で止まります。

赤が、指示書として届いた

テストを走らせたら、5日前に書いたこのテストだけが落ちた。

detail_keeps_page_on_reload_test.dart
「別の写真を公開すると、詳細の provider が『値を持ったまま loading』を通る」

普通ならここで「何か壊した」と思います。実際、この形の失敗は紛らわしいです。修正が原因で落ちているので、修正を疑いたくなります。

でもテストの説明文に「消えたらフラグを見直してよい合図」と書いてあった。だから読んだ瞬間に壊したのではなく届いたと分かった。5日前の自分が、今の自分に手順を渡していた形になる。

直したのは3か所。

テストを消さずに改訂した。 固定する内容を「中間状態が在る」から「もう通らない」へ。

dart
test('別の写真を公開しても、詳細の provider は loading を通らない', () async {
  // ...
  // expect(調べたい値, 期待する結果) の形で書く
  expect(
    states.any((s) => s.loading),  // states の中に loading 状態のものが1つでもあるか
    isFalse,  // 上の値が false であることを期待
    reason: '月が変わっていないので、月クエリを張り直す理由が無い',  // 落ちた時に出る一言
  );
});

フラグは残した。 5日前に書いた合図は「フラグを見直してよい」であって「外してよい」ではない。見直した結果、月をまたぐ移動など別の理由で読み込み直しが起きる経路はまだ残っていた。だからフラグ自体は残し、何を守っているかが入れ替わったことのほうをコメントに書いた。

dart
// 訂正: このフラグを入れた時の理由(anchor 写真への任意の書き込みで
// provider ごと作り直される)は根から消えた。それでも残しているのは、
// 月をまたぐ移動など別の理由での張り直しが残るため。
// 「publish のたびにページが戻る」を防いでいるのはもうこのフラグではない。

止めすぎていないかも測った。 連鎖を止めるのが目的だが、止めすぎると本当に月が変わった時に更新されません。

dart
test('月が実際に変われば通知される(止めすぎていないことの確認)', () async {
  // 写真の撮影日を 7月 → 9月 に書き換える
  await (db.update(db.photos)..where((p) => p.id.equals(anchor)))  // photos テーブルの更新対象を anchor の id に絞り込む
      .write(PhotosCompanion(takenAt: Value(DateTime(2026, 9, 3))));  // takenAt を 2026-09-03 に書き換えて反映する
 
  expect(seen, contains((year: 2026, month: 9)));  // seen が (2026年, 9月) の組を含むことを期待
});

学び

  • 対症療法を入れる時、根が消えた時の合図を同じ場所に置きます。別のメモに書くと読まれないが、落ちるテストの説明文なら必ず読まれます
  • 「今こう動いている」を固定するテストは、それが変わった時の意味まで書いていないと、後で読む自分が「壊した」と読んで元に戻します
  • 止める修正を入れたら、止めすぎていないかも測ります。「変わらないこと」だけを固定すると、変わるべき時に変わらないバグを作り込みます

この「合図を残しておく」やり方、たぶん一番効くのは自分が忘れている前提だと思う。今回も、5日前に何を考えて入れたフラグかは覚えていなかった。覚えていたのはテストのほうだった。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。