Dart test 的跨檔案 GetX 狀態污染:flaky 真因不是 fail 訊息上的那個 test
事故類型:cross-file 狀態污染、dart test runner 同 process 共用 GetX 症狀:
flutter test約 50% 機率隨機失敗、每次失敗的 test 不固定;單獨跑該 test file 100% 通過 根因:dart test runner 在同 process 內跑多個 test file 共用 GetX 容器;前面 file 的 setUp 留下殘留(測試 mode 旗標、未 dispose 的 controller、stream subscription)污染後面 file 的測試環境
事故場景
表面症狀
跑 flutter test 全 suite,Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似:
100:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
200:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
300:27 +125: Some tests failed.訊息直接點名 PrintCenter 廚房印表機管理 group 的兩個 test「did not complete」。直覺反應:那兩個 test 有問題、去看那個 file。
第一次診斷與失敗的修法
打開 online_order_print_handler_test.dart,看到 PrintCenter 廚房印表機管理 group 的 setUp 沒做 Get.reset()、純粹依賴 outer setUp 的 Get.reset()。判斷可能是 outer setUp 的 OnlineOrderPrintHandler.onInit 在這個 group 留下副作用(stream subscription 之類),於是給這個 group 加自己的 reset:
1group('PrintCenter 廚房印表機管理', () {
2 late PrintCenter printCenter;
3
4 setUp(() {
5 Get.reset(); // ← 加這行隔離 outer setUp 的副作用
6 printCenter = PrintCenter(FakePrinterAdapter('main'));
7 Get.put(printCenter);
8 });
9
10 tearDown(() {
11 Get.reset(); // ← 加這行確保不殘留
12 });
13});跑 5 次:Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——flakiness 比例沒改變。
修錯了。
重新診斷:看 +N -1 計數的真正位置
把 fail 輸出存進檔案、仔細看 progress line 的 +N -1 部分:
100:08 +125 -1: ... auto_service_config_test.dart: ...
200:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更:badge 立即更新(list 與 selected 都沒命中時)
300:08 +127 -1: ... auto_service_config_test.dart: ...-1 在第 126 個 test 才第一次出現——失敗的不是 print handler,是中間夾的 widget test。再看另一次 fail:
100:09 +124 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity orderList[i] 替換:badge 從「已完成」立即變「退貨」不同 run 失敗的 test 不一樣,但都是 settle_page_order_object_test.dart 的不同 case。print handler 的 did not complete 是被牽連、不是源頭。
確認 root cause:單獨跑全綠
把 widget test 單獨重複跑 8 次:
1for i in 1 2 3 4 5 6 7 8; do
2 flutter test test/widgets/settle_page_order_object_test.dart 2>&1 | tail -1
3done8/8 全綠。單獨跑沒問題、混進全 suite 跑就 flaky——這是 cross-file pollution 的固定特徵。
為什麼 did not complete 訊息會誤導
dart test runner 的失敗訊息設計上有個盲點:
+N是累計通過數-N是累計失敗數did not complete是某個 test 還沒跑完整體就終止了(process 退出 / 超時 / 前面有未捕捉錯誤導致 runner 提前結束)
當前面有 test 失敗、後面的 test 沒機會跑、這些後面的 test 會印 did not complete——但它們本身沒問題。看到 did not complete 直覺會想「這個 test 卡住了」、但真實意思更接近「這個 test 還沒跑、上游已掛」。
正確的診斷流程:
- 找
-N第一次出現的位置(-1表示第一個失敗) - 對照那一行的 test 名稱、那才是真正失敗的源頭
did not complete出現的 test 通常只是受牽連
我第一次掉的坑:直接讀 did not complete 的 test 名、跳過了「往前找 -1 第一次出現」這步。
為什麼 cross-file 會污染:dart test runner 與 GetX 的不對齊
dart test runner 的執行模型
flutter test(背後是 dart test)跑全 suite 時不一定 1 file = 1 isolate。預設行為:
- 多個 test file 可能共用同一個 isolate / Dart VM
- 共用 isolate 等於共用所有 process-scoped state(static field、singleton、未 GC 的全域物件)
並發策略受 --concurrency 與 platform 影響、行為不固定,但「共用 process」是日常常見現象。
GetX 的 state 是 process-scoped
GetX 的 Get.put / Get.find 把 instance 放進一個 process-global 容器。Get.reset() 清空容器、但有些東西不會被 reset:
Get.testMode是 static field、reset()不動它- 如果 instance 在 onInit 內 subscribe 了 stream(例如
BroadcastReceiveService.messages.listen)、Get.reset()移除 instance reference 但 subscription 不會自動 cancel - StreamController / Timer / Future.delayed 在 GetX 容器外仍然活著
實際發生的污染鏈
跑全 suite 時,假設執行順序是:
11. test/services/online_order/... ← 最前面
22. test/widgets/settle_page_order_... ← 中間
33. test/services/auth_service_config... ← 後面第 1 個 file 的 setUp 若有 Get.put(SomeService()),service 在 onInit 內訂閱了 stream,就算 tearDown 跑了 Get.reset()、那條 stream subscription 仍 active。第 2 個 file 開始跑時:
- 它的 setUp 也呼叫
Get.put(...)、放進去的物件可能是 完全不同類型 ——但 GetX 容器內可能還有上一輪殘留的物件 - 第 2 個 file 的 widget test 進入 widget tree、Obx 訂閱、各種 reactive 路徑啟動
- 上一輪殘留的 stream / timer 此時 fire、進到不該觸及的 state
整個 race 在「殘留事件何時 fire vs widget test 何時 expect」之間,所以 flakiness 是 ~50% 而不是 100%。
解法:setUp 開頭主動 reset
對任何用 GetX 的 test,setUp 最開頭就該 reset、不要依賴上一個 file 的 tearDown 跑乾淨:
1setUp(() {
2 // 同 process 內跑全 suite 時其他 test file 可能在 GetX 容器留殘留
3 // (Get.testMode、未 dispose 的 controller、未 cancel 的 stream subscription),
4 // setUp 開頭主動 reset 切斷 cross-file 污染
5 Get.reset();
6 Get.testMode = true;
7 // ... 之後再 Get.put 自己需要的東西
8});
9
10tearDown(() {
11 Get.reset();
12});把這個 pattern 加到所有 widget test 與 controller test 的 setUp 之後,全 suite 連跑 5 次:
1Run 1: All tests passed!
2Run 2: All tests passed!
3Run 3: All tests passed!
4Run 4: All tests passed!
5Run 5: All tests passed!5/5 全綠,flakiness 消失。
為什麼 tearDown 的 reset 不夠
理論上 tearDown 已經 Get.reset() 了,下個 test 的 setUp 看到的應該是乾淨容器——但這個推理在「同 file 內」成立、跨 file 不成立:
- 跨 file 之間 dart test runner 在 file 邊界做的事是不確定的(可能整個 isolate 重啟、也可能只是切換 group)
- 即使前一個 file 的 tearDown 跑完,跨 file 的某個 microtask / timer callback 仍可能在後一個 file 的 setUp 之前 fire
- 用 setUp 開頭的 reset 等於再保險一次、把這個邊界內的不確定性吃掉
除錯思維:flaky test 的固定診斷流程
11. 看是不是真的 flaky
2 - 連跑 5~10 次、計算成功率
3 - 隨機失敗(不是 100% 也不是 0%)→ 進入 flaky 診斷
4
52. 找真正的失敗源頭
6 - 看 progress line `+N -M`、找 -1 第一次出現位置
7 - 不要直接讀 "did not complete"、那是受牽連訊息
8
93. 判斷是 in-file 還是 cross-file 污染
10 - 失敗的 test 單獨跑:
11 - 100% 通過 → cross-file 污染(其他 file 的殘留進來)
12 - 也會隨機 fail → in-file 污染(同 file 的 test 之間互相污染)
13
144. 補對應的隔離
15 - cross-file → setUp 開頭 Get.reset()
16 - in-file → 看是 setUp/tearDown 沒清乾淨還是 test 之間共享 mutable state教訓
did not complete不是失敗源、是被牽連訊息——往前找-1第一次出現的位置才是真正失敗的 test。- 單獨跑通過 + 全 suite fail = cross-file pollution——這是 flaky test 最常見的固定模式之一、有專屬的解法(setUp reset)、不要當成「資料時序的隨機性」隨便重跑。
- tearDown 清不夠、setUp 也要清——任何用 GetX 的 test 應該在 setUp 開頭主動
Get.reset()、不要依賴上一個 file 的 tearDown。 - 第一次診斷錯誤是常態、要回到證據——順著 fail 訊息修是直覺反應、但訊息可能誤導;停下來看計數欄位、單獨跑驗證、才是穩定的診斷方式。
適用範圍
這個 pattern 不限於 GetX、適用於任何在 process-scoped global state 註冊東西的框架:
Provider的MultiProvider/ 全域 instanceRiverpod的ProviderContainer(雖然 Riverpod 設計上更鼓勵 per-test container)- 自寫的 service locator / singleton
- 任何
staticfield 累積的狀態
只要框架的 state 跨 test boundary 而 dart test runner 又在同 process 跑多 file,cross-file pollution 都可能發生。setUp 開頭主動 reset 是通用防身術。