<?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>Binding on Tarragon</title><link>https://tarrragon.github.io/blog/tags/binding/</link><description>Recent content in Binding on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 17 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/binding/index.xml" rel="self" type="application/rss+xml"/><item><title>流程測試基礎設施</title><link>https://tarrragon.github.io/blog/flutter/flow-test-infrastructure/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/flow-test-infrastructure/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>驅動的是跨服務的真實編排，&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>從策略層回答了「什麼時候值得建假後端、流程測試驗證什麼」；本章處理拿著策略在 Dart/Flutter 生態落地時碰到的實作限制。四個限制形成一條建置鏈：前一個的答案決定後一個的形態，跳著解會繞遠路。&lt;/p>
&lt;h2 id="閘門-spike編排的宿主能不能在-headless-環境立起來">閘門 spike：編排的宿主能不能在 headless 環境立起來&lt;/h2>
&lt;p>流程測試要驅動的是控制器裡的真實編排——順序約束、防護動作、收尾同步——而 Flutter 的控制器在 &lt;code>onInit&lt;/code> 常帶平台耦合（platform channel 訂閱、相機偵測、外接裝置 plugin）。這些耦合在 headless 測試環境全部失效。&lt;/p>
&lt;p>開工前先做一條最小測試 spike：能否建構控制器並呼叫編排入口。spike 的答案決定整個套件形態——立得起來就直接驅動真實編排（零漂移），立不起來要先評估重構接縫的成本。&lt;/p>
&lt;p>平台耦合的中和工具在 Dart 生態有固定的對應：&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>EventChannel（plugin 建構子就訂閱）&lt;/td>
 &lt;td>&lt;code>setMockStreamHandler&lt;/code> 掛空 handler&lt;/td>
 &lt;td>在&lt;strong>建構 plugin 物件之前&lt;/strong>掛好&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MethodChannel（查詢類呼叫）&lt;/td>
 &lt;td>&lt;code>setMockMethodCallHandler&lt;/code> 回空結果&lt;/td>
 &lt;td>在&lt;strong>呼叫發生之前&lt;/strong>掛好&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>服務級平台依賴（掃碼、藍牙）&lt;/td>
 &lt;td>手寫 no-op 子類覆寫碰平台的成員&lt;/td>
 &lt;td>在 DI 容器註冊子類取代原服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>addPostFrameCallback&lt;/code> 承載的初始化&lt;/td>
 &lt;td>測試裡手動呼叫同一個方法&lt;/td>
 &lt;td>控制器建構後、斷言前&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>時序欄位承載的是這張表最容易踩的陷阱：EventChannel 的訂閱發生在 plugin 建構子裡，晚一步掛 mock，訂閱已經對著真實 channel 成立、之後補掛不會回溯生效——症狀是測試偶發卡在等事件。MethodChannel 的容錯高一些（呼叫當下才查 handler），但同樣要在第一次呼叫前就位。no-op 子類優於 mock 框架的場景：要中和的成員少（覆寫列表一目瞭然）、其餘行為要保留真實。流程測試的精神是假件越少，測試的證言越可信——這裡指的是平台耦合層的假件（與被驗編排無關的雜訊），被測邊界本身的假後端是另一回事，它的設計判準與忠實性把關見&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端&lt;/a>。&lt;/p>
&lt;p>這套 channel mock 加 no-op 子類的組合（後文稱 harness）一旦成立就收斂為共用 bootstrap 方法——之後每條流程測試的邊際成本只剩劇本本身。完整 case 與程式碼範例：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來&lt;/a>。&lt;/p>
&lt;h2 id="兩種測試的共存binding-的檔案級-isolate-隔離">兩種測試的共存：binding 的檔案級 isolate 隔離&lt;/h2>
&lt;p>流程測試需要 &lt;code>TestWidgetsFlutterBinding&lt;/code>（mock channel、立控制器），真實後端驗證測試需要真實網路——兩者互斥。&lt;code>TestWidgetsFlutterBinding.ensureInitialized()&lt;/code> 的副作用之一是把 &lt;code>dart:io&lt;/code> 的 &lt;code>HttpClient&lt;/code> 換成一律回 400 的假件，且這個副作用是程序級全域、沒有乾淨的關閉開關。&lt;/p>
&lt;p>共存機制不需要額外設計：&lt;code>flutter test&lt;/code> 讓每個測試檔案跑在獨立 isolate，而 isolate 之間記憶體不共享——binding 換掉的全域物件只在自己的 isolate 內生效，副作用因此以檔案為邊界。&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>binding&lt;/td>
 &lt;td>&lt;code>ensureInitialized()&lt;/code>&lt;/td>
 &lt;td>不初始化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HttpClient&lt;/td>
 &lt;td>假件（走假後端 adapter）&lt;/td>
 &lt;td>真實（需要）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隔離邊界&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>硬約束：真實後端驗證測試的檔案&lt;strong>不可 import 流程測試的 harness&lt;/strong>——harness 為了 mock channel 第一步就是 &lt;code>ensureInitialized&lt;/code>，import 進來即使不直接呼叫，任何共用 helper 順手初始化都會中招。違反這條約束的症狀是「穩定 400、無網路痕跡」——症狀與原因之間距離太遠，值得寫進檔頭。&lt;/p>
&lt;p>真實後端驗證檔裡繞開產品 DI 的做法：手組最小可用的 &lt;code>Dio&lt;/code>，請求與解析仍走產品的 API client 與模型（型別化解析層共用、不手寫 JSON）。完整機制與程式碼：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">TestWidgetsFlutterBinding 會擋掉真實網路&lt;/a>。&lt;/p>
&lt;h2 id="輸出雜訊治理預期環境狀態的正確處理路徑">輸出雜訊治理：預期環境狀態的正確處理路徑&lt;/h2>
&lt;p>harness 立起後，測試輸出可能固定印出幾行「錯誤長相」的文字——相機偵測的 &lt;code>MissingPluginException&lt;/code>、toast 套件的 assert fallback。這些在生產環境是正確的防護路徑，在測試環境是必然觸發的假警報。假警報訓練人忽略輸出，新的真警報混在裡面就被同一個心理過濾器吃掉。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>驅動的是跨服務的真實編排，<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>從策略層回答了「什麼時候值得建假後端、流程測試驗證什麼」；本章處理拿著策略在 Dart/Flutter 生態落地時碰到的實作限制。四個限制形成一條建置鏈：前一個的答案決定後一個的形態，跳著解會繞遠路。</p>
<h2 id="閘門-spike編排的宿主能不能在-headless-環境立起來">閘門 spike：編排的宿主能不能在 headless 環境立起來</h2>
<p>流程測試要驅動的是控制器裡的真實編排——順序約束、防護動作、收尾同步——而 Flutter 的控制器在 <code>onInit</code> 常帶平台耦合（platform channel 訂閱、相機偵測、外接裝置 plugin）。這些耦合在 headless 測試環境全部失效。</p>
<p>開工前先做一條最小測試 spike：能否建構控制器並呼叫編排入口。spike 的答案決定整個套件形態——立得起來就直接驅動真實編排（零漂移），立不起來要先評估重構接縫的成本。</p>
<p>平台耦合的中和工具在 Dart 生態有固定的對應：</p>
<table>
  <thead>
      <tr>
          <th>耦合類型</th>
          <th>中和手段</th>
          <th>時序要求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EventChannel（plugin 建構子就訂閱）</td>
          <td><code>setMockStreamHandler</code> 掛空 handler</td>
          <td>在<strong>建構 plugin 物件之前</strong>掛好</td>
      </tr>
      <tr>
          <td>MethodChannel（查詢類呼叫）</td>
          <td><code>setMockMethodCallHandler</code> 回空結果</td>
          <td>在<strong>呼叫發生之前</strong>掛好</td>
      </tr>
      <tr>
          <td>服務級平台依賴（掃碼、藍牙）</td>
          <td>手寫 no-op 子類覆寫碰平台的成員</td>
          <td>在 DI 容器註冊子類取代原服務</td>
      </tr>
      <tr>
          <td><code>addPostFrameCallback</code> 承載的初始化</td>
          <td>測試裡手動呼叫同一個方法</td>
          <td>控制器建構後、斷言前</td>
      </tr>
  </tbody>
</table>
<p>時序欄位承載的是這張表最容易踩的陷阱：EventChannel 的訂閱發生在 plugin 建構子裡，晚一步掛 mock，訂閱已經對著真實 channel 成立、之後補掛不會回溯生效——症狀是測試偶發卡在等事件。MethodChannel 的容錯高一些（呼叫當下才查 handler），但同樣要在第一次呼叫前就位。no-op 子類優於 mock 框架的場景：要中和的成員少（覆寫列表一目瞭然）、其餘行為要保留真實。流程測試的精神是假件越少，測試的證言越可信——這裡指的是平台耦合層的假件（與被驗編排無關的雜訊），被測邊界本身的假後端是另一回事，它的設計判準與忠實性把關見<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端</a>。</p>
<p>這套 channel mock 加 no-op 子類的組合（後文稱 harness）一旦成立就收斂為共用 bootstrap 方法——之後每條流程測試的邊際成本只剩劇本本身。完整 case 與程式碼範例：<a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來</a>。</p>
<h2 id="兩種測試的共存binding-的檔案級-isolate-隔離">兩種測試的共存：binding 的檔案級 isolate 隔離</h2>
<p>流程測試需要 <code>TestWidgetsFlutterBinding</code>（mock channel、立控制器），真實後端驗證測試需要真實網路——兩者互斥。<code>TestWidgetsFlutterBinding.ensureInitialized()</code> 的副作用之一是把 <code>dart:io</code> 的 <code>HttpClient</code> 換成一律回 400 的假件，且這個副作用是程序級全域、沒有乾淨的關閉開關。</p>
<p>共存機制不需要額外設計：<code>flutter test</code> 讓每個測試檔案跑在獨立 isolate，而 isolate 之間記憶體不共享——binding 換掉的全域物件只在自己的 isolate 內生效，副作用因此以檔案為邊界。</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>流程測試檔</th>
          <th>真實後端驗證檔</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>binding</td>
          <td><code>ensureInitialized()</code></td>
          <td>不初始化</td>
      </tr>
      <tr>
          <td>HttpClient</td>
          <td>假件（走假後端 adapter）</td>
          <td>真實（需要）</td>
      </tr>
      <tr>
          <td>隔離邊界</td>
          <td>檔案自己的 isolate</td>
          <td>檔案自己的 isolate</td>
      </tr>
  </tbody>
</table>
<p>硬約束：真實後端驗證測試的檔案<strong>不可 import 流程測試的 harness</strong>——harness 為了 mock channel 第一步就是 <code>ensureInitialized</code>，import 進來即使不直接呼叫，任何共用 helper 順手初始化都會中招。違反這條約束的症狀是「穩定 400、無網路痕跡」——症狀與原因之間距離太遠，值得寫進檔頭。</p>
<p>真實後端驗證檔裡繞開產品 DI 的做法：手組最小可用的 <code>Dio</code>，請求與解析仍走產品的 API client 與模型（型別化解析層共用、不手寫 JSON）。完整機制與程式碼：<a href="/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">TestWidgetsFlutterBinding 會擋掉真實網路</a>。</p>
<h2 id="輸出雜訊治理預期環境狀態的正確處理路徑">輸出雜訊治理：預期環境狀態的正確處理路徑</h2>
<p>harness 立起後，測試輸出可能固定印出幾行「錯誤長相」的文字——相機偵測的 <code>MissingPluginException</code>、toast 套件的 assert fallback。這些在生產環境是正確的防護路徑，在測試環境是必然觸發的假警報。假警報訓練人忽略輸出，新的真警報混在裡面就被同一個心理過濾器吃掉。</p>
<p>治理原則：測試環境 100% 必然成立的觸發條件，用前置判斷或 harness mock 讓它走正常路徑，讓例外接管只剩真正的例外。</p>
<table>
  <thead>
      <tr>
          <th>雜訊類型</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>平台 plugin 不存在導致的 <code>MissingPluginException</code></td>
          <td>harness 的 channel mock 回空結果（上一節已掛的 mock 同時解決）</td>
      </tr>
      <tr>
          <td>headless 環境沒有 widget tree 導致的 assert fallback</td>
          <td>顯示前前置判斷 <code>rootElement != null</code>，無畫面時跳過顯示</td>
      </tr>
  </tbody>
</table>
<p>前置判斷放在 <code>runZonedGuarded</code> 的 zone 內而非外——極端環境（binding 未初始化）連 <code>WidgetsBinding.instance</code> 都會拋，放 zone 內讓它落入兜底。分工因此變乾淨：前置判斷處理已知的環境狀態，zone 兜底處理未知的失敗。完整案例與判準：<a href="/blog/work-log/flutter_test_noise_expected_paths/" data-link-title="測試輸出的雜訊治理：預期的環境狀態不該走例外路徑" data-link-desc="測試輸出長期印著兩行「已知無害」的錯誤——相機偵測 MissingPluginException、toast 套件的 assert fallback。已知雜訊會訓練人忽略輸出，新警報混在裡面就被過濾掉。修法是把「預期的環境狀態」變成前置判斷（channel mock 回空、無畫面早退），讓例外路徑只剩真正的例外。">測試輸出的雜訊治理</a>。</p>
<h2 id="假後端的回應序列化物件--tojson不是手寫-json">假後端的回應序列化：物件 → toJson，不是手寫 JSON</h2>
<p>有狀態假後端的回應資料從哪裡來，是 Dart 生態特有的選型：freezed 模型天生雙向（<code>fromJson</code>/<code>toJson</code>），假後端持有模型物件、出口一律 <code>toJson()</code>，服務層走的反序列化路徑與生產環境完全相同。</p>
<p>手寫 JSON 樣板跳過這個閉環的前半段——樣板對不對靠人眼比對 API 文件。實際案例：同一批測試裡的 raw 寫法重踩了產品早已內建處理的分頁包裝（信封解析層的知識被重複實作在測試 helper 裡），改走產品的 API client 與模型後，helper 連同它代表的重複知識一起刪除。</p>
<p>判準一句話：<strong>回應形狀的知識只該存在一份</strong>。測試裡出現 <code>data['data']</code> 之類手挖回應欄位的 helper，就是重複知識的訊號。</p>
<p>假後端的狀態演變用 <code>copyWith</code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。toJson 閉環的忠實性由配對的<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>把關——模型的 <code>fromJson</code>/<code>toJson</code> 若與真實後端不對稱，會在那裡先於產品爆出來。完整做法與對照組事故：<a href="/blog/work-log/flutter_fake_backend_real_model_serialization/" data-link-title="有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題" data-link-desc="流程測試的假後端持有 freezed 模型物件、以 toJson 序列化回應，讓服務層走完整的反序列化鏈。對照組是手寫 JSON fixture——同一批測試裡的 raw 寫法重踩了一次產品早已內建處理的分頁包裝，證明「回應形狀的知識」應該只存在一份。">有狀態假後端用真實模型序列化回應</a>。</p>
<h2 id="建置順序">建置順序</h2>
<p>四個限制的處理有先後依賴：</p>
<ol>
<li><strong>spike</strong>：一條最小測試驗證控制器能否在 headless 環境建構並呼叫編排入口</li>
<li><strong>harness 收斂</strong>：spike 過了之後，把 channel mock + no-op 子類 + postFrameCallback 補位收進共用 bootstrap</li>
<li><strong>檔案隔離規劃</strong>：真實後端驗證測試另開檔案、不 import harness、不初始化 binding</li>
<li><strong>雜訊治理</strong>：harness 的 channel mock 順便解決大部分雜訊；剩餘的 headless 環境假警報加前置判斷</li>
<li><strong>假後端序列化</strong>：以 freezed 模型持有狀態、toJson 出口，seed builder 集中維護初始狀態</li>
<li><strong>第一條劇本</strong>：走完一段跨服務業務旅程，斷言散佈在各階段的可觀察結果上</li>
</ol>
<p>步驟 1 決定後續全部形態——立不起來就回到策略層重新評估（<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>的「可測性閘門」段）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>策略層的完整判準（什麼時候值得建假後端）→ <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>配對的真實後端驗證（假後端行為漂移的防線）→ <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>憑證的存放與 CI 注入 → <a href="/blog/testing/03-protocol-integration-test/credential-management/" data-link-title="測試憑證管理" data-link-desc="測試環境的帳號密碼放在哪裡、CI 怎麼拿到、怎麼防止對生產環境執行 — 存放策略的適用前提與失效偵測">測試憑證管理</a></li>
<li>各限制的完整 case：<a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">headless 控制器</a>、<a href="/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">binding 互斥</a>、<a href="/blog/work-log/flutter_test_noise_expected_paths/" data-link-title="測試輸出的雜訊治理：預期的環境狀態不該走例外路徑" data-link-desc="測試輸出長期印著兩行「已知無害」的錯誤——相機偵測 MissingPluginException、toast 套件的 assert fallback。已知雜訊會訓練人忽略輸出，新警報混在裡面就被過濾掉。修法是把「預期的環境狀態」變成前置判斷（channel mock 回空、無畫面早退），讓例外路徑只剩真正的例外。">雜訊治理</a>、<a href="/blog/work-log/flutter_fake_backend_real_model_serialization/" data-link-title="有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題" data-link-desc="流程測試的假後端持有 freezed 模型物件、以 toJson 序列化回應，讓服務層走完整的反序列化鏈。對照組是手寫 JSON fixture——同一批測試裡的 raw 寫法重踩了一次產品早已內建處理的分頁包裝，證明「回應形狀的知識」應該只存在一份。">假後端序列化</a></li>
</ul>
]]></content:encoded></item><item><title>TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離</title><link>https://tarrragon.github.io/blog/work-log/flutter_test_binding_blocks_real_network/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_test_binding_blocks_real_network/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：&lt;code>TestWidgetsFlutterBinding.ensureInitialized()&lt;/code> 的副作用之一是把 &lt;code>HttpClient&lt;/code> 換成一律回 400 的假件（防止 widget test 意外打真實網路）。POS App 要在測試套件裡加一層「對真實後端驗證行為」的測試時，這個副作用成為硬約束。
&lt;strong>案例骨幹&lt;/strong>：真實後端驗證測試與流程測試住在同一個 &lt;code>test/integration/&lt;/code> 目錄；前者不可初始化 binding、不可 import 流程測試的 harness（harness 第一行就 &lt;code>ensureInitialized&lt;/code>）。兩者能共存的原因是 &lt;code>flutter test&lt;/code> 讓每個測試檔案跑在獨立 isolate——binding 的全域副作用以檔案為邊界。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-症狀測試裡所有真實-http-請求回-400">1. 症狀：測試裡所有真實 HTTP 請求回 400&lt;/h2>
&lt;p>對真實後端（QA 環境）發請求的測試，若與一般測試共用同一個 setup，會得到一個誤導性極強的症狀：請求沒有網路錯誤、沒有逾時，而是穩定回 400。看起來像後端拒絕，實際上請求根本沒離開測試程序。&lt;/p>
&lt;p>機制：&lt;code>TestWidgetsFlutterBinding&lt;/code> 初始化時安裝 &lt;code>HttpOverrides&lt;/code>，把 &lt;code>dart:io&lt;/code> 的 &lt;code>HttpClient&lt;/code> 換成假實作——這是 flutter_test 的刻意設計，避免 widget test 因為 &lt;code>Image.network&lt;/code> 之類的呼叫打到真實網路造成不穩定。它保護了大多數測試，也無差別地擋掉了「就是要打真實網路」的那一種。&lt;/p>
&lt;h2 id="2-約束的傳染性不可-import-會初始化-binding-的東西">2. 約束的傳染性：不可 import 會初始化 binding 的東西&lt;/h2>
&lt;p>這個副作用是&lt;strong>程序級全域&lt;/strong>的，且沒有乾淨的關閉開關（&lt;code>HttpOverrides.global = null&lt;/code> 可以事後拆除，但依賴「記得拆」的紀律，且 binding 的其他副作用仍在）。更穩的做法是把它當成檔案級的硬約束：&lt;/p>
&lt;ul>
&lt;li>真實後端驗證測試的檔案&lt;strong>從頭到尾不初始化 binding&lt;/strong>&lt;/li>
&lt;li>因此也&lt;strong>不可 import 流程測試的 harness&lt;/strong>——harness 為了 mock platform channel、立起 UI 控制器，第一步就是 &lt;code>ensureInitialized&lt;/code>；import 進來即使不呼叫，任何一個共用 helper 順手初始化都會中招&lt;/li>
&lt;/ul>
&lt;p>這條約束值得寫在檔案頭部的註解裡，因為違反它的症狀（400）與原因（binding）之間的距離太遠，下一個維護者幾乎不可能自己連起來。&lt;/p>
&lt;h2 id="3-共存機制檔案級-isolate-隔離">3. 共存機制：檔案級 isolate 隔離&lt;/h2>
&lt;p>看似矛盾的需求——同一個目錄裡，流程測試需要 binding（mock channel、UI 控制器）、真實後端測試不能有 binding——不需要任何額外設計就能共存，因為 &lt;code>flutter test&lt;/code> 的執行模型是&lt;strong>每個測試檔案一個獨立 isolate&lt;/strong>：&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>binding&lt;/td>
 &lt;td>&lt;code>ensureInitialized()&lt;/code>&lt;/td>
 &lt;td>不初始化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HttpClient&lt;/td>
 &lt;td>假件（無妨，走假後端 adapter）&lt;/td>
 &lt;td>真實（需要）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隔離邊界&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>推論：&lt;strong>binding 相關的全域副作用，思考單位是「檔案」而不是「目錄」或「套件」&lt;/strong>。同目錄混放兩種測試完全安全；同檔案混放必炸。&lt;/p>
&lt;h2 id="4-真實後端測試的請求層組裝">4. 真實後端測試的請求層組裝&lt;/h2>
&lt;p>不初始化 binding 之後，還要繞開產品 HTTP 層的執行環境依賴。產品的 Dio 由 DI 容器組裝，攔截器依賴登入服務（token）與翻譯資源（語系前綴）——在無 binding、無 DI 的測試檔裡拉不起來。做法是手組最小可用的傳輸層、但&lt;strong>請求與解析仍走產品的 API client 與模型&lt;/strong>：&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="kd">final&lt;/span> &lt;span class="n">dio&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Dio&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">BaseOptions&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="nl">baseUrl:&lt;/span> &lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">baseUrl&lt;/span>&lt;span class="s1">/&lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">languagePrefix&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 語系前綴改由 baseUrl 提供
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">connectTimeout:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">Duration&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">seconds:&lt;/span> &lt;span class="m">5&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&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="kd">final&lt;/span> &lt;span class="n">api&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ApiClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dio&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// retrofit client 只需要一個 Dio
&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">// 登入後手動掛 token，取代攔截器
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">dio&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">options&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;Authorization&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;Bearer &lt;/span>&lt;span class="si">$&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「走產品的 client 與模型」是刻意選擇：手刻 raw JSON 曾在同一批測試裡重踩產品早已解決的問題（列表回應的分頁包裝），而型別化解析讓後端回應形狀的變化在這層先於產品爆出來。&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>測試出現「穩定 400、無網路痕跡」→ 先查是不是 binding 的 HttpClient 假件，再查後端。&lt;/li>
&lt;li>需要真實網路的測試檔：不初始化 binding、不 import 任何會初始化的模組，約束寫進檔頭。&lt;/li>
&lt;li>兩種測試同目錄共存靠檔案級 isolate——組織測試時以檔案為 binding 副作用的邊界單位。&lt;/li>
&lt;li>繞開 DI 不等於繞開產品程式碼：傳輸層手組、解析層共用。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>這層測試的完整設計（預設可執行、離線降級、憑證失效紅燈）→ &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>&lt;/li>
&lt;li>harness 那一側的組裝 → &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：<code>TestWidgetsFlutterBinding.ensureInitialized()</code> 的副作用之一是把 <code>HttpClient</code> 換成一律回 400 的假件（防止 widget test 意外打真實網路）。POS App 要在測試套件裡加一層「對真實後端驗證行為」的測試時，這個副作用成為硬約束。
<strong>案例骨幹</strong>：真實後端驗證測試與流程測試住在同一個 <code>test/integration/</code> 目錄；前者不可初始化 binding、不可 import 流程測試的 harness（harness 第一行就 <code>ensureInitialized</code>）。兩者能共存的原因是 <code>flutter test</code> 讓每個測試檔案跑在獨立 isolate——binding 的全域副作用以檔案為邊界。</p></blockquote>
<hr>
<h2 id="1-症狀測試裡所有真實-http-請求回-400">1. 症狀：測試裡所有真實 HTTP 請求回 400</h2>
<p>對真實後端（QA 環境）發請求的測試，若與一般測試共用同一個 setup，會得到一個誤導性極強的症狀：請求沒有網路錯誤、沒有逾時，而是穩定回 400。看起來像後端拒絕，實際上請求根本沒離開測試程序。</p>
<p>機制：<code>TestWidgetsFlutterBinding</code> 初始化時安裝 <code>HttpOverrides</code>，把 <code>dart:io</code> 的 <code>HttpClient</code> 換成假實作——這是 flutter_test 的刻意設計，避免 widget test 因為 <code>Image.network</code> 之類的呼叫打到真實網路造成不穩定。它保護了大多數測試，也無差別地擋掉了「就是要打真實網路」的那一種。</p>
<h2 id="2-約束的傳染性不可-import-會初始化-binding-的東西">2. 約束的傳染性：不可 import 會初始化 binding 的東西</h2>
<p>這個副作用是<strong>程序級全域</strong>的，且沒有乾淨的關閉開關（<code>HttpOverrides.global = null</code> 可以事後拆除，但依賴「記得拆」的紀律，且 binding 的其他副作用仍在）。更穩的做法是把它當成檔案級的硬約束：</p>
<ul>
<li>真實後端驗證測試的檔案<strong>從頭到尾不初始化 binding</strong></li>
<li>因此也<strong>不可 import 流程測試的 harness</strong>——harness 為了 mock platform channel、立起 UI 控制器，第一步就是 <code>ensureInitialized</code>；import 進來即使不呼叫，任何一個共用 helper 順手初始化都會中招</li>
</ul>
<p>這條約束值得寫在檔案頭部的註解裡，因為違反它的症狀（400）與原因（binding）之間的距離太遠，下一個維護者幾乎不可能自己連起來。</p>
<h2 id="3-共存機制檔案級-isolate-隔離">3. 共存機制：檔案級 isolate 隔離</h2>
<p>看似矛盾的需求——同一個目錄裡，流程測試需要 binding（mock channel、UI 控制器）、真實後端測試不能有 binding——不需要任何額外設計就能共存，因為 <code>flutter test</code> 的執行模型是<strong>每個測試檔案一個獨立 isolate</strong>：</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>流程測試檔</th>
          <th>真實後端驗證檔</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>binding</td>
          <td><code>ensureInitialized()</code></td>
          <td>不初始化</td>
      </tr>
      <tr>
          <td>HttpClient</td>
          <td>假件（無妨，走假後端 adapter）</td>
          <td>真實（需要）</td>
      </tr>
      <tr>
          <td>隔離邊界</td>
          <td>檔案自己的 isolate</td>
          <td>檔案自己的 isolate</td>
      </tr>
  </tbody>
</table>
<p>推論：<strong>binding 相關的全域副作用，思考單位是「檔案」而不是「目錄」或「套件」</strong>。同目錄混放兩種測試完全安全；同檔案混放必炸。</p>
<h2 id="4-真實後端測試的請求層組裝">4. 真實後端測試的請求層組裝</h2>
<p>不初始化 binding 之後，還要繞開產品 HTTP 層的執行環境依賴。產品的 Dio 由 DI 容器組裝，攔截器依賴登入服務（token）與翻譯資源（語系前綴）——在無 binding、無 DI 的測試檔裡拉不起來。做法是手組最小可用的傳輸層、但<strong>請求與解析仍走產品的 API client 與模型</strong>：</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="kd">final</span> <span class="n">dio</span> <span class="o">=</span> <span class="n">Dio</span><span class="p">(</span><span class="n">BaseOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">baseUrl:</span> <span class="s1">&#39;</span><span class="si">$</span><span class="n">baseUrl</span><span class="s1">/</span><span class="si">$</span><span class="n">languagePrefix</span><span class="s1">&#39;</span><span class="p">,</span>   <span class="c1">// 語系前綴改由 baseUrl 提供
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nl">connectTimeout:</span> <span class="kd">const</span> <span class="n">Duration</span><span class="p">(</span><span class="nl">seconds:</span> <span class="m">5</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="kd">final</span> <span class="n">api</span> <span class="o">=</span> <span class="n">ApiClient</span><span class="p">(</span><span class="n">dio</span><span class="p">);</span>              <span class="c1">// retrofit client 只需要一個 Dio
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 登入後手動掛 token，取代攔截器
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">dio</span><span class="p">.</span><span class="n">options</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="s1">&#39;Authorization&#39;</span><span class="p">]</span> <span class="o">=</span> <span class="s1">&#39;Bearer </span><span class="si">$</span><span class="n">token</span><span class="s1">&#39;</span><span class="p">;</span></span></span></code></pre></div><p>「走產品的 client 與模型」是刻意選擇：手刻 raw JSON 曾在同一批測試裡重踩產品早已解決的問題（列表回應的分頁包裝），而型別化解析讓後端回應形狀的變化在這層先於產品爆出來。</p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>測試出現「穩定 400、無網路痕跡」→ 先查是不是 binding 的 HttpClient 假件，再查後端。</li>
<li>需要真實網路的測試檔：不初始化 binding、不 import 任何會初始化的模組，約束寫進檔頭。</li>
<li>兩種測試同目錄共存靠檔案級 isolate——組織測試時以檔案為 binding 副作用的邊界單位。</li>
<li>繞開 DI 不等於繞開產品程式碼：傳輸層手組、解析層共用。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>這層測試的完整設計（預設可執行、離線降級、憑證失效紅燈）→ <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>harness 那一側的組裝 → <a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來</a></li>
</ul>
]]></content:encoded></item></channel></rss>