AIレビューで出た「公開操作と自動処理が並走できる」という指摘を直していたら、まったく関係ないテストが3本落ちた。その遠回りの記録です。
サーバへの公開操作と、自動で走る突き合わせ処理が同時に動ける状態だった。片方が結果を書き戻す前にもう片方が古い結果を適用すると、ユーザーの操作が静かに取り消されます。直し方は素直で、公開系の処理を全部1列に並べればいいです。
順番待ちは AI に書いてもらった。これだけ。
/// 直前の操作の完了。公開系の操作すべてがここに並ぶ。
Future<void> _pending = Future<void>.value();
Future<T> _serialized<T>(Future<T> Function() action) {
final done = Completer<void>(); // 自分の完了を知らせる札を1枚発行する
final previous = _pending; // 今 列の最後尾に並んでいる人の札
_pending = done.future; // 最後尾を自分に差し替える(次の人はこれを待つ)
// 前の人が終わってから action() を流し、終わったら自分の札を「完了」にする
return previous.then((_) => action()).whenComplete(() => done.complete());
}前の処理が終わるまで待ってから次を流す。終わったら次の人のために札を渡す。それだけ。
対象の処理をこれで包んで、狙っていたテストは通った。ところがまったく別のテストが3本落ちた。
落ちたのは「画面に入ったら1回だけ通信する」テスト
落ちたのは画面のテストだった。中身はこう。
testWidgets('画面に入ると1回だけ突き合わせる', (tester) async {
await pumpHost(tester, triggerOnEnter: true); // テスト用の手伝い関数。「入ったら突き合わせる」設定の画面をログイン済みで実際に描き、pumpAndSettle を1回かけて待つ
// expect(調べたい値, 期待する結果) の形で書く
expect(sync.fetchCalls, 1); // fetchCalls が 1 であることを期待
});fetchCalls が 0 になっていた。通信が1回も走っていない。
順番待ちを入れる前は通っていたので、原因はそこにある。最初に考えたのは「待ちが1段増えたから、テストが待ち足りていない」だった。
なので pumpAndSettle() を2回にした。落ちたまま。
3回。落ちたまま。5回。落ちたまま。
ここで手が止まった。1回足して変わらないなら、それは待ち方の問題ではありません。回数を増やし続けていたのは、原因の仮説を確かめずに対症療法を積んでいただけだった。
待っている相手が、別の世界にいた
問題の行はこれ。
Future<void> _pending = Future<void>.value();_pending はクラスのフィールドで、初期値として「もう完了している Future」を入れてある。最初の1回は待たずに流れる、というつもりだった。
フィールドの初期化子は、そのクラスが new された瞬間に走ります。テストではこう書いてある。
setUp(() { // 各テストの直前に毎回走る準備処理
db = AppDatabase(NativeDatabase.memory());
runner = AutoReconcileRunner(
coordinator: SyncCoordinator(db: db, /* ... */), // ← ここで new される
);
});
testWidgets('画面に入ると1回だけ突き合わせる', (tester) async {
// ← テスト本体はここ
});setUp と testWidgets の本体は、別のゾーンで走ります。
直し方は「最初は待たない」
札を null から始めることにした。
/// null は「列が空」。Future.value() で初期化しないこと(これは仕様)。
/// フィールド初期化子はコンストラクタが呼ばれたゾーンで走るので、widget test では
/// setUp(fake-async の外)で作られた Future をテスト本体(fake-async の中)で
/// await することになり、完了する機会が二度と来ない=同期が丸ごと止まる。
Future<void>? _pending;
Future<T> _serialized<T>(Future<T> Function() action) {
final previous = _pending;
final done = Completer<void>();
_pending = done.future;
final result = previous == null ? action() : previous.then((_) => action());
return result.whenComplete(() => done.complete());
}テストは1回の pumpAndSettle() に戻して、5本とも通った。増やした pump は全部消した。
学び
pumpAndSettleを2回にして変わらなかった時点で、待ち方の仮説は捨てるべきだった。3回・5回と積んだのは、確かめずに祈っていただけ。効かない対策が積み上がっている状態そのものが、原因の見立てが違うという情報だった- フィールドの初期化子は「いつ・どこで」走るかを持っています。
Future.value()のように「もう終わっている」つもりの値でも、終わり方は作られた場所に依存します - 今回いじろうとしていたのはテストのほうで、直すべきだったのは実装の初期化だった。この初期化は本番なら素通りします(作る場所と待つ場所が同じ Zone なので)。だからテストを直して通していたら、「widget test でだけ列が止まる形」が実装に残ったうえ、それを踏める唯一の場所を黙らせたことになります
コメントに「Future.value() で初期化しない」と理由ごと書いておいた。次に誰か(たぶん自分)が「null チェックが冗長では」と思った時に、ここで止まってほしいので。
この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。