<?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>Platform-Channel on Tarragon</title><link>https://tarrragon.github.io/blog/tags/platform-channel/</link><description>Recent content in Platform-Channel 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/platform-channel/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>測試輸出的雜訊治理：預期的環境狀態不該走例外路徑</title><link>https://tarrragon.github.io/blog/work-log/flutter_test_noise_expected_paths/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_test_noise_expected_paths/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：測試環境沒有相機、沒有 widget tree——這些是&lt;strong>必然且預期&lt;/strong>的狀態，但程式用「拋例外→catch→印 log」的路徑處理它們，於是每次跑測試都固定印出兩行看起來像壞掉的輸出。人對重複出現的警報會建立心理過濾，等真的有新問題印出相似訊息時，一起被過濾掉。
&lt;strong>案例骨幹&lt;/strong>：POS App 的流程測試輸出固定出現「偵測相機失敗（MissingPluginException）」與「toast 套件不可用（assert 失敗）」。使用者的裁定是：無法使用的就該移除、可疑的要查清是什麼——結果一個在 harness 補 channel mock 讓偵測走正常路徑，一個查證為環境必然後改成前置判斷，例外接管降級為真正非預期的兜底。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-兩行雜訊的解剖">1. 兩行雜訊的解剖&lt;/h2>
&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>&lt;code>偵測相機失敗，視為無相機: MissingPluginException(...)&lt;/code>&lt;/td>
 &lt;td>啟動期呼叫 &lt;code>availableCameras()&lt;/code>，測試環境無 plugin → 拋例外 → 產品的 catch 把它降級成「無相機」並印 log&lt;/td>
 &lt;td>產品行為正確、輸出是雜訊&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>[fallback] toast 套件不可用: Failed assertion: '_key.currentState != null'&lt;/code>&lt;/td>
 &lt;td>顯示提示時 toast 套件斷言 widget tree 上有掛載點——headless 測試沒有畫面 → assert → 被 &lt;code>runZonedGuarded&lt;/code> 兜底接住印 log&lt;/td>
 &lt;td>兜底行為正確、輸出是雜訊&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者共同點：&lt;strong>觸發條件在測試環境是 100% 必然&lt;/strong>。每一次執行、每一條會經過這些路徑的測試，都固定貢獻幾行「錯誤長相」的輸出。&lt;/p>
&lt;h2 id="2-為什麼要治理已知雜訊遮蔽新警報">2. 為什麼要治理：已知雜訊遮蔽新警報&lt;/h2>
&lt;p>單看無害——catch 有接、fallback 有兜、測試全綠。傷害在人這一側：讀輸出的人很快學會「那兩行不用看」，心理過濾建立後，過濾的是&lt;strong>模式&lt;/strong>不是內容——某天一行真正的新錯誤以相似的形狀出現（另一個 MissingPluginException、另一個 assert fallback），會被同一個過濾器吃掉。&lt;/p>
&lt;p>治理原則一句話：&lt;strong>測試輸出裡的每一行錯誤長相的文字，都應該值得停下來看&lt;/strong>。做不到就治理到做得到。&lt;/p>
&lt;h2 id="3-修法一讓偵測走正常路徑harness-補-channel-mock">3. 修法一：讓偵測走正常路徑（harness 補 channel mock）&lt;/h2>
&lt;p>相機那行的問題不是 catch 寫錯——生產環境「枚舉失敗保守視為無相機」是正確防護。問題是測試環境讓它天天走例外路徑。修在 harness：&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="c1">// 測試環境沒有相機 plugin，回空清單讓啟動期的相機偵測
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">// 得到「無相機」的乾淨結果，不走例外路徑留下雜訊 log。
&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="n">TestDefaultBinaryMessengerBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">defaultBinaryMessenger&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 class="n">setMockMethodCallHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">const&lt;/span> &lt;span class="n">MethodChannel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;plugins.flutter.io/camera&amp;#39;&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="p">(&lt;/span>&lt;span class="n">call&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">call&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">method&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s1">&amp;#39;availableCameras&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">[]&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>偵測照常執行、得到空清單、安靜地判定無相機。產品碼一行不動。&lt;/p>
&lt;h2 id="4-修法二預期狀態前置判斷例外接管留給非預期">4. 修法二：預期狀態前置判斷，例外接管留給非預期&lt;/h2>
&lt;p>toast 那行先查證：正式 App 在根 widget 正確掛了套件的初始化 builder——&lt;strong>不是產品 bug&lt;/strong>，assert 純粹是 headless 環境沒有畫面。既然「無畫面」是可以直接判斷的狀態，就不該用「撞 assert → zone 接住」的方式發現它：&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">runZonedGuarded&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="c1">// 無畫面（headless 測試等）時 toast 沒有掛載點；
&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="c1">// 訊息已先記進 log 服務，略過顯示即可
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">WidgetsBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">rootElement&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&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">showToast&lt;/span>&lt;span class="p">(...);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">},&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">error&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">stack&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">7&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 這裡從此只會接到真正非預期的失敗——留著的 log 才是警訊
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>分工因此變乾淨：&lt;strong>前置判斷處理已知的環境狀態，&lt;code>runZonedGuarded&lt;/code> 兜底處理未知的失敗&lt;/strong>。兜底的 log 從「每次測試都響的假警報」變成「響了就該查」的真警報——這才是兜底該有的信噪比。（zone 接管機制本身的分析見 &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_test_async_unhandled_error/" data-link-title="寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計" data-link-desc="測試裡 sync try-catch 接不到錯誤，或 fire-and-forget API 從 async gap 後拋 `LateInitializationError`。用 runZonedGuarded 同時罩 sync 與 async 失敗路徑，含 fallback 訊息 signature 設計。">sync try-catch 接不到 async 錯誤&lt;/a>。）&lt;/p>
&lt;p>前置判斷放在 zone 內而非 zone 外有一個小理由：極端環境（binding 未初始化）連 &lt;code>WidgetsBinding.instance&lt;/code> 都會拋，放 zone 內讓這種罕見情況也落入兜底而不是炸到 caller。&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>測試輸出固定出現的錯誤長相文字 = 治理對象，不因「無害」豁免——它的成本收在未來某次被過濾掉的真警報上。&lt;/li>
&lt;li>分類處理：觸發條件在測試環境&lt;strong>必然&lt;/strong>成立 → 前置判斷或 harness mock 讓它走正常路徑；觸發條件&lt;strong>不確定&lt;/strong> → 保留例外接管，且它的 log 要因稀有而醒目。&lt;/li>
&lt;li>動手前先查證「這是誰的問題」——toast 那行若不查，可能誤修產品的初始化；查證後才知道是環境必然，修法完全不同。&lt;/li>
&lt;li>fallback 的價值與它的觸發頻率成反比：天天觸發的 fallback 是常態路徑的錯位，不是防護。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>zone 與 async 錯誤接管的機制層 → &lt;a href="https://tarrragon.github.io/blog/work-log/flutter_test_async_unhandled_error/" data-link-title="寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計" data-link-desc="測試裡 sync try-catch 接不到錯誤，或 fire-and-forget API 從 async gap 後拋 `LateInitializationError`。用 runZonedGuarded 同時罩 sync 與 async 失敗路徑，含 fallback 訊息 signature 設計。">sync try-catch 接不到 async 錯誤&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>：測試環境沒有相機、沒有 widget tree——這些是<strong>必然且預期</strong>的狀態，但程式用「拋例外→catch→印 log」的路徑處理它們，於是每次跑測試都固定印出兩行看起來像壞掉的輸出。人對重複出現的警報會建立心理過濾，等真的有新問題印出相似訊息時，一起被過濾掉。
<strong>案例骨幹</strong>：POS App 的流程測試輸出固定出現「偵測相機失敗（MissingPluginException）」與「toast 套件不可用（assert 失敗）」。使用者的裁定是：無法使用的就該移除、可疑的要查清是什麼——結果一個在 harness 補 channel mock 讓偵測走正常路徑，一個查證為環境必然後改成前置判斷，例外接管降級為真正非預期的兜底。</p></blockquote>
<hr>
<h2 id="1-兩行雜訊的解剖">1. 兩行雜訊的解剖</h2>
<table>
  <thead>
      <tr>
          <th>輸出</th>
          <th>來源</th>
          <th>性質</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>偵測相機失敗，視為無相機: MissingPluginException(...)</code></td>
          <td>啟動期呼叫 <code>availableCameras()</code>，測試環境無 plugin → 拋例外 → 產品的 catch 把它降級成「無相機」並印 log</td>
          <td>產品行為正確、輸出是雜訊</td>
      </tr>
      <tr>
          <td><code>[fallback] toast 套件不可用: Failed assertion: '_key.currentState != null'</code></td>
          <td>顯示提示時 toast 套件斷言 widget tree 上有掛載點——headless 測試沒有畫面 → assert → 被 <code>runZonedGuarded</code> 兜底接住印 log</td>
          <td>兜底行為正確、輸出是雜訊</td>
      </tr>
  </tbody>
</table>
<p>兩者共同點：<strong>觸發條件在測試環境是 100% 必然</strong>。每一次執行、每一條會經過這些路徑的測試，都固定貢獻幾行「錯誤長相」的輸出。</p>
<h2 id="2-為什麼要治理已知雜訊遮蔽新警報">2. 為什麼要治理：已知雜訊遮蔽新警報</h2>
<p>單看無害——catch 有接、fallback 有兜、測試全綠。傷害在人這一側：讀輸出的人很快學會「那兩行不用看」，心理過濾建立後，過濾的是<strong>模式</strong>不是內容——某天一行真正的新錯誤以相似的形狀出現（另一個 MissingPluginException、另一個 assert fallback），會被同一個過濾器吃掉。</p>
<p>治理原則一句話：<strong>測試輸出裡的每一行錯誤長相的文字，都應該值得停下來看</strong>。做不到就治理到做得到。</p>
<h2 id="3-修法一讓偵測走正常路徑harness-補-channel-mock">3. 修法一：讓偵測走正常路徑（harness 補 channel mock）</h2>
<p>相機那行的問題不是 catch 寫錯——生產環境「枚舉失敗保守視為無相機」是正確防護。問題是測試環境讓它天天走例外路徑。修在 harness：</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">// 測試環境沒有相機 plugin，回空清單讓啟動期的相機偵測
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 得到「無相機」的乾淨結果，不走例外路徑留下雜訊 log。
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="n">TestDefaultBinaryMessengerBinding</span><span class="p">.</span><span class="n">instance</span><span class="p">.</span><span class="n">defaultBinaryMessenger</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">.</span><span class="n">setMockMethodCallHandler</span><span class="p">(</span><span class="kd">const</span> <span class="n">MethodChannel</span><span class="p">(</span><span class="s1">&#39;plugins.flutter.io/camera&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="p">(</span><span class="n">call</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">call</span><span class="p">.</span><span class="n">method</span> <span class="o">==</span> <span class="s1">&#39;availableCameras&#39;</span> <span class="o">?</span> <span class="o">&lt;</span><span class="kt">dynamic</span><span class="o">&gt;</span><span class="p">[]</span> <span class="o">:</span> <span class="kc">null</span><span class="p">);</span></span></span></code></pre></div><p>偵測照常執行、得到空清單、安靜地判定無相機。產品碼一行不動。</p>
<h2 id="4-修法二預期狀態前置判斷例外接管留給非預期">4. 修法二：預期狀態前置判斷，例外接管留給非預期</h2>
<p>toast 那行先查證：正式 App 在根 widget 正確掛了套件的初始化 builder——<strong>不是產品 bug</strong>，assert 純粹是 headless 環境沒有畫面。既然「無畫面」是可以直接判斷的狀態，就不該用「撞 assert → zone 接住」的方式發現它：</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">runZonedGuarded</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">// 無畫面（headless 測試等）時 toast 沒有掛載點；
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// 訊息已先記進 log 服務，略過顯示即可
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="k">if</span> <span class="p">(</span><span class="n">WidgetsBinding</span><span class="p">.</span><span class="n">instance</span><span class="p">.</span><span class="n">rootElement</span> <span class="o">==</span> <span class="kc">null</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="n">showToast</span><span class="p">(...);</span>
</span></span><span class="line"><span class="ln">6</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">7</span><span class="cl">  <span class="c1">// 這裡從此只會接到真正非預期的失敗——留著的 log 才是警訊
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>分工因此變乾淨：<strong>前置判斷處理已知的環境狀態，<code>runZonedGuarded</code> 兜底處理未知的失敗</strong>。兜底的 log 從「每次測試都響的假警報」變成「響了就該查」的真警報——這才是兜底該有的信噪比。（zone 接管機制本身的分析見 <a href="/blog/work-log/flutter_test_async_unhandled_error/" data-link-title="寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計" data-link-desc="測試裡 sync try-catch 接不到錯誤，或 fire-and-forget API 從 async gap 後拋 `LateInitializationError`。用 runZonedGuarded 同時罩 sync 與 async 失敗路徑，含 fallback 訊息 signature 設計。">sync try-catch 接不到 async 錯誤</a>。）</p>
<p>前置判斷放在 zone 內而非 zone 外有一個小理由：極端環境（binding 未初始化）連 <code>WidgetsBinding.instance</code> 都會拋，放 zone 內讓這種罕見情況也落入兜底而不是炸到 caller。</p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>測試輸出固定出現的錯誤長相文字 = 治理對象，不因「無害」豁免——它的成本收在未來某次被過濾掉的真警報上。</li>
<li>分類處理：觸發條件在測試環境<strong>必然</strong>成立 → 前置判斷或 harness mock 讓它走正常路徑；觸發條件<strong>不確定</strong> → 保留例外接管，且它的 log 要因稀有而醒目。</li>
<li>動手前先查證「這是誰的問題」——toast 那行若不查，可能誤修產品的初始化；查證後才知道是環境必然，修法完全不同。</li>
<li>fallback 的價值與它的觸發頻率成反比：天天觸發的 fallback 是常態路徑的錯位，不是防護。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>zone 與 async 錯誤接管的機制層 → <a href="/blog/work-log/flutter_test_async_unhandled_error/" data-link-title="寫測試時 sync try-catch 接不到 BotToast 的 async 錯誤：fire-and-forget API 的接管設計" data-link-desc="測試裡 sync try-catch 接不到錯誤，或 fire-and-forget API 從 async gap 後拋 `LateInitializationError`。用 runZonedGuarded 同時罩 sync 與 async 失敗路徑，含 fallback 訊息 signature 設計。">sync try-catch 接不到 async 錯誤</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><item><title>讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位</title><link>https://tarrragon.github.io/blog/work-log/flutter_headless_controller_test_bootstrap/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_headless_controller_test_bootstrap/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：跨服務的&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>若只在 service 層打轉，控制器裡的編排（順序、防護動作、收尾同步）就是測不到的盲區——而實戰裡的 bug 恰好密集出現在編排層。能否直接 &lt;code>Get.put(控制器)&lt;/code> 立起真實控制器，是套件形態的閘門問題，值得用一條最小測試先做 spike。
&lt;strong>案例骨幹&lt;/strong>：POS App 的主控制器在 &lt;code>onInit&lt;/code> 做四件與平台耦合的事（掃碼廣播、相機偵測、外接顯示器、frame callback）。逐一拆解後發現全部可以在 headless 環境用低成本手段中和——spike 首跑即過，此後所有流程測試都能驅動真實編排、零複製漂移。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-為什麼非立起控制器不可">1. 為什麼非立起控制器不可&lt;/h2>
&lt;p>替代方案是「測試裡複製編排步驟」：service 層一步步照著控制器的順序呼叫。它能跑，但有結構性缺陷——控制器改了順序、測試不會知道，兩邊靠註解互指維持同步。這個漂移風險不是理論：同一個專案裡，一個「刷新要在列表同步之後」的順序約束，正是靠流程測試驅動真實編排才抓到的（複製版測試當時是綠的）。&lt;/p>
&lt;p>所以開工前先回答閘門問題：&lt;strong>控制器立不立得起來？&lt;/strong> 答案決定套件形態，用一條最小測試 spike，不要寫到一半才發現。&lt;/p>
&lt;h2 id="2-四個啟動期障礙與各自的中和手段">2. 四個啟動期障礙與各自的中和手段&lt;/h2>
&lt;p>控制器 &lt;code>onInit&lt;/code> 的每一項平台耦合，對應一個測試側的責任：&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>訂閱硬體掃碼廣播（Android channel）&lt;/td>
 &lt;td>&lt;code>start()&lt;/code> 打 platform channel → 未 await 的 Future 拋 &lt;code>MissingPluginException&lt;/code> → 測試 zone 判定失敗&lt;/td>
 &lt;td>服務&lt;strong>子類覆寫&lt;/strong> &lt;code>start&lt;/code>/&lt;code>stop&lt;/code>/&lt;code>messages&lt;/code> 為 no-op 與空 stream，註冊子類取代原服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相機偵測（&lt;code>availableCameras()&lt;/code>）&lt;/td>
 &lt;td>拋 &lt;code>MissingPluginException&lt;/code>（有 catch，留雜訊 log）&lt;/td>
 &lt;td>&lt;code>MethodChannel&lt;/code> 掛 mock 回空清單，讓偵測走正常路徑得到「無相機」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外接顯示器 plugin&lt;/td>
 &lt;td>&lt;strong>建構子&lt;/strong>就訂閱 EventChannel——物件一 new 就打 channel&lt;/td>
 &lt;td>建立前先為該 EventChannel 掛 mock stream handler&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>addPostFrameCallback&lt;/code> 裡補訂閱&lt;/td>
 &lt;td>純 &lt;code>test()&lt;/code> 不跑 frame，callback 永不觸發&lt;/td>
 &lt;td>測試裡手動呼叫同一個訂閱方法，註解說明對應關係&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>channel mock 的兩個 API 各對一種 channel：&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="c1">// EventChannel：plugin 建構子就會 listen，必須在建立前掛好
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">TestDefaultBinaryMessengerBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">defaultBinaryMessenger&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="n">setMockStreamHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">const&lt;/span> &lt;span class="n">EventChannel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;channel-name&amp;#39;&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="n">MockStreamHandler&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">inline&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">onListen:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">events&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>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">// MethodChannel：讓查詢類呼叫得到正常的空結果，而非例外
&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">TestDefaultBinaryMessengerBinding&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">instance&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">defaultBinaryMessenger&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 class="n">setMockMethodCallHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">const&lt;/span> &lt;span class="n">MethodChannel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;channel-name&amp;#39;&lt;/span>&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 class="p">(&lt;/span>&lt;span class="n">call&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">call&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">method&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s1">&amp;#39;query&amp;#39;&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">dynamic&lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="p">[]&lt;/span> &lt;span class="o">:&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「建構子就訂閱 EventChannel」是其中最陰的一個——mock 掛晚了炸在 &lt;code>new&lt;/code> 的當下，錯誤點在別人的 package 深處。判準：&lt;strong>引入外接硬體類 plugin 時，先看它的建構子做了什麼&lt;/strong>。&lt;/p>
&lt;h2 id="3-no-op-子類-vs-mock-框架">3. no-op 子類 vs mock 框架&lt;/h2>
&lt;p>掃碼廣播服務的替換用的是手寫子類而不是 mock 框架：&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">class&lt;/span> &lt;span class="nc">SilentBroadcastService&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">BroadcastService&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="err">@&lt;/span>&lt;span class="n">override&lt;/span> &lt;span class="n">Stream&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Message&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="kd">get&lt;/span> &lt;span class="n">messages&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">Stream&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">empty&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 class="err">@&lt;/span>&lt;span class="n">override&lt;/span> &lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">start&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&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="err">@&lt;/span>&lt;span class="n">override&lt;/span> &lt;span class="n">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="kt">void&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">stop&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kd">async&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>理由：要中和的只有「碰平台」的三個成員，其餘行為保留真實；子類的差異一目瞭然，mock 框架在這裡只會增加閱讀成本。這與「服務鏈盡量走真實實作」的流程測試精神一致——假的東西越少，測試的證言越可信。&lt;/p>
&lt;h2 id="4-立起來之後非同步收尾的等待紀律">4. 立起來之後：非同步收尾的等待紀律&lt;/h2>
&lt;p>控制器編排常見 fire-and-forget（收尾動作不 await 就返回）。測試斷言若緊跟在呼叫後面，就是在跟背景收尾賽跑——單獨跑碰巧贏、整批跑排程不同就輸。等待紀律：&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">await&lt;/span> &lt;span class="n">controller&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">checkout&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="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">var&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="m">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="o">++&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="err">終態條件到達&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">break&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">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">pumpEventQueue&lt;/span>&lt;span class="p">();&lt;/span> &lt;span class="c1">// 沖刷 microtask 與已到期 timer
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>輪詢「可觀察的終態」而非固定 sleep——固定延遲只是把起跑線挪後，在更慢的環境照樣輸。通用層的分析見 &lt;a href="https://tarrragon.github.io/blog/testing/cases/fire-and-forget-test-race/" data-link-title="T.C8 fire-and-forget 編排讓測試單跑綠、合跑紅" data-link-desc="結帳成功後的收尾動作（列印、狀態清理、資料同步）沒有被 await — 測試斷言與收尾動作賽跑，單獨執行時碰巧贏、整批執行時排程不同就輸；競態本身也揭露了產品的時序特性">T.C8 fire-and-forget 編排的測試競態&lt;/a>。&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>流程測試開工前，先 spike「編排的宿主立不立得起來」——答案改變整個套件的寫法。&lt;/li>
&lt;li>控制器啟動期的每一項平台耦合 = 測試 harness 的一項責任；逐項列表處理，不要碰到一個修一個。&lt;/li>
&lt;li>EventChannel 的 mock 要在「任何會建構該 plugin 物件的程式」之前掛好。&lt;/li>
&lt;li>postFrameCallback 承載的初始化，在純 test 環境要手動補跑，並在兩處註明對應。&lt;/li>
&lt;li>harness 一旦成立就收斂為共用 bootstrap——之後每條流程測試的成本只剩劇本本身。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>harness 與真實網路測試的互斥約束 → &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;/li>
&lt;li>這個形態的完整方法論 → &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;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：跨服務的<a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>若只在 service 層打轉，控制器裡的編排（順序、防護動作、收尾同步）就是測不到的盲區——而實戰裡的 bug 恰好密集出現在編排層。能否直接 <code>Get.put(控制器)</code> 立起真實控制器，是套件形態的閘門問題，值得用一條最小測試先做 spike。
<strong>案例骨幹</strong>：POS App 的主控制器在 <code>onInit</code> 做四件與平台耦合的事（掃碼廣播、相機偵測、外接顯示器、frame callback）。逐一拆解後發現全部可以在 headless 環境用低成本手段中和——spike 首跑即過，此後所有流程測試都能驅動真實編排、零複製漂移。</p></blockquote>
<hr>
<h2 id="1-為什麼非立起控制器不可">1. 為什麼非立起控制器不可</h2>
<p>替代方案是「測試裡複製編排步驟」：service 層一步步照著控制器的順序呼叫。它能跑，但有結構性缺陷——控制器改了順序、測試不會知道，兩邊靠註解互指維持同步。這個漂移風險不是理論：同一個專案裡，一個「刷新要在列表同步之後」的順序約束，正是靠流程測試驅動真實編排才抓到的（複製版測試當時是綠的）。</p>
<p>所以開工前先回答閘門問題：<strong>控制器立不立得起來？</strong> 答案決定套件形態，用一條最小測試 spike，不要寫到一半才發現。</p>
<h2 id="2-四個啟動期障礙與各自的中和手段">2. 四個啟動期障礙與各自的中和手段</h2>
<p>控制器 <code>onInit</code> 的每一項平台耦合，對應一個測試側的責任：</p>
<table>
  <thead>
      <tr>
          <th>啟動期動作</th>
          <th>在測試環境的行為</th>
          <th>中和手段</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>訂閱硬體掃碼廣播（Android channel）</td>
          <td><code>start()</code> 打 platform channel → 未 await 的 Future 拋 <code>MissingPluginException</code> → 測試 zone 判定失敗</td>
          <td>服務<strong>子類覆寫</strong> <code>start</code>/<code>stop</code>/<code>messages</code> 為 no-op 與空 stream，註冊子類取代原服務</td>
      </tr>
      <tr>
          <td>相機偵測（<code>availableCameras()</code>）</td>
          <td>拋 <code>MissingPluginException</code>（有 catch，留雜訊 log）</td>
          <td><code>MethodChannel</code> 掛 mock 回空清單，讓偵測走正常路徑得到「無相機」</td>
      </tr>
      <tr>
          <td>外接顯示器 plugin</td>
          <td><strong>建構子</strong>就訂閱 EventChannel——物件一 new 就打 channel</td>
          <td>建立前先為該 EventChannel 掛 mock stream handler</td>
      </tr>
      <tr>
          <td><code>addPostFrameCallback</code> 裡補訂閱</td>
          <td>純 <code>test()</code> 不跑 frame，callback 永不觸發</td>
          <td>測試裡手動呼叫同一個訂閱方法，註解說明對應關係</td>
      </tr>
  </tbody>
</table>
<p>channel mock 的兩個 API 各對一種 channel：</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">// EventChannel：plugin 建構子就會 listen，必須在建立前掛好
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">TestDefaultBinaryMessengerBinding</span><span class="p">.</span><span class="n">instance</span><span class="p">.</span><span class="n">defaultBinaryMessenger</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="p">.</span><span class="n">setMockStreamHandler</span><span class="p">(</span><span class="kd">const</span> <span class="n">EventChannel</span><span class="p">(</span><span class="s1">&#39;channel-name&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="n">MockStreamHandler</span><span class="p">.</span><span class="n">inline</span><span class="p">(</span><span class="nl">onListen:</span> <span class="p">(</span><span class="n">args</span><span class="p">,</span> <span class="n">events</span><span class="p">)</span> <span class="p">{}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// MethodChannel：讓查詢類呼叫得到正常的空結果，而非例外
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">TestDefaultBinaryMessengerBinding</span><span class="p">.</span><span class="n">instance</span><span class="p">.</span><span class="n">defaultBinaryMessenger</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">.</span><span class="n">setMockMethodCallHandler</span><span class="p">(</span><span class="kd">const</span> <span class="n">MethodChannel</span><span class="p">(</span><span class="s1">&#39;channel-name&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">        <span class="p">(</span><span class="n">call</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">call</span><span class="p">.</span><span class="n">method</span> <span class="o">==</span> <span class="s1">&#39;query&#39;</span> <span class="o">?</span> <span class="o">&lt;</span><span class="kt">dynamic</span><span class="o">&gt;</span><span class="p">[]</span> <span class="o">:</span> <span class="kc">null</span><span class="p">);</span></span></span></code></pre></div><p>「建構子就訂閱 EventChannel」是其中最陰的一個——mock 掛晚了炸在 <code>new</code> 的當下，錯誤點在別人的 package 深處。判準：<strong>引入外接硬體類 plugin 時，先看它的建構子做了什麼</strong>。</p>
<h2 id="3-no-op-子類-vs-mock-框架">3. no-op 子類 vs mock 框架</h2>
<p>掃碼廣播服務的替換用的是手寫子類而不是 mock 框架：</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">class</span> <span class="nc">SilentBroadcastService</span> <span class="kd">extends</span> <span class="n">BroadcastService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="n">Stream</span><span class="o">&lt;</span><span class="n">Message</span><span class="o">&gt;</span> <span class="kd">get</span> <span class="n">messages</span> <span class="o">=&gt;</span> <span class="kd">const</span> <span class="n">Stream</span><span class="p">.</span><span class="n">empty</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">start</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="err">@</span><span class="n">override</span> <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">stop</span><span class="p">()</span> <span class="kd">async</span> <span class="p">{}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>理由：要中和的只有「碰平台」的三個成員，其餘行為保留真實；子類的差異一目瞭然，mock 框架在這裡只會增加閱讀成本。這與「服務鏈盡量走真實實作」的流程測試精神一致——假的東西越少，測試的證言越可信。</p>
<h2 id="4-立起來之後非同步收尾的等待紀律">4. 立起來之後：非同步收尾的等待紀律</h2>
<p>控制器編排常見 fire-and-forget（收尾動作不 await 就返回）。測試斷言若緊跟在呼叫後面，就是在跟背景收尾賽跑——單獨跑碰巧贏、整批跑排程不同就輸。等待紀律：</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">await</span> <span class="n">controller</span><span class="p">.</span><span class="n">checkout</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kd">var</span> <span class="n">i</span> <span class="o">=</span> <span class="m">0</span><span class="p">;</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="m">100</span><span class="p">;</span> <span class="n">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="err">終態條件到達</span><span class="p">)</span> <span class="k">break</span><span class="p">;</span>      <span class="c1">// 狀態已清除、計數已到位
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">pumpEventQueue</span><span class="p">();</span>      <span class="c1">// 沖刷 microtask 與已到期 timer
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>輪詢「可觀察的終態」而非固定 sleep——固定延遲只是把起跑線挪後，在更慢的環境照樣輸。通用層的分析見 <a href="/blog/testing/cases/fire-and-forget-test-race/" data-link-title="T.C8 fire-and-forget 編排讓測試單跑綠、合跑紅" data-link-desc="結帳成功後的收尾動作（列印、狀態清理、資料同步）沒有被 await — 測試斷言與收尾動作賽跑，單獨執行時碰巧贏、整批執行時排程不同就輸；競態本身也揭露了產品的時序特性">T.C8 fire-and-forget 編排的測試競態</a>。</p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>流程測試開工前，先 spike「編排的宿主立不立得起來」——答案改變整個套件的寫法。</li>
<li>控制器啟動期的每一項平台耦合 = 測試 harness 的一項責任；逐項列表處理，不要碰到一個修一個。</li>
<li>EventChannel 的 mock 要在「任何會建構該 plugin 物件的程式」之前掛好。</li>
<li>postFrameCallback 承載的初始化，在純 test 環境要手動補跑，並在兩處註明對應。</li>
<li>harness 一旦成立就收斂為共用 bootstrap——之後每條流程測試的成本只剩劇本本身。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>harness 與真實網路測試的互斥約束 → <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></li>
<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>
</ul>
]]></content:encoded></item></channel></rss>