綴Photo の勉強ノートに戻る
開発記録2026.08.11

「直す前に赤にする」を守ったら、同じ日に2回、赤にできなかった

テストTDDレビューFlutter綴Photo

7月に書いた「テストが3本とも落ちたのに、落ちた理由が違った」の続きです。あのときは赤の理由が違った。今回は赤にできなかった。

テストを先に書いて、赤になるのを確かめてから直す、という順序を守るようにしている。緑になるだけのテストは何も測っていないことがあるので、赤を1回見ないと採用しない。

その手順で、AIレビューから返ってきた8件ぶんの指摘を1日で捌いていたら、2件だけ赤にならなかった。原因は毎回違った。

1回目: 直そうとしたバグが、実在しなかった

マイグレーション(データベースの形を変える手順)の生 SQL に、日時をミリ秒で書いている箇所があった。strftime('%s','now') が返す秒を 1000 倍しているのに、Dart 側の ORM は DateTime を秒で読み書きする。1000倍ズレて 2023年が 55840年になり、古いバージョンから上げると「また今度」の棚に 55840-11 という存在しない年月が1つ生えていた。単位がズレる仕組みと該当の SQL は「テストは通っていた。写真を1枚も入れていなかったから」に書いた。

原因も直し方もはっきりしていたので、* 1000 を落として、テストを1本書いた。「古いバージョンから上げたあと、日時が 1990〜2100年に入っていること」。

このテストが、直す前から緑だった。

修正が効いていない可能性と、テストが何も測っていない可能性がある。追いかけたら、3つ出てきた。

① 到達しない。 値を運ぶ段はこう書いてある。

sql
COALESCE(ss.last_skipped_at, ss.updated_at, ss.created_at, CAST(strftime('%s','now') AS INTEGER) * 1000)

COALESCE は左から見て最初に NULL でない値を返す。ミリ秒が入っているのは4番目だが、3番目の created_at は NOT NULL で宣言されている(=この列は必ず値が入る、空になれない)。4番目には永久に到達しない。

② 発火しない。 ミリ秒を埋めている DEFAULT 句が効くのは、その表に行が挿入されたときだけ。ところがこの表は、マイグレーションのある段で空のまま作られて、2つ先の段で捨てられる。連続して流すかぎり1行も入らない。値が入るのは、その時代のアプリが実際に使っていた場合だけ。

③ 配っていない。 そのバージョンは公開していなかった。7月に別件で git 履歴を当たったときの記録が残っていて、v1〜v4 は一度も配布していない。つまり利用者の端末にミリ秒は入りようがない。

では 55840年はどこから来たのか。テストのフィクスチャ(テスト用に手で用意する下ごしらえデータ)が自分で 1700000000000 を書いていた。

dart
raw.execute(
  // 3つ目の値が created_at。ここがミリ秒のまま手で書かれていた
  "INSERT INTO skip_status (...) VALUES ('skip-asset-1', 1, 1700000000000, ...)",
);
ミリ秒の置き場所。実体があったのはテスト用 SQLite の skip_status だけ。関所 COALESCE 4番目置き場所ではない* 1000 はここに書いてあるcreated_at は NOT NULL4番目には到達しないskip_status 表端末 SQLite(連鎖中)空のまま作られるDEFAULT は INSERT 時だけ2つ先の段で捨てる。0行利用者の端末 SQLite本番の DB ファイルv1〜v4 は未配布ミリ秒は入りようがないここには無い上の3つにはミリ秒は乗らないテスト用 SQLite の skip_statusフィクスチャが 1700000000000 を INSERT55840年の棚はここから生まれたテストの一時ファイル。本番の端末 SQLite には無い

実装のコードが利用者に被害を出した経路は無い(v4 時代のビルドを動かした自分の開発機に値が残っている可能性までは潰していない)。

直さない、にはしなかった

実害がないなら放っておく、という判断はしなかった。同じ書き方がファイルの中に何箇所も残っていて、次に日時の列を足す人が周りを真似するからだ。そのときズレるのは写真の撮影日かもしれない。

やったことは3つ。実装とフィクスチャから * 1000 を落とす。チェックリストに「日時は unix 秒」を1行足す。そしてその1行が守られているかを機械で見るテストを置く。

dart
// 同じ行に strftime('%s' と * 1000 が並んでいたら落とす
if (!line.contains("strftime('%s'")) continue;
if (!line.contains('* 1000')) continue;
offenders.add('$path:${i + 1}  ${line.trim()}');

チェックリストで止めなかったのは、この種の地雷が踏まれるのが「周りを真似して書いた瞬間」だからだ。書く側はチェックリストを読みに行っていない。読まれない前提の規約は、機械が読む側に置き換えないと効かない。

あわせて、コミットメッセージと検証レポートに「これはバグ修正ではなく、地雷の除去」と書いた。着手前の設計書は既に「本当の危険は landmine のほう」と書いていて、そちらが当たっていた。重さの見立てが間違っていたことを記録に残すほうが、直した事実より後から効くと思ったからだ。前のマイグレーションの記事ではこれを実害のある欠陥として並べていたので、そこは自分で訂正したことになる。

2回目: 今度はテストデータのほうが悪かった

同じ日の後半。今度は写真の候補が「静かに落ちる」件を直していた。

端末のカメラロールを新しい順に読んでいって、目的の日付より古い写真が出たら打ち切る、という作りになっている。速いが、列が本当に日付順であることを前提にしている。

その前提が崩れる場合が2つある。1つは Android で、OS が返す順が「撮影日」ではなく「端末に追加された日」だということ。もう1つは、撮影日が 1990年より前になっている写真(EXIF が無いと 1970年が返る)で、こちらは自前の判定が「その値は信用せずファイルの更新日時を使う」と決めているので、そこで軸が入れ替わる。

打ち切りをやめる条件を足して、テストを書いた。

dart
final assets = [
  _asset('recent-1',     createdAt: DateTime(2026, 8, 5)),
  _asset('exif-missing', createdAt: DateTime(1970), modifiedAt: DateTime(2026, 8, 3)),
  _asset('recent-2',     createdAt: DateTime(2026, 8, 1)),
  _asset('old',          createdAt: DateTime(2019, 5, 1)),
];

これも赤にならなかった。

直したのは実装ではなくデータのほうだった。

dart
  _asset('exif-missing', ...),
  _asset('old',      createdAt: DateTime(2019, 5, 1)), // 旧実装はここで打ち切る
  _asset('recent-2', createdAt: DateTime(2026, 8, 1)), // だから届かない
候補の列はテストのメモリ。保存しない。DB にも prefs にも無い。直し前の列recent-1(2026-8-5)exif-missing(1970)recent-2(2026-8-1)old(2019-5-1)旧実装はここで打ち切る後ろに何も無い新旧とも recent-1, exif-missing, recent-2差が出ない。緑のまま直し後の列recent-1(2026-8-5)exif-missing(1970)old(2019-5-1)recent-2(2026-8-1)旧実装は old で打ち切るrecent-2 には届かない旧は recent-1, exif-missing新は recent-2 まで拾う。差が出る

学び

緑のままなら普通は「まだ直っていない」を疑う。でも今回はどちらも直っていて、赤にならない理由が別の場所にあった。1回目は実装側、2回目は検証物側。赤にならない理由は両側にあるので、どちらかに決め打ちして探すと片方を取り逃す。実際、1回目で「実装側を疑う」という型を作った直後に、2回目は逆だった。

ただ、追い方は2回とも同じだった。直したコードまで実行が届いているかを数える。1回目は3つとも届いていなかった。2回目は届いていたが、旧実装のほうも同じ結果を出す並びだった。「どっちを疑うか」ではなく「経路が通っているか」から入れば、どちらの型でも同じ手順で着地する。

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