<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Test on Tarragon</title><link>https://tarrragon.github.io/blog/tags/test/</link><description>Recent content in Test on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 26 May 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/test/index.xml" rel="self" type="application/rss+xml"/><item><title>Flaky Test</title><link>https://tarrragon.github.io/blog/ci/knowledge-cards/flaky-test/</link><pubDate>Wed, 06 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ci/knowledge-cards/flaky-test/</guid><description>&lt;p>Flaky Test 的核心概念是「同一版本在相同條件下測試結果不穩定」。它會把紅燈從有效訊號降級成噪音，直接影響 CI gate 信任度。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Flaky Test 位在 test stage 與 release gate 之間，會放大重跑成本與判讀延遲。&lt;/p>
&lt;h2 id="可觀察訊號">可觀察訊號&lt;/h2>
&lt;ul>
&lt;li>同一 commit 重跑結果時好時壞。&lt;/li>
&lt;li>失敗集中在等待條件、時間假設或外部依賴。&lt;/li>
&lt;li>團隊習慣以重跑代替根因修復。&lt;/li>
&lt;/ul>
&lt;h2 id="接近真實服務的例子">接近真實服務的例子&lt;/h2>
&lt;p>UI 測試在動畫未完成時抓取元素，或整合測試依賴不穩定第三方 API，都容易出現 flaky pattern。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Flaky Test 治理要建立 owner、隔離策略、修復 SLA 與觀測指標，讓測試結果恢復可判讀性。&lt;/p></description><content:encoded><![CDATA[<p>Flaky Test 的核心概念是「同一版本在相同條件下測試結果不穩定」。它會把紅燈從有效訊號降級成噪音，直接影響 CI gate 信任度。</p>
<h2 id="概念位置">概念位置</h2>
<p>Flaky Test 位在 test stage 與 release gate 之間，會放大重跑成本與判讀延遲。</p>
<h2 id="可觀察訊號">可觀察訊號</h2>
<ul>
<li>同一 commit 重跑結果時好時壞。</li>
<li>失敗集中在等待條件、時間假設或外部依賴。</li>
<li>團隊習慣以重跑代替根因修復。</li>
</ul>
<h2 id="接近真實服務的例子">接近真實服務的例子</h2>
<p>UI 測試在動畫未完成時抓取元素，或整合測試依賴不穩定第三方 API，都容易出現 flaky pattern。</p>
<h2 id="設計責任">設計責任</h2>
<p>Flaky Test 治理要建立 owner、隔離策略、修復 SLA 與觀測指標，讓測試結果恢復可判讀性。</p>
]]></content:encoded></item><item><title>Flaky test 治理</title><link>https://tarrragon.github.io/blog/ci/flaky-test-governance/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ci/flaky-test-governance/</guid><description>&lt;p>Flaky test 治理的核心責任是保護 CI gate 的信任度。&lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/flaky-test/" data-link-title="Flaky Test" data-link-desc="說明非決定性測試如何降低 CI gate 信任度與治理方式">Flaky test&lt;/a> 會讓團隊開始用重跑取代判讀，最後讓紅燈失去阻擋意義。&lt;/p>
&lt;h2 id="概念定位">概念定位&lt;/h2>
&lt;p>Flaky test 是非決定性的 gate 訊號。它的危害不只在延遲 merge，而是在心理上訓練團隊忽略紅燈；當真回歸出現時，大家也可能先按 rerun。治理目標是把 flaky 分類、隔離、修復，並保持 required checks 的語意可信。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>責任&lt;/th>
 &lt;th>判讀訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Detect&lt;/td>
 &lt;td>找出非決定性失敗&lt;/td>
 &lt;td>同 commit 重跑結果不一致&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Classify&lt;/td>
 &lt;td>區分測試、環境、資料與產品問題&lt;/td>
 &lt;td>failure pattern、log、trace&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Contain&lt;/td>
 &lt;td>降低對主線 gate 的污染&lt;/td>
 &lt;td>quarantine、owner、expiry&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Fix&lt;/td>
 &lt;td>修掉根因&lt;/td>
 &lt;td>timing、isolation、mock、resource&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Re-admit&lt;/td>
 &lt;td>恢復 gate 信任&lt;/td>
 &lt;td>連續穩定、觀測窗口、owner sign-off&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Detect 階段負責證明 flakiness。單次失敗不應直接貼 flaky 標籤；要看同一 commit、同一測試、相近環境下是否出現 pass / fail 不一致，並保存 log、trace、screenshot 或 seed。&lt;/p>
&lt;p>Classify 階段負責找根因方向。常見來源包含時間競態、測試順序依賴、共享狀態、外部服務、隨機資料、資源不足、瀏覽器 layout timing、網路模擬與 CI runner 差異；不同來源需要不同修法。&lt;/p>
&lt;p>Contain 階段負責保護主線。高價值但暫時 flaky 的測試可以進 quarantine workflow，但必須有 owner、issue、到期日與 replacement gate；直接從 required checks 移除而不追蹤，等於降低品質基線。&lt;/p>
&lt;p>Fix 階段負責消除非決定性。常見修法是移除固定 sleep、改用可觀察條件等待、隔離資料、固定 random seed、避免測試共享全域狀態、mock 不穩定外部依賴或調整資源限制。&lt;/p>
&lt;p>Re-admit 階段負責把測試放回 gate。測試修完後應在多次 workflow、不同 runner 或足夠時間窗口中穩定通過，再恢復 required checks；否則 gate 會反覆被污染。&lt;/p>
&lt;h2 id="分類矩陣">分類矩陣&lt;/h2>
&lt;p>分類矩陣的責任是讓 flaky issue 有明確修復路由。沒有分類時，團隊容易只留下「偶發失敗」這種不可執行標籤。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>常見訊號&lt;/th>
 &lt;th>修復方向&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Timing&lt;/td>
 &lt;td>sleep 不足、元素尚未出現&lt;/td>
 &lt;td>等待可觀察條件、移除固定 sleep&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Shared state&lt;/td>
 &lt;td>單跑通過、整批失敗&lt;/td>
 &lt;td>隔離資料、清理全域狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Order&lt;/td>
 &lt;td>測試順序改變後失敗&lt;/td>
 &lt;td>移除順序依賴、獨立 setup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>External&lt;/td>
 &lt;td>第三方 API、網路或時間服務不穩&lt;/td>
 &lt;td>mock、contract fixture、retry boundary&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Resource&lt;/td>
 &lt;td>CI runner 負載高時失敗&lt;/td>
 &lt;td>降低 parallelism、設定 resource&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Product race&lt;/td>
 &lt;td>真實功能存在競態&lt;/td>
 &lt;td>回到產品修復，不只改測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的邊界是：flaky 可能來自測試，也可能來自產品 race condition。若測試揭露的是產品 race condition，它應該被當成真 bug 處理。&lt;/p>
&lt;h2 id="quarantine-契約">Quarantine 契約&lt;/h2>
&lt;p>Quarantine 的責任是暫時隔離污染，並維持 gate 的長期品質基線。隔離測試時，要把責任、期限與替代風險控制寫清楚。&lt;/p>
&lt;ol>
&lt;li>每個 quarantine test 必須有 issue 與 owner。&lt;/li>
&lt;li>每個 issue 必須標明分類、失敗證據與修復方向。&lt;/li>
&lt;li>Required checks 若移除測試，要補 replacement gate 或風險說明。&lt;/li>
&lt;li>Quarantine workflow 仍需定期跑，並回報趨勢。&lt;/li>
&lt;li>到期未修復時要重新評估：修、刪、改寫或降級測試責任。&lt;/li>
&lt;/ol>
&lt;p>這個契約讓 quarantine 成為治理工具。沒有期限與 owner 的 quarantine 會變成測試墓地，讓主線 gate 永久失去一部分覆蓋。&lt;/p>
&lt;h2 id="tripwire">Tripwire&lt;/h2>
&lt;p>Tripwire 的責任是提示 flaky 已經從局部問題變成流程問題。&lt;/p>
&lt;ul>
&lt;li>團隊看到紅燈第一反應是 rerun：暫停重跑習慣，要求先分類失敗。&lt;/li>
&lt;li>同一測試一週內多次 quarantine：提升到測試架構或產品 race 檢討。&lt;/li>
&lt;li>Required checks 常因環境問題失敗：檢查 runner、resource、cache 與外部依賴。&lt;/li>
&lt;li>Flaky issue 沒 owner 或沒期限：把 quarantine 視為未完成修復，不視為已處理。&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Flaky 術語：讀 &lt;a href="https://tarrragon.github.io/blog/ci/knowledge-cards/flaky-test/" data-link-title="Flaky Test" data-link-desc="說明非決定性測試如何降低 CI gate 信任度與治理方式">Flaky Test&lt;/a>。&lt;/li>
&lt;li>Failure routing：讀 &lt;a href="../github-actions-failure-flow/">CI 失敗到修復發布流程&lt;/a>。&lt;/li>
&lt;li>Gate 邊界：讀 &lt;a href="../ci-gate-workflow-boundary/">CI gate 與 workflow 邊界&lt;/a>。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Flaky test 治理的核心責任是保護 CI gate 的信任度。<a href="/blog/ci/knowledge-cards/flaky-test/" data-link-title="Flaky Test" data-link-desc="說明非決定性測試如何降低 CI gate 信任度與治理方式">Flaky test</a> 會讓團隊開始用重跑取代判讀，最後讓紅燈失去阻擋意義。</p>
<h2 id="概念定位">概念定位</h2>
<p>Flaky test 是非決定性的 gate 訊號。它的危害不只在延遲 merge，而是在心理上訓練團隊忽略紅燈；當真回歸出現時，大家也可能先按 rerun。治理目標是把 flaky 分類、隔離、修復，並保持 required checks 的語意可信。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>責任</th>
          <th>判讀訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Detect</td>
          <td>找出非決定性失敗</td>
          <td>同 commit 重跑結果不一致</td>
      </tr>
      <tr>
          <td>Classify</td>
          <td>區分測試、環境、資料與產品問題</td>
          <td>failure pattern、log、trace</td>
      </tr>
      <tr>
          <td>Contain</td>
          <td>降低對主線 gate 的污染</td>
          <td>quarantine、owner、expiry</td>
      </tr>
      <tr>
          <td>Fix</td>
          <td>修掉根因</td>
          <td>timing、isolation、mock、resource</td>
      </tr>
      <tr>
          <td>Re-admit</td>
          <td>恢復 gate 信任</td>
          <td>連續穩定、觀測窗口、owner sign-off</td>
      </tr>
  </tbody>
</table>
<p>Detect 階段負責證明 flakiness。單次失敗不應直接貼 flaky 標籤；要看同一 commit、同一測試、相近環境下是否出現 pass / fail 不一致，並保存 log、trace、screenshot 或 seed。</p>
<p>Classify 階段負責找根因方向。常見來源包含時間競態、測試順序依賴、共享狀態、外部服務、隨機資料、資源不足、瀏覽器 layout timing、網路模擬與 CI runner 差異；不同來源需要不同修法。</p>
<p>Contain 階段負責保護主線。高價值但暫時 flaky 的測試可以進 quarantine workflow，但必須有 owner、issue、到期日與 replacement gate；直接從 required checks 移除而不追蹤，等於降低品質基線。</p>
<p>Fix 階段負責消除非決定性。常見修法是移除固定 sleep、改用可觀察條件等待、隔離資料、固定 random seed、避免測試共享全域狀態、mock 不穩定外部依賴或調整資源限制。</p>
<p>Re-admit 階段負責把測試放回 gate。測試修完後應在多次 workflow、不同 runner 或足夠時間窗口中穩定通過，再恢復 required checks；否則 gate 會反覆被污染。</p>
<h2 id="分類矩陣">分類矩陣</h2>
<p>分類矩陣的責任是讓 flaky issue 有明確修復路由。沒有分類時，團隊容易只留下「偶發失敗」這種不可執行標籤。</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>常見訊號</th>
          <th>修復方向</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Timing</td>
          <td>sleep 不足、元素尚未出現</td>
          <td>等待可觀察條件、移除固定 sleep</td>
      </tr>
      <tr>
          <td>Shared state</td>
          <td>單跑通過、整批失敗</td>
          <td>隔離資料、清理全域狀態</td>
      </tr>
      <tr>
          <td>Order</td>
          <td>測試順序改變後失敗</td>
          <td>移除順序依賴、獨立 setup</td>
      </tr>
      <tr>
          <td>External</td>
          <td>第三方 API、網路或時間服務不穩</td>
          <td>mock、contract fixture、retry boundary</td>
      </tr>
      <tr>
          <td>Resource</td>
          <td>CI runner 負載高時失敗</td>
          <td>降低 parallelism、設定 resource</td>
      </tr>
      <tr>
          <td>Product race</td>
          <td>真實功能存在競態</td>
          <td>回到產品修復，不只改測試</td>
      </tr>
  </tbody>
</table>
<p>這張表的邊界是：flaky 可能來自測試，也可能來自產品 race condition。若測試揭露的是產品 race condition，它應該被當成真 bug 處理。</p>
<h2 id="quarantine-契約">Quarantine 契約</h2>
<p>Quarantine 的責任是暫時隔離污染，並維持 gate 的長期品質基線。隔離測試時，要把責任、期限與替代風險控制寫清楚。</p>
<ol>
<li>每個 quarantine test 必須有 issue 與 owner。</li>
<li>每個 issue 必須標明分類、失敗證據與修復方向。</li>
<li>Required checks 若移除測試，要補 replacement gate 或風險說明。</li>
<li>Quarantine workflow 仍需定期跑，並回報趨勢。</li>
<li>到期未修復時要重新評估：修、刪、改寫或降級測試責任。</li>
</ol>
<p>這個契約讓 quarantine 成為治理工具。沒有期限與 owner 的 quarantine 會變成測試墓地，讓主線 gate 永久失去一部分覆蓋。</p>
<h2 id="tripwire">Tripwire</h2>
<p>Tripwire 的責任是提示 flaky 已經從局部問題變成流程問題。</p>
<ul>
<li>團隊看到紅燈第一反應是 rerun：暫停重跑習慣，要求先分類失敗。</li>
<li>同一測試一週內多次 quarantine：提升到測試架構或產品 race 檢討。</li>
<li>Required checks 常因環境問題失敗：檢查 runner、resource、cache 與外部依賴。</li>
<li>Flaky issue 沒 owner 或沒期限：把 quarantine 視為未完成修復，不視為已處理。</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Flaky 術語：讀 <a href="/blog/ci/knowledge-cards/flaky-test/" data-link-title="Flaky Test" data-link-desc="說明非決定性測試如何降低 CI gate 信任度與治理方式">Flaky Test</a>。</li>
<li>Failure routing：讀 <a href="../github-actions-failure-flow/">CI 失敗到修復發布流程</a>。</li>
<li>Gate 邊界：讀 <a href="../ci-gate-workflow-boundary/">CI gate 與 workflow 邊界</a>。</li>
</ul>
]]></content:encoded></item><item><title>寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計</title><link>https://tarrragon.github.io/blog/work-log/%E5%AF%AB%E6%B8%AC%E8%A9%A6%E6%99%82-sync-try-catch-%E6%8E%A5%E4%B8%8D%E5%88%B0-bottoast-%E7%9A%84-async-%E9%8C%AF%E8%AA%A4fire-and-forget-api-%E7%9A%84%E6%8E%A5%E7%AE%A1%E8%A8%AD%E8%A8%88/</link><pubDate>Tue, 26 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E5%AF%AB%E6%B8%AC%E8%A9%A6%E6%99%82-sync-try-catch-%E6%8E%A5%E4%B8%8D%E5%88%B0-bottoast-%E7%9A%84-async-%E9%8C%AF%E8%AA%A4fire-and-forget-api-%E7%9A%84%E6%8E%A5%E7%AE%A1%E8%A8%AD%E8%A8%88/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：寫測試時觸及 type system 看不到的 runtime contract — service locator 的注入契約、widget tree 的 framework state、async error 的 try-catch 邊界。三類都要 runtime 才會炸、test 跑到才會曝光。
&lt;strong>案例骨幹&lt;/strong>：&lt;code>Popup.hint&lt;/code> 同一條呼叫路徑同時持有 sync 與 async 兩條失敗路徑（缺 service 注入、BotToast 同步 assert、BotToast 從 async gap 後拋 &lt;code>LateInitializationError&lt;/code>）。用 &lt;code>runZonedGuarded&lt;/code> 把兩條路徑收斂到同一個 fallback handler、用 fallback signature 設計讓訊息不被誤判為 error。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-type-system-看不到的-runtime-contract">1. Type system 看不到的 runtime contract&lt;/h2>
&lt;p>&lt;code>flutter analyze&lt;/code>（與一般的 type checker）的責任是檢查宣告與名稱層的契約 — 型別一致、import 能解析、識別字能對到符號。它驗證的是「靜態可決定的事」：missing import、undefined method、type mismatch 都會在 compile 前被攔下。&lt;/p>
&lt;p>它&lt;strong>看不到&lt;/strong>的是 runtime 才成立的契約，這正是寫測試最容易暴露的盲區：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Service locator 的注入契約&lt;/strong>：GetX 的 &lt;code>Get.find&amp;lt;T&amp;gt;()&lt;/code>、&lt;code>get_it&lt;/code> 的 &lt;code>GetIt.I&amp;lt;T&amp;gt;()&lt;/code>、Provider 的 &lt;code>Provider.of&amp;lt;T&amp;gt;()&lt;/code> 都是 runtime 查找機制（Map lookup 或 widget tree 上溯，視實作而定）。「呼叫前 T 必須先註冊或在 ancestor 提供」是執行期前置條件，型別系統看不見。&lt;/li>
&lt;li>&lt;strong>Framework state 的存在前提&lt;/strong>：BotToast 需要 widget tree 上有 &lt;code>BotToastInit&lt;/code>、Navigator 需要 &lt;code>MaterialApp&lt;/code> 包著。這是 framework 的執行期狀態，不是型別。&lt;/li>
&lt;li>&lt;strong>&lt;code>late&lt;/code> 變數的跨呼叫順序契約&lt;/strong>：宣告對了不代表用對了。analyzer 對單一檔案內某些 unsafe pattern 能出警告，但「A 函式必須在 B 函式前被呼叫」這類跨呼叫順序契約，型別系統看不見。&lt;/li>
&lt;/ul>
&lt;p>這個邊界對「寫測試」的意涵：test setUp 不只是準備資料，更是補上 type system 看不到的 runtime contract — 注入哪些 service、提供哪些 framework state、控制哪些 init 順序。&lt;strong>主程式裡那些「靠 widget tree」「靠 service locator」「靠 framework lifecycle」的契約，每一條都對應到 test setUp 的一個責任&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="2-案例一條呼叫路徑觸及三類邊界">2. 案例：一條呼叫路徑觸及三類邊界&lt;/h2>
&lt;p>下面以 &lt;code>Popup.hint&lt;/code> 對 &lt;code>BotToast.showNotification&lt;/code> 的呼叫為例。寫一個跑 &lt;code>AuthService.afterLogin&lt;/code> 的 unit test 時，這條呼叫一次觸及 runtime contract 段列的三類邊界：service locator 注入缺失、widget tree 缺 &lt;code>BotToastInit&lt;/code>、&lt;code>late&lt;/code> 變數在 async 排程後讀取。三組訊號攤開：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>性質&lt;/th>
 &lt;th>sync try-catch 能接？&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>&amp;quot;LogService&amp;quot; not found.&lt;/code> 從 &lt;code>Get.find&amp;lt;LogService&amp;gt;()&lt;/code> 拋出&lt;/td>
 &lt;td>同步（service locator 查無注入）&lt;/td>
 &lt;td>能，但這層該補 setUp 而非包 try&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>Failed assertion: '_key.currentState != null'&lt;/code> 在 &lt;code>BotToast.showNotification&lt;/code> 入口&lt;/td>
 &lt;td>同步（widget tree 缺 &lt;code>BotToastInit&lt;/code> 入口 assert）&lt;/td>
 &lt;td>能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>LateInitializationError: Local 'cancelFunc' has not been initialized.&lt;/code> 出現在 &lt;code>===== asynchronous gap =====&lt;/code> 之後&lt;/td>
 &lt;td>async + 跨呼叫順序契約破裂（&lt;code>late cancelFunc&lt;/code> 預期在某次 init 之後才讀、但 BotToast 排到下一 frame 時順序對不上）&lt;/td>
 &lt;td>不能&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第一條的修法是 setUp 補注入。第二條的同步 assert 單獨看，sync try-catch 接得住。但它跟第三條 async error 是&lt;strong>同一個 API 的兩種失敗模式&lt;/strong> — 包 sync try-catch 只罩到同步那條、async 那條仍漏。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：寫測試時觸及 type system 看不到的 runtime contract — service locator 的注入契約、widget tree 的 framework state、async error 的 try-catch 邊界。三類都要 runtime 才會炸、test 跑到才會曝光。
<strong>案例骨幹</strong>：<code>Popup.hint</code> 同一條呼叫路徑同時持有 sync 與 async 兩條失敗路徑（缺 service 注入、BotToast 同步 assert、BotToast 從 async gap 後拋 <code>LateInitializationError</code>）。用 <code>runZonedGuarded</code> 把兩條路徑收斂到同一個 fallback handler、用 fallback signature 設計讓訊息不被誤判為 error。</p></blockquote>
<hr>
<h2 id="1-type-system-看不到的-runtime-contract">1. Type system 看不到的 runtime contract</h2>
<p><code>flutter analyze</code>（與一般的 type checker）的責任是檢查宣告與名稱層的契約 — 型別一致、import 能解析、識別字能對到符號。它驗證的是「靜態可決定的事」：missing import、undefined method、type mismatch 都會在 compile 前被攔下。</p>
<p>它<strong>看不到</strong>的是 runtime 才成立的契約，這正是寫測試最容易暴露的盲區：</p>
<ul>
<li><strong>Service locator 的注入契約</strong>：GetX 的 <code>Get.find&lt;T&gt;()</code>、<code>get_it</code> 的 <code>GetIt.I&lt;T&gt;()</code>、Provider 的 <code>Provider.of&lt;T&gt;()</code> 都是 runtime 查找機制（Map lookup 或 widget tree 上溯，視實作而定）。「呼叫前 T 必須先註冊或在 ancestor 提供」是執行期前置條件，型別系統看不見。</li>
<li><strong>Framework state 的存在前提</strong>：BotToast 需要 widget tree 上有 <code>BotToastInit</code>、Navigator 需要 <code>MaterialApp</code> 包著。這是 framework 的執行期狀態，不是型別。</li>
<li><strong><code>late</code> 變數的跨呼叫順序契約</strong>：宣告對了不代表用對了。analyzer 對單一檔案內某些 unsafe pattern 能出警告，但「A 函式必須在 B 函式前被呼叫」這類跨呼叫順序契約，型別系統看不見。</li>
</ul>
<p>這個邊界對「寫測試」的意涵：test setUp 不只是準備資料，更是補上 type system 看不到的 runtime contract — 注入哪些 service、提供哪些 framework state、控制哪些 init 順序。<strong>主程式裡那些「靠 widget tree」「靠 service locator」「靠 framework lifecycle」的契約，每一條都對應到 test setUp 的一個責任</strong>。</p>
<hr>
<h2 id="2-案例一條呼叫路徑觸及三類邊界">2. 案例：一條呼叫路徑觸及三類邊界</h2>
<p>下面以 <code>Popup.hint</code> 對 <code>BotToast.showNotification</code> 的呼叫為例。寫一個跑 <code>AuthService.afterLogin</code> 的 unit test 時，這條呼叫一次觸及 runtime contract 段列的三類邊界：service locator 注入缺失、widget tree 缺 <code>BotToastInit</code>、<code>late</code> 變數在 async 排程後讀取。三組訊號攤開：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>性質</th>
          <th>sync try-catch 能接？</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>&quot;LogService&quot; not found.</code> 從 <code>Get.find&lt;LogService&gt;()</code> 拋出</td>
          <td>同步（service locator 查無注入）</td>
          <td>能，但這層該補 setUp 而非包 try</td>
      </tr>
      <tr>
          <td><code>Failed assertion: '_key.currentState != null'</code> 在 <code>BotToast.showNotification</code> 入口</td>
          <td>同步（widget tree 缺 <code>BotToastInit</code> 入口 assert）</td>
          <td>能</td>
      </tr>
      <tr>
          <td><code>LateInitializationError: Local 'cancelFunc' has not been initialized.</code> 出現在 <code>===== asynchronous gap =====</code> 之後</td>
          <td>async + 跨呼叫順序契約破裂（<code>late cancelFunc</code> 預期在某次 init 之後才讀、但 BotToast 排到下一 frame 時順序對不上）</td>
          <td>不能</td>
      </tr>
  </tbody>
</table>
<p>第一條的修法是 setUp 補注入。第二條的同步 assert 單獨看，sync try-catch 接得住。但它跟第三條 async error 是<strong>同一個 API 的兩種失敗模式</strong> — 包 sync try-catch 只罩到同步那條、async 那條仍漏。</p>
<p>結論：要兩條都接到，需要一個同時 cover sync 與 async 的接管機制。</p>
<hr>
<h2 id="3-sync-try-catch-與-async-error-的邊界">3. Sync try-catch 與 async error 的邊界</h2>
<p>Sync <code>try-catch</code> 的作用範圍是同步調用棧：try block 執行期間棧上拋的錯誤會被接住。一旦執行流程穿越 async 邊界（Future、Timer、microtask 排程），原 try-catch 已經出 scope：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Popup.hint() {
</span></span><span class="line"><span class="ln">2</span><span class="cl">  try {
</span></span><span class="line"><span class="ln">3</span><span class="cl">    BotToast.showNotification(...)   ← 同步返回，立刻離開 try
</span></span><span class="line"><span class="ln">4</span><span class="cl">      └─ 內部排到下一個 frame 或 microtask {  ← 之後才跑
</span></span><span class="line"><span class="ln">5</span><span class="cl">           ...拋 LateInitializationError...   ← try-catch 已經出 scope
</span></span><span class="line"><span class="ln">6</span><span class="cl">         }
</span></span><span class="line"><span class="ln">7</span><span class="cl">  } catch (e) { ... }
</span></span><span class="line"><span class="ln">8</span><span class="cl">}</span></span></code></pre></div><p>辨識 async unhandled error 的訊號是 stack trace 裡有 <code>===== asynchronous gap =====</code> — 它代表錯誤穿越了一個 async 邊界。從 caller frame 來看「沒人在 stack 上」，錯誤會上溯到 zone 的 uncaught error handler；root zone 把它印到 stderr，或讓 flutter_test runner 當作 test failure。</p>
<p><code>async</code> 函式內的 try-catch 是常見混淆點：寫成 <code>try { await x; } catch (e)</code> 時，try-catch <strong>能</strong>接住 <code>await</code> 的 future rejection（<code>await</code> 把 async error rewire 成 sync throw）。但對沒 await 的 fire-and-forget 排程（直接呼叫一個會內部 schedule microtask 的 API），try-catch 的覆蓋範圍止於同步路徑。</p>
<h3 id="風險fire-and-forget-api-的-error-路徑跨-async-邊界">風險：fire-and-forget API 的 error 路徑跨 async 邊界</h3>
<p>BotToast、analytics、Toast、SnackBar 這類 API 通常<strong>同步返回</strong>（讓 caller 不必 await），內部排到下一個 frame 或 microtask 做 UI 工作。caller 看到的是同步呼叫，但錯誤可能從 async 邊界後跑出來。caller 端的 sync try-catch 看起來罩住了，實際接不到。</p>
<hr>
<h2 id="4-接管機制runzonedguarded-同時罩-sync-與-async">4. 接管機制：runZonedGuarded 同時罩 sync 與 async</h2>
<p>接 async unhandled error 要用 zone-aware 機制。<code>runZonedGuarded(body, onError)</code> 建立一個子 zone，<strong>任何在這個 zone 內 schedule 的 async work，錯誤都會冒泡到 <code>onError</code></strong> — 不管錯誤穿越幾層 microtask、Timer、Stream。它同時也 cover 同步拋錯，可以取代 try-catch 包住整個 best-effort 邊界：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// toast 是 best-effort：BotToast 需要 widget tree (BotToastInit)，
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1">// 在非 UI 環境（unit test、isolate）顯示失敗時保留 log、不向 caller 傳遞錯誤。
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1">// 用 runZonedGuarded 因為 BotToast 部分錯誤從 async gap 後拋出，sync try-catch 接不到。
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="n">runZonedGuarded</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">BotToast</span><span class="p">.</span><span class="n">showNotification</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">title:</span> <span class="p">(</span><span class="n">_</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">Text</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="nl">style:</span> <span class="n">AppTheme</span><span class="p">.</span><span class="n">whiteTextButtonStyle</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nl">backgroundColor:</span> <span class="n">contentColor</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nl">duration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">2</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">animationDuration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">300</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nl">animationReverseDuration:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">milliseconds:</span> <span class="m">300</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">},</span> <span class="p">(</span><span class="n">error</span><span class="p">,</span> <span class="n">stack</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">kDebugMode</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">debugPrint</span><span class="p">(</span><span class="s1">&#39;[Popup.hint][fallback] BotToast 不可用，僅記 log：</span><span class="si">$</span><span class="n">error</span><span class="s1">&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>機制重點：同一個 <code>onError</code> 同時接住同步的 <code>Failed assertion</code> 與 async 的 <code>LateInitializationError</code> — sync 與 async 兩條失敗路徑收斂到單一 fallback handler，不需要為兩條各寫一套錯誤處理。</p>
<hr>
<h2 id="5-runzonedguarded-的責任邊界">5. runZonedGuarded 的責任邊界</h2>
<p><code>runZonedGuarded</code> 把整個邊界的錯誤導向 fallback handler，責任範圍要劃清楚：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>行為</th>
          <th>設計意涵</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>async work 自己處理掉錯誤（try-catch 或 <code>.catchError</code>）</td>
          <td>接不到</td>
          <td>zone 看不到已被吞的錯誤；要 zone 接，內層別自吞</td>
      </tr>
      <tr>
          <td><code>onError</code> handler 自己拋錯</td>
          <td>上溯到 parent zone</td>
          <td>handler 要簡短可靠；fallback 自己掛是上層責任</td>
      </tr>
      <tr>
          <td>同步拋錯</td>
          <td>也會被接住</td>
          <td>zone 同時 cover sync 與 async，可取代 try-catch</td>
      </tr>
      <tr>
          <td>zone 內建立的 Timer / Stream</td>
          <td>屬於這個 zone</td>
          <td>spawn 出的 async 物件「記得」自己屬於哪個 zone</td>
      </tr>
  </tbody>
</table>
<p><strong>zone ≠ thread</strong>。Dart 是單線程的，zone 只是邏輯標籤、不涉及並發。它<strong>只改變錯誤的去向、不會 cancel 已 schedule 的 work</strong>。</p>
<h3 id="注意事項何時不該用">注意事項：何時不該用</h3>
<p>Zone 歸屬以 schedule 時的 zone 為準、不是執行時 — async 物件「屬於」schedule 它的那個 zone。這個規則讓跨 zone 操作 Timer、Stream 的行為偏離直覺。實務上最常見的觸發場景是 <code>WidgetsFlutterBinding.ensureInitialized()</code> 在 root zone 註冊了 framework binding 後、才用 <code>runZonedGuarded</code> 包 <code>runApp</code>，binding 內部 callback 已綁在 root zone、外層 zone 接不到。<a href="https://docs.flutter.dev/release/breaking-changes/zone-errors">Flutter 官方明確建議</a> <code>ensureInitialized()</code> 跟 <code>runApp()</code> 都在同一個 <code>runZonedGuarded</code> 內。</p>
<p>zone 適合包「整個邊界」：整個 isolate entry、整個 best-effort UI 工作、整個 background task。<strong>不適合包關鍵 transaction logic</strong> — 那是 try-catch + Future error handling 的責任，zone 是 fallback 收斂層、不是主要錯誤處理。</p>
<hr>
<h2 id="6-fallback-訊息設計可識別的-signature">6. Fallback 訊息設計：可識別的 signature</h2>
<p>Fallback path 跑通之後，留在 console 的訊息會被讀到很多次（每次 test 都會跑）。<strong>訊息措辭要與設計意圖一致</strong>，否則讀者每次都要花心力辨識「這是設計內降級、還是真的 bug」。</p>
<h3 id="風險fallback-長得像-error">風險：fallback 長得像 error</h3>
<p>直覺寫法 <code>debugPrint('toast 顯示失敗：$error')</code> 加上 framework 的 assert stack，字面看起來就是個 error。讀者第一眼會緊張、要花心力比對程式才能確認「這是設計內路徑」。test 跑很多次、每次都付一次辨識成本。</p>
<h3 id="三條設計原則">三條設計原則</h3>
<p><strong>Fallback path 要有可識別的 signature</strong>（標籤、prefix、特定字眼）、長得不像 error。對人類讀者，prefix 是視覺上一眼識別「設計內路徑」；對工具，<code>grep -v &quot;\[fallback\]&quot;</code> 可快速剔除 test 輸出裡的預期降級訊息。</p>
<p><strong>字眼要表達因果與處置</strong>：「BotToast 不可用，僅記 log」比「顯示失敗」更完整 — 前者說了為什麼降級、後者只描述現象。寫 fallback 訊息要回答兩個問題：為什麼進這條路徑、降級到哪。</p>
<p><strong>主程式不該感知測試框架</strong>：主程式 import <code>dart:io</code>、查 <code>Platform.environment['FLUTTER_TEST']</code> 等於「主程式對自己被 test 跑」有意識 — 這違反「主程式不該知道 test 存在」的原則，test 框架是 caller 的事、不是 callee 的事。違反後續成本：app 行為依賴環境變數時，QA / staging / production 的環境一致性會多一條檢查線。</p>
<h3 id="三個候選方案在原則上的取捨">三個候選方案在原則上的取捨</h3>
<p>下列三個方案分別在「signature 識別度」「主程式對 test 框架感知」「dev 可見性」三條原則上做不同取捨：</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>A. 標籤化（<code>[fallback]</code> prefix）</th>
          <th>B. 偵測 <code>FLUTTER_TEST</code> 環境 silent</th>
          <th>C. 完全靜默</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>改動大小</td>
          <td>+1 行</td>
          <td>~10 行 + 新 import</td>
          <td>−1 行</td>
      </tr>
      <tr>
          <td>test 輸出乾淨度</td>
          <td>仍有訊息，但 prefix 一眼識別</td>
          <td>完全乾淨</td>
          <td>完全乾淨</td>
      </tr>
      <tr>
          <td>dev app 跑時可見性</td>
          <td>保留</td>
          <td>保留</td>
          <td>失去</td>
      </tr>
      <tr>
          <td>主程式對 test 框架的感知</td>
          <td>無</td>
          <td>有（import dart:io 查 env）</td>
          <td>無</td>
      </tr>
      <tr>
          <td>grep 友善度</td>
          <td>好（<code>[fallback]</code> prefix）</td>
          <td>—</td>
          <td>—</td>
      </tr>
      <tr>
          <td>BotToast 真壞時 debug 難度</td>
          <td>容易（訊號 + 標籤）</td>
          <td>中（test 看不到、要切環境）</td>
          <td>難（無線索）</td>
      </tr>
  </tbody>
</table>
<h3 id="為什麼選-a">為什麼選 A</h3>
<p>保留 dev 訊號（BotToast 在 dev app 真的壞時 console 仍會印） + 主程式對 test 框架無感知 + prefix 雙贏（人類視覺辨識 + grep 過濾）。方案 C 完全靜默會失去保險、dev 環境真壞時看不見；方案 B 雖然 test 輸出乾淨，代價是違反設計原則。</p>
<hr>
<h2 id="7-設計副產物修主程式對缺依賴的容錯">7. 設計副產物：修主程式對缺依賴的容錯</h2>
<p><code>Popup.hint</code> 對「沒有 widget tree」連環倒，這個失敗不只 unit test 會遇到 — isolate 內、background task 內、任何非 UI 環境都會炸。修 test 順手把主程式對缺依賴的容錯加上，是合理副產物：unit test 是觸發訊號、主程式被觸發後變得更能適應多元 caller 環境，這個改動的受益面大於原本 test 暴露的那個情境。</p>
<p><strong>主程式變 robust 的價值大於「讓 test 過」</strong>。修主程式對 caller 環境的容錯時要分辨「容錯」與「掩蓋」的界線：log 仍要留、fallback signature 仍要可識別（Fallback 訊息設計段），錯誤完全靜默會讓 dev app 真壞掉時也看不見。</p>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p><code>runZonedGuarded</code> 適用情境：</p>
<ul>
<li><strong>Fire-and-forget 的 UI 通知</strong>：Toast、SnackBar、analytics 上報；這些是 best-effort，caller 連環倒不合理。</li>
<li><strong>Isolate entry point</strong>：spawn 出來的 isolate 沒有預設 error handler，包一層 zone 才不會靜默掛掉。</li>
<li><strong>Background task / Timer 包裝</strong>：long-running periodic job 內部錯誤不該炸掉整個 process。</li>
<li><strong>flutter_test 內掛 Stream / Future 驗證</strong>：把測試體包進 zone 才能完整接 async 拋出的東西。</li>
</ul>
<p>「Type system 看不到的 runtime contract」適用任何用 service locator / DI 容器、framework state、late init 的 Flutter 專案。Test 是這些 runtime contract 的事實驗證者 — analyze 過了不代表這些契約沒破，test 跑到才會炸。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://api.dart.dev/stable/dart-async/runZonedGuarded.html">Dart <code>runZonedGuarded</code> API</a></li>
<li><a href="https://dart.dev/articles/archive/zones">Dart Zone 概念與 zone-local variables</a></li>
<li><a href="https://docs.flutter.dev/release/breaking-changes/zone-errors">Flutter Zone mismatch breaking change</a> — <code>ensureInitialized()</code> 與 <code>runApp()</code> 必須同 zone</li>
<li><a href="https://api.flutter.dev/flutter/flutter_test/FlutterTest-library.html"><code>flutter_test</code> async error 處理機制</a></li>
<li>同主題本站文章：<a href="../dart_test_getx_cross_file_state_pollution/">Dart test 的跨檔案 GetX 狀態污染</a> — 另一種「test 環境組裝不完整」的 case</li>
</ul>
]]></content:encoded></item><item><title>Dart test 的跨檔案 GetX 狀態污染：flaky 真因不是 fail 訊息上的那個 test</title><link>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</link><pubDate>Thu, 07 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/dart-test-%E7%9A%84%E8%B7%A8%E6%AA%94%E6%A1%88-getx-%E7%8B%80%E6%85%8B%E6%B1%A1%E6%9F%93flaky-%E7%9C%9F%E5%9B%A0%E4%B8%8D%E6%98%AF-fail-%E8%A8%8A%E6%81%AF%E4%B8%8A%E7%9A%84%E9%82%A3%E5%80%8B-test/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>事故類型&lt;/strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
&lt;strong>症狀&lt;/strong>：&lt;code>flutter test&lt;/code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
&lt;strong>根因&lt;/strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="事故場景">事故場景&lt;/h2>
&lt;h3 id="表面症狀">表面症狀&lt;/h3>
&lt;p>跑 &lt;code>flutter test&lt;/code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">00:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">00:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">00:27 +125: Some tests failed.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>訊息直接點名 &lt;code>PrintCenter 廚房印表機管理&lt;/code> group 的兩個 test「did not complete」。直覺反應：那兩個 test 有問題、去看那個 file。&lt;/p>
&lt;h3 id="第一次診斷與失敗的修法">第一次診斷與失敗的修法&lt;/h3>
&lt;p>打開 &lt;code>online_order_print_handler_test.dart&lt;/code>，看到 &lt;code>PrintCenter 廚房印表機管理&lt;/code> group 的 setUp 沒做 &lt;code>Get.reset()&lt;/code>、純粹依賴 outer setUp 的 &lt;code>Get.reset()&lt;/code>。判斷可能是 outer setUp 的 &lt;code>OnlineOrderPrintHandler.onInit&lt;/code> 在這個 group 留下副作用（stream subscription 之類），於是給這個 group 加自己的 reset：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;PrintCenter 廚房印表機管理&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="n">late&lt;/span> &lt;span class="n">PrintCenter&lt;/span> &lt;span class="n">printCenter&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">setUp&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// ← 加這行隔離 outer setUp 的副作用
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">printCenter&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PrintCenter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">FakePrinterAdapter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;main&amp;#39;&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">put&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">printCenter&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="n">tearDown&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">Get&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// ← 加這行確保不殘留
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>跑 5 次：Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——&lt;strong>flakiness 比例沒改變&lt;/strong>。&lt;/p>
&lt;p>修錯了。&lt;/p>
&lt;h3 id="重新診斷看-n--1-計數的真正位置">重新診斷：看 &lt;code>+N -1&lt;/code> 計數的真正位置&lt;/h3>
&lt;p>把 fail 輸出存進檔案、仔細看 progress line 的 &lt;code>+N -1&lt;/code> 部分：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">00:08 +125 -1: ... auto_service_config_test.dart: ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">00:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更：badge 立即更新（list 與 selected 都沒命中時）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">00:08 +127 -1: ... auto_service_config_test.dart: ...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>-1&lt;/code> 在第 126 個 test 才第一次出現——失敗的不是 print handler，是中間夾的 &lt;strong>widget test&lt;/strong>。再看另一次 fail：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>事故類型</strong>：cross-file 狀態污染、dart test runner 同 process 共用 GetX
<strong>症狀</strong>：<code>flutter test</code> 約 50% 機率隨機失敗、每次失敗的 test 不固定；單獨跑該 test file 100% 通過
<strong>根因</strong>：dart test runner 在同 process 內跑多個 test file 共用 GetX 容器；前面 file 的 setUp 留下殘留（測試 mode 旗標、未 dispose 的 controller、stream subscription）污染後面 file 的測試環境</p></blockquote>
<hr>
<h2 id="事故場景">事故場景</h2>
<h3 id="表面症狀">表面症狀</h3>
<p>跑 <code>flutter test</code> 全 suite，Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail。看到的失敗訊息類似：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00:27 +125: PrintCenter 廚房印表機管理 kitchenPrinter 向後兼容取第一台 - did not complete [E]
</span></span><span class="line"><span class="ln">2</span><span class="cl">00:27 +125: PrintCenter 廚房印表機管理 重複呼叫 initFakeKitchenPrinters 會清除舊的 - did not complete [E]
</span></span><span class="line"><span class="ln">3</span><span class="cl">00:27 +125: Some tests failed.</span></span></code></pre></div><p>訊息直接點名 <code>PrintCenter 廚房印表機管理</code> group 的兩個 test「did not complete」。直覺反應：那兩個 test 有問題、去看那個 file。</p>
<h3 id="第一次診斷與失敗的修法">第一次診斷與失敗的修法</h3>
<p>打開 <code>online_order_print_handler_test.dart</code>，看到 <code>PrintCenter 廚房印表機管理</code> group 的 setUp 沒做 <code>Get.reset()</code>、純粹依賴 outer setUp 的 <code>Get.reset()</code>。判斷可能是 outer setUp 的 <code>OnlineOrderPrintHandler.onInit</code> 在這個 group 留下副作用（stream subscription 之類），於是給這個 group 加自己的 reset：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">group</span><span class="p">(</span><span class="s1">&#39;PrintCenter 廚房印表機管理&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">late</span> <span class="n">PrintCenter</span> <span class="n">printCenter</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>  <span class="c1">// ← 加這行隔離 outer setUp 的副作用
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>    <span class="n">printCenter</span> <span class="o">=</span> <span class="n">PrintCenter</span><span class="p">(</span><span class="n">FakePrinterAdapter</span><span class="p">(</span><span class="s1">&#39;main&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">put</span><span class="p">(</span><span class="n">printCenter</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">tearDown</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>  <span class="c1">// ← 加這行確保不殘留
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>跑 5 次：Run 1 fail、Run 2 pass、Run 3 pass、Run 4 fail、Run 5 fail——<strong>flakiness 比例沒改變</strong>。</p>
<p>修錯了。</p>
<h3 id="重新診斷看-n--1-計數的真正位置">重新診斷：看 <code>+N -1</code> 計數的真正位置</h3>
<p>把 fail 輸出存進檔案、仔細看 progress line 的 <code>+N -1</code> 部分：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00:08 +125 -1: ... auto_service_config_test.dart: ...
</span></span><span class="line"><span class="ln">2</span><span class="cl">00:08 +126 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity searchedOrder 變更：badge 立即更新（list 與 selected 都沒命中時）
</span></span><span class="line"><span class="ln">3</span><span class="cl">00:08 +127 -1: ... auto_service_config_test.dart: ...</span></span></code></pre></div><p><code>-1</code> 在第 126 個 test 才第一次出現——失敗的不是 print handler，是中間夾的 <strong>widget test</strong>。再看另一次 fail：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">00:09 +124 -1: ... settle_page_order_object_test.dart: SettlePage.orderObject reactivity orderList[i] 替換：badge 從「已完成」立即變「退貨」</span></span></code></pre></div><p>不同 run 失敗的 test 不一樣，但都是 <code>settle_page_order_object_test.dart</code> 的不同 case。print handler 的 <code>did not complete</code> 是被牽連、不是源頭。</p>
<h3 id="確認-root-cause單獨跑全綠">確認 root cause：單獨跑全綠</h3>
<p>把 widget test 單獨重複跑 8 次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> i in <span class="m">1</span> <span class="m">2</span> <span class="m">3</span> <span class="m">4</span> <span class="m">5</span> <span class="m">6</span> <span class="m">7</span> 8<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  flutter <span class="nb">test</span> test/widgets/settle_page_order_object_test.dart 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> tail -1
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">done</span></span></span></code></pre></div><p>8/8 全綠。<strong>單獨跑沒問題、混進全 suite 跑就 flaky</strong>——這是 cross-file pollution 的固定特徵。</p>
<hr>
<h2 id="為什麼-did-not-complete-訊息會誤導">為什麼 <code>did not complete</code> 訊息會誤導</h2>
<p>dart test runner 的失敗訊息設計上有個盲點：</p>
<ul>
<li><code>+N</code> 是累計通過數</li>
<li><code>-N</code> 是累計失敗數</li>
<li><code>did not complete</code> 是某個 test 還沒跑完整體就終止了（process 退出 / 超時 / 前面有未捕捉錯誤導致 runner 提前結束）</li>
</ul>
<p>當前面有 test 失敗、後面的 test 沒機會跑、這些後面的 test 會印 <code>did not complete</code>——但<strong>它們本身沒問題</strong>。看到 <code>did not complete</code> 直覺會想「這個 test 卡住了」、但真實意思更接近「這個 test 還沒跑、上游已掛」。</p>
<p>正確的診斷流程：</p>
<ol>
<li>找 <code>-N</code> 第一次出現的位置（<code>-1</code> 表示第一個失敗）</li>
<li>對照那一行的 test 名稱、那才是真正失敗的源頭</li>
<li><code>did not complete</code> 出現的 test 通常只是受牽連</li>
</ol>
<p>我第一次掉的坑：直接讀 <code>did not complete</code> 的 test 名、跳過了「往前找 <code>-1</code> 第一次出現」這步。</p>
<hr>
<h2 id="為什麼-cross-file-會污染dart-test-runner-與-getx-的不對齊">為什麼 cross-file 會污染：dart test runner 與 GetX 的不對齊</h2>
<h3 id="dart-test-runner-的執行模型">dart test runner 的執行模型</h3>
<p><code>flutter test</code>（背後是 <code>dart test</code>）跑全 suite 時不一定 1 file = 1 isolate。預設行為：</p>
<ul>
<li>多個 test file 可能共用同一個 isolate / Dart VM</li>
<li>共用 isolate 等於共用所有 process-scoped state（static field、singleton、未 GC 的全域物件）</li>
</ul>
<p>並發策略受 <code>--concurrency</code> 與 platform 影響、行為不固定，但「共用 process」是日常常見現象。</p>
<h3 id="getx-的-state-是-process-scoped">GetX 的 state 是 process-scoped</h3>
<p>GetX 的 <code>Get.put</code> / <code>Get.find</code> 把 instance 放進一個 process-global 容器。<code>Get.reset()</code> 清空容器、但有些東西不會被 reset：</p>
<ul>
<li><code>Get.testMode</code> 是 static field、<code>reset()</code> 不動它</li>
<li>如果 instance 在 onInit 內 subscribe 了 stream（例如 <code>BroadcastReceiveService.messages.listen</code>）、<code>Get.reset()</code> 移除 instance reference 但 <strong>subscription 不會自動 cancel</strong></li>
<li>StreamController / Timer / Future.delayed 在 GetX 容器外仍然活著</li>
</ul>
<h3 id="實際發生的污染鏈">實際發生的污染鏈</h3>
<p>跑全 suite 時，假設執行順序是：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. test/services/online_order/...      ← 最前面
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. test/widgets/settle_page_order_...   ← 中間
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. test/services/auth_service_config... ← 後面</span></span></code></pre></div><p>第 1 個 file 的 setUp 若有 <code>Get.put(SomeService())</code>，service 在 onInit 內訂閱了 stream，就算 tearDown 跑了 <code>Get.reset()</code>、那條 stream subscription 仍 active。第 2 個 file 開始跑時：</p>
<ul>
<li>它的 setUp 也呼叫 <code>Get.put(...)</code>、放進去的物件可能是 <strong>完全不同類型</strong> ——但 GetX 容器內可能還有上一輪殘留的物件</li>
<li>第 2 個 file 的 widget test 進入 widget tree、Obx 訂閱、各種 reactive 路徑啟動</li>
<li>上一輪殘留的 stream / timer 此時 fire、進到不該觸及的 state</li>
</ul>
<p>整個 race 在「殘留事件何時 fire vs widget test 何時 expect」之間，所以 flakiness 是 ~50% 而不是 100%。</p>
<hr>
<h2 id="解法setup-開頭主動-reset">解法：setUp 開頭主動 reset</h2>
<p>對任何用 GetX 的 test，setUp 最開頭就該 reset、不要依賴上一個 file 的 tearDown 跑乾淨：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">setUp</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="c1">// 同 process 內跑全 suite 時其他 test file 可能在 GetX 容器留殘留
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="c1">// （Get.testMode、未 dispose 的 controller、未 cancel 的 stream subscription），
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="c1">// setUp 開頭主動 reset 切斷 cross-file 污染
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">Get</span><span class="p">.</span><span class="n">testMode</span> <span class="o">=</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// ... 之後再 Get.put 自己需要的東西
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">tearDown</span><span class="p">(()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">Get</span><span class="p">.</span><span class="n">reset</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>把這個 pattern 加到所有 widget test 與 controller test 的 setUp 之後，全 suite 連跑 5 次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Run 1: All tests passed!
</span></span><span class="line"><span class="ln">2</span><span class="cl">Run 2: All tests passed!
</span></span><span class="line"><span class="ln">3</span><span class="cl">Run 3: All tests passed!
</span></span><span class="line"><span class="ln">4</span><span class="cl">Run 4: All tests passed!
</span></span><span class="line"><span class="ln">5</span><span class="cl">Run 5: All tests passed!</span></span></code></pre></div><p>5/5 全綠，flakiness 消失。</p>
<h3 id="為什麼-teardown-的-reset-不夠">為什麼 tearDown 的 reset 不夠</h3>
<p>理論上 tearDown 已經 <code>Get.reset()</code> 了，下個 test 的 setUp 看到的應該是乾淨容器——但這個推理在「同 file 內」成立、跨 file 不成立：</p>
<ul>
<li>跨 file 之間 dart test runner 在 file 邊界做的事是不確定的（可能整個 isolate 重啟、也可能只是切換 group）</li>
<li>即使前一個 file 的 tearDown 跑完，跨 file 的某個 microtask / timer callback 仍可能在後一個 file 的 setUp 之前 fire</li>
<li>用 setUp 開頭的 reset 等於再保險一次、把這個邊界內的不確定性吃掉</li>
</ul>
<hr>
<h2 id="除錯思維flaky-test-的固定診斷流程">除錯思維：flaky test 的固定診斷流程</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">1. 看是不是真的 flaky
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">   - 連跑 5~10 次、計算成功率
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">   - 隨機失敗（不是 100% 也不是 0%）→ 進入 flaky 診斷
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">2. 找真正的失敗源頭
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">   - 看 progress line <span class="sb">`</span>+N -M<span class="sb">`</span>、找 -1 第一次出現位置
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">   - 不要直接讀 <span class="s2">&#34;did not complete&#34;</span>、那是受牽連訊息
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">3. 判斷是 in-file 還是 cross-file 污染
</span></span><span class="line"><span class="ln">10</span><span class="cl">   - 失敗的 <span class="nb">test</span> 單獨跑：
</span></span><span class="line"><span class="ln">11</span><span class="cl">     - 100% 通過 → cross-file 污染（其他 file 的殘留進來）
</span></span><span class="line"><span class="ln">12</span><span class="cl">     - 也會隨機 fail → in-file 污染（同 file 的 <span class="nb">test</span> 之間互相污染）
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">4. 補對應的隔離
</span></span><span class="line"><span class="ln">15</span><span class="cl">   - cross-file → setUp 開頭 Get.reset<span class="o">()</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">   - in-file → 看是 setUp/tearDown 沒清乾淨還是 <span class="nb">test</span> 之間共享 mutable state</span></span></code></pre></div><hr>
<h2 id="教訓">教訓</h2>
<ol>
<li><strong><code>did not complete</code> 不是失敗源、是被牽連訊息</strong>——往前找 <code>-1</code> 第一次出現的位置才是真正失敗的 test。</li>
<li><strong>單獨跑通過 + 全 suite fail = cross-file pollution</strong>——這是 flaky test 最常見的固定模式之一、有專屬的解法（setUp reset）、不要當成「資料時序的隨機性」隨便重跑。</li>
<li><strong>tearDown 清不夠、setUp 也要清</strong>——任何用 GetX 的 test 應該在 setUp 開頭主動 <code>Get.reset()</code>、不要依賴上一個 file 的 tearDown。</li>
<li><strong>第一次診斷錯誤是常態、要回到證據</strong>——順著 fail 訊息修是直覺反應、但訊息可能誤導；停下來看計數欄位、單獨跑驗證、才是穩定的診斷方式。</li>
</ol>
<hr>
<h2 id="適用範圍">適用範圍</h2>
<p>這個 pattern 不限於 GetX、適用於任何在 process-scoped global state 註冊東西的框架：</p>
<ul>
<li><code>Provider</code> 的 <code>MultiProvider</code> / 全域 instance</li>
<li><code>Riverpod</code> 的 <code>ProviderContainer</code>（雖然 Riverpod 設計上更鼓勵 per-test container）</li>
<li>自寫的 service locator / singleton</li>
<li>任何 <code>static</code> field 累積的狀態</li>
</ul>
<p>只要框架的 state 跨 test boundary 而 dart test runner 又在同 process 跑多 file，cross-file pollution 都可能發生。setUp 開頭主動 reset 是通用防身術。</p>
<hr>
<h2 id="參考資料">參考資料</h2>
<ul>
<li><a href="https://github.com/dart-lang/test/blob/master/pkgs/test/doc/configuration.md#concurrency">Dart <code>package:test</code> runner concurrency docs</a></li>
<li><a href="https://github.com/jonataslaw/getx">GetX <code>Get.reset()</code> source</a></li>
<li><a href="https://api.flutter.dev/flutter/flutter_test/TestWidgetsFlutterBinding-class.html">Flutter <code>flutter_test</code> binding lifecycle</a></li>
</ul>
]]></content:encoded></item></channel></rss>