綴Photo の勉強ノートに戻る
Flutter技術2026.07.28

回数を増やしても直らないなら、待ち方の問題ではない — テストの中で永遠に完了しない Future

FlutterDartテスト非同期綴Photo

AIレビューで出た「公開操作と自動処理が並走できる」という指摘を直していたら、まったく関係ないテストが3本落ちた。その遠回りの記録です。

サーバへの公開操作と、自動で走る突き合わせ処理が同時に動ける状態だった。片方が結果を書き戻す前にもう片方が古い結果を適用すると、ユーザーの操作が静かに取り消されます。直し方は素直で、公開系の処理を全部1列に並べればいいです。

順番待ちは AI に書いてもらった。これだけ。

dart
/// 直前の操作の完了。公開系の操作すべてがここに並ぶ。
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回だけ通信する」テスト

落ちたのは画面のテストだった。中身はこう。

dart
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回足して変わらないなら、それは待ち方の問題ではありません。回数を増やし続けていたのは、原因の仮説を確かめずに対症療法を積んでいただけだった。

待っている相手が、別の世界にいた

問題の行はこれ。

dart
Future<void> _pending = Future<void>.value();

_pending はクラスのフィールドで、初期値として「もう完了している Future」を入れてある。最初の1回は待たずに流れる、というつもりだった。

フィールドの初期化子は、そのクラスが new された瞬間に走ります。テストではこう書いてある。

dart
setUp(() {   // 各テストの直前に毎回走る準備処理
  db = AppDatabase(NativeDatabase.memory());
  runner = AutoReconcileRunner(
    coordinator: SyncCoordinator(db: db, /* ... */),   // ← ここで new される
  );
});
 
testWidgets('画面に入ると1回だけ突き合わせる', (tester) async {
  // ← テスト本体はここ
});

setUp と testWidgets の本体は、別のゾーンで走ります。

_pending はメモリ上のフィールド。保存しない。キューもプロセスのメモリ。DB には無い。メモリ上のフィールド _pending中身は Future.value()。作られたゾーンのキューに乗るsetUp / 本物のゾーンnew の瞬間に初期化子が走るFuture.value() は本物のキューへこのキューはテスト中回らないpump は触れないtestWidgets / 偽装ゾーンprevious.then でその Future を待つpumpAndSettle はここのキューだけ続きは永遠に来ない突き合わせ通信は 0 回 札も窓口もメモリの中の話。保存しない。DB には無い。① setUp(テストの外)SyncCoordinator を new する_pending に完了済みの札が入る時間② テスト本体(テストの中)画面に入る → _serialized を呼ぶ「札が済んだら action() を呼んで」pumpAndSettle で時間を進める札の発行元pump外の窓口(本物の時計)進んでいいよ(② の合図)テスト中は誰も回さない中の窓口(偽物の時計)pump が回すのはここだけここには何も積まれていない外の窓口が回らないので action()(通信)は呼ばれない → fetchCalls が 0

直し方は「最初は待たない」

札を null から始めることにした。

dart
/// 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());
}
列の札はメモリ上のフィールド。保存しない。DB には無い。直し前の _pendingFuture.value() が入っている作られたのは setUp の本物ゾーンテスト本体が then で待つ偽装ゾーンの pump では来ない最初の1つが列を止める直し後の _pendingnull(列が空)最初の1回は待たずに流れる2回目以降の札はテスト本体で作る偽装ゾーンのキューに乗るpump で進む 直した後。札も窓口もメモリの中の話。保存しない。DB には無い。① setUp(テストの外)SyncCoordinator を new する_pending は空(null)。札を作らない時間② テスト本体(テストの中)前の札が無い → すぐ action()(通信)自分の札はここで作るpumpAndSettle で時間を進める矢印なし札pump外の窓口(本物の時計)何も積まれていない回らなくても困らない中の窓口(偽物の時計)自分の札もここに乗るpump で回る→ 次の人もちゃんと進める外の窓口へ向かう矢印が1本も無い → 中の窓口だけで回る → fetchCalls が 1

テストは1回の pumpAndSettle() に戻して、5本とも通った。増やした pump は全部消した。

学び

  • pumpAndSettle を2回にして変わらなかった時点で、待ち方の仮説は捨てるべきだった。3回・5回と積んだのは、確かめずに祈っていただけ。効かない対策が積み上がっている状態そのものが、原因の見立てが違うという情報だった
  • フィールドの初期化子は「いつ・どこで」走るかを持っています。Future.value() のように「もう終わっている」つもりの値でも、終わり方は作られた場所に依存します
  • 今回いじろうとしていたのはテストのほうで、直すべきだったのは実装の初期化だった。この初期化は本番なら素通りします(作る場所と待つ場所が同じ Zone なので)。だからテストを直して通していたら、「widget test でだけ列が止まる形」が実装に残ったうえ、それを踏める唯一の場所を黙らせたことになります

コメントに「Future.value() で初期化しない」と理由ごと書いておいた。次に誰か(たぶん自分)が「null チェックが冗長では」と思った時に、ここで止まってほしいので。

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