<?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>Controller on Tarragon</title><link>https://tarrragon.github.io/blog/tags/controller/</link><description>Recent content in Controller 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/controller/index.xml" rel="self" type="application/rss+xml"/><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>