<?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>模組一：測試策略分層 on Tarragon</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/</link><description>Recent content in 模組一：測試策略分層 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/index.xml" rel="self" type="application/rss+xml"/><item><title>三層定義與職責表</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/three-layer-definition/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/three-layer-definition/</guid><description>&lt;p>測試分層的目的是讓每一層只負責一類問題，使得「哪種 bug 該被哪層抓到」有明確歸屬。三層之間存在語意斷層，單靠一層無論寫多少 test 都無法跨越另一層的職責。&lt;/p>
&lt;h2 id="三層的職責邊界">三層的職責邊界&lt;/h2>
&lt;h3 id="unit-test驗證程式碼邏輯">Unit Test：驗證程式碼邏輯&lt;/h3>
&lt;p>Unit test 驗證的對象是「開發者寫的程式碼是否按預期運作」。它的輸入和輸出都在程式碼控制範圍內 — 函式的參數、回傳值、狀態變化、例外拋出。&lt;/p>
&lt;p>Unit test 的盲區是所有程式碼以外的東西。外部服務的協議行為、網路傳輸的編碼方式、作業系統的檔案鎖定機制 — 這些不在 unit test 的驗證範圍內，因為 unit test 用 mock 取代了這些外部依賴。Mock 忠實模擬的是程式語言層面的 API 契約（方法簽名、參數型別、回傳值），不是外部服務的協議行為。&lt;/p>
&lt;p>app_tunnel 的 192 個 unit test 全部通過，但實機連線後鍵盤輸入無回應。原因是 WebSocket 的 text frame 與 binary frame 差異屬於協議層語意 — &lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>sink.add(dynamic)&lt;/code> 接受任何型別，不區分 frame type（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）。192 個 test 驗證的是「Dart 程式碼邏輯正確」，沒有任何一個 test 的職責是驗證「ttyd 收到的 frame type 是否正確」。&lt;/p>
&lt;h3 id="protocol-integration-test驗證真實協議互動">Protocol Integration Test：驗證真實協議互動&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">Protocol integration test&lt;/a> 驗證的對象是「程式碼和真實外部服務之間的協議互動是否正確」。它不用 mock，而是對真實的服務實例發送請求，觀察真實的回應。&lt;/p>
&lt;p>這一層的驗證目標包括：連線握手是否完成、認證流程是否正確、資料編碼是否符合對方期望、逾時行為是否合理。這些問題的答案不在程式碼裡，而是在程式碼與外部服務的互動過程中。&lt;/p>
&lt;p>app_tunnel 的 auth handshake 缺失就是典型案例。ttyd 要求連線後發送 auth token JSON frame，但 &lt;code>ConnectionManager&lt;/code> 沒有實作這個步驟 — &lt;code>FakeWebSocketChannel.ready&lt;/code> 立即完成不需認證，所有 test 看到的都是連線成功（&lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2&lt;/a>）。對真實 ttyd 執行一個「連線後不發 auth token，斷言 timeout」的 test，就能暴露這個缺失。&lt;/p>
&lt;h3 id="screen-state-test驗證畫面狀態完整性">Screen State Test：驗證畫面狀態完整性&lt;/h3>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/screen-state-test/" data-link-title="Screen State Test" data-link-desc="驗證使用者可見的畫面狀態覆蓋度和狀態間轉換完整性的 test 層級">Screen state test&lt;/a> 驗證的對象是「使用者可見的畫面狀態是否覆蓋所有情境」。它的關注點是畫面層級的狀態機 — loading、connected、error、reconnecting 等狀態之間的轉換是否完整，每個狀態下使用者看到什麼、能操作什麼。&lt;/p>
&lt;p>Screen state test 和 unit test 的區別在於斷言對象：unit test 斷言「函式回傳值是否正確」，screen state test 斷言「使用者看到的畫面是否正確」。同一段程式碼邏輯可能 unit test 通過（回傳值正確）但 screen state test 失敗（畫面沒顯示對應狀態），因為 UI 層的 binding 有問題。&lt;/p>
&lt;h2 id="三層對照">三層對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Unit Test&lt;/th>
 &lt;th>Protocol Integration Test&lt;/th>
 &lt;th>Screen State Test&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>驗證對象&lt;/td>
 &lt;td>程式碼邏輯&lt;/td>
 &lt;td>程式碼與真實服務的協議互動&lt;/td>
 &lt;td>使用者可見的畫面狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>外部依賴&lt;/td>
 &lt;td>全部 mock&lt;/td>
 &lt;td>對真實服務實例&lt;/td>
 &lt;td>視實作而定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>斷言標的&lt;/td>
 &lt;td>回傳值、狀態變化、例外拋出&lt;/td>
 &lt;td>連線結果、回應內容、逾時行為&lt;/td>
 &lt;td>畫面元素、狀態轉換、可操作性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>能抓到&lt;/td>
 &lt;td>邏輯錯誤、邊界條件、狀態機&lt;/td>
 &lt;td>協議不相容、認證缺失、編碼錯誤&lt;/td>
 &lt;td>狀態遺漏、轉換缺失、顯示錯誤&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>抓不到&lt;/td>
 &lt;td>協議層行為、環境差異&lt;/td>
 &lt;td>UI 層 binding、畫面狀態完整性&lt;/td>
 &lt;td>內部邏輯錯誤、效能問題&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="數量與覆蓋率的關係">數量與覆蓋率的關係&lt;/h2>
&lt;p>測試數量和測試覆蓋率是兩個獨立的維度。192 個 unit test 提供的是 unit test 層的覆蓋率 — 程式碼邏輯的分支覆蓋。把 unit test 從 192 個加到 500 個，增加的仍然是同一層的覆蓋率，不會跨越到協議層或畫面層。&lt;/p></description><content:encoded><![CDATA[<p>測試分層的目的是讓每一層只負責一類問題，使得「哪種 bug 該被哪層抓到」有明確歸屬。三層之間存在語意斷層，單靠一層無論寫多少 test 都無法跨越另一層的職責。</p>
<h2 id="三層的職責邊界">三層的職責邊界</h2>
<h3 id="unit-test驗證程式碼邏輯">Unit Test：驗證程式碼邏輯</h3>
<p>Unit test 驗證的對象是「開發者寫的程式碼是否按預期運作」。它的輸入和輸出都在程式碼控制範圍內 — 函式的參數、回傳值、狀態變化、例外拋出。</p>
<p>Unit test 的盲區是所有程式碼以外的東西。外部服務的協議行為、網路傳輸的編碼方式、作業系統的檔案鎖定機制 — 這些不在 unit test 的驗證範圍內，因為 unit test 用 mock 取代了這些外部依賴。Mock 忠實模擬的是程式語言層面的 API 契約（方法簽名、參數型別、回傳值），不是外部服務的協議行為。</p>
<p>app_tunnel 的 192 個 unit test 全部通過，但實機連線後鍵盤輸入無回應。原因是 WebSocket 的 text frame 與 binary frame 差異屬於協議層語意 — <code>FakeWebSocketChannel</code> 的 <code>sink.add(dynamic)</code> 接受任何型別，不區分 frame type（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）。192 個 test 驗證的是「Dart 程式碼邏輯正確」，沒有任何一個 test 的職責是驗證「ttyd 收到的 frame type 是否正確」。</p>
<h3 id="protocol-integration-test驗證真實協議互動">Protocol Integration Test：驗證真實協議互動</h3>
<p><a href="/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">Protocol integration test</a> 驗證的對象是「程式碼和真實外部服務之間的協議互動是否正確」。它不用 mock，而是對真實的服務實例發送請求，觀察真實的回應。</p>
<p>這一層的驗證目標包括：連線握手是否完成、認證流程是否正確、資料編碼是否符合對方期望、逾時行為是否合理。這些問題的答案不在程式碼裡，而是在程式碼與外部服務的互動過程中。</p>
<p>app_tunnel 的 auth handshake 缺失就是典型案例。ttyd 要求連線後發送 auth token JSON frame，但 <code>ConnectionManager</code> 沒有實作這個步驟 — <code>FakeWebSocketChannel.ready</code> 立即完成不需認證，所有 test 看到的都是連線成功（<a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a>）。對真實 ttyd 執行一個「連線後不發 auth token，斷言 timeout」的 test，就能暴露這個缺失。</p>
<h3 id="screen-state-test驗證畫面狀態完整性">Screen State Test：驗證畫面狀態完整性</h3>
<p><a href="/blog/testing/knowledge-cards/screen-state-test/" data-link-title="Screen State Test" data-link-desc="驗證使用者可見的畫面狀態覆蓋度和狀態間轉換完整性的 test 層級">Screen state test</a> 驗證的對象是「使用者可見的畫面狀態是否覆蓋所有情境」。它的關注點是畫面層級的狀態機 — loading、connected、error、reconnecting 等狀態之間的轉換是否完整，每個狀態下使用者看到什麼、能操作什麼。</p>
<p>Screen state test 和 unit test 的區別在於斷言對象：unit test 斷言「函式回傳值是否正確」，screen state test 斷言「使用者看到的畫面是否正確」。同一段程式碼邏輯可能 unit test 通過（回傳值正確）但 screen state test 失敗（畫面沒顯示對應狀態），因為 UI 層的 binding 有問題。</p>
<h2 id="三層對照">三層對照</h2>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Unit Test</th>
          <th>Protocol Integration Test</th>
          <th>Screen State Test</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>驗證對象</td>
          <td>程式碼邏輯</td>
          <td>程式碼與真實服務的協議互動</td>
          <td>使用者可見的畫面狀態</td>
      </tr>
      <tr>
          <td>外部依賴</td>
          <td>全部 mock</td>
          <td>對真實服務實例</td>
          <td>視實作而定</td>
      </tr>
      <tr>
          <td>斷言標的</td>
          <td>回傳值、狀態變化、例外拋出</td>
          <td>連線結果、回應內容、逾時行為</td>
          <td>畫面元素、狀態轉換、可操作性</td>
      </tr>
      <tr>
          <td>能抓到</td>
          <td>邏輯錯誤、邊界條件、狀態機</td>
          <td>協議不相容、認證缺失、編碼錯誤</td>
          <td>狀態遺漏、轉換缺失、顯示錯誤</td>
      </tr>
      <tr>
          <td>抓不到</td>
          <td>協議層行為、環境差異</td>
          <td>UI 層 binding、畫面狀態完整性</td>
          <td>內部邏輯錯誤、效能問題</td>
      </tr>
  </tbody>
</table>
<h2 id="數量與覆蓋率的關係">數量與覆蓋率的關係</h2>
<p>測試數量和測試覆蓋率是兩個獨立的維度。192 個 unit test 提供的是 unit test 層的覆蓋率 — 程式碼邏輯的分支覆蓋。把 unit test 從 192 個加到 500 個，增加的仍然是同一層的覆蓋率，不會跨越到協議層或畫面層。</p>
<p>層級缺失的問題無法用數量解決。如果整個 test suite 只有 unit test，即使覆蓋率 100%，protocol integration test 層和 screen state test 層的覆蓋率仍然是 0%。app_tunnel 的經驗是：在 unit test 層加更多 test 不會讓 frame type 問題浮現，因為 <code>FakeWebSocketChannel</code> 的行為在每一個 test 中都是一致的 — 一致地遮蔽了協議層差異。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Mock 如何在 API 層和協議層之間製造盲區 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>如何辨認「名義 integration test」 → <a href="/blog/testing/01-test-strategy-layers/nominal-integration-test/" data-link-title="「名義 integration test」的識別與修正" data-link-desc="test 名稱含 integration 但核心依賴全用 fake — 如何辨認、為什麼有害、怎麼修正命名和測試策略">名義 integration test 的識別與修正</a></li>
<li>判斷自己的服務是否需要 protocol integration test → <a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a></li>
<li>三層測試如何對應畫面狀態矩陣 → <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一：畫面狀態機</a></li>
</ul>
]]></content:encoded></item><item><title>Mock 遮蔽機制分析</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">Mock 遮蔽&lt;/a>是 mock 的設計邊界。「遮蔽」描述的是機制 — mock 讓協議層差異變得不可見；「盲區」描述的是結果 — 被遮蔽的範圍形成結構性的驗證缺口。Mock 的職責是模擬程式語言層面的 API 契約 — 方法簽名、參數型別、回傳值結構。協議層行為（frame type、handshake 步驟、編碼格式）不在 API 契約的描述範圍內，mock 沒有模擬這些行為的義務，也不應該被期待模擬。&lt;/p>
&lt;h2 id="三層語意與斷裂點">三層語意與斷裂點&lt;/h2>
&lt;p>程式碼和外部服務之間的互動經過三層語意轉換，每一層描述不同粒度的行為。Mock 模擬的是最上層，真實行為發生在下面兩層。&lt;/p>
&lt;h3 id="api-層程式語言的方法簽名">API 層：程式語言的方法簽名&lt;/h3>
&lt;p>API 層描述的是「這個方法接受什麼參數、回傳什麼型別」。Dart 的 &lt;code>WebSocketSink.add&lt;/code> 簽名是 &lt;code>void add(dynamic event)&lt;/code> — 從 API 層看，傳 &lt;code>String&lt;/code> 和傳 &lt;code>Uint8List&lt;/code> 都合法，都不會拋出例外。&lt;/p>
&lt;p>&lt;code>FakeWebSocketChannel&lt;/code> 忠實實作了這個 API 契約。&lt;code>sink.add(&amp;quot;hello&amp;quot;)&lt;/code> 和 &lt;code>sink.add(Uint8List.fromList([104, 101, 108, 108, 111]))&lt;/code> 在 fake 的行為完全相同 — 資料進入內部 buffer，test 可以從 buffer 讀取驗證。Mock 的行為在 API 層是正確的。&lt;/p>
&lt;h3 id="協議層通訊標準的語意規則">協議層：通訊標準的語意規則&lt;/h3>
&lt;p>協議層描述的是「這個資料在網路上如何被編碼、對方如何解讀」。WebSocket 協議（RFC 6455）定義 text frame 用 opcode 0x1、binary frame 用 opcode 0x2 — 兩者語意不同，接收端可以選擇只處理其中一種。&lt;/p>
&lt;p>Dart 的 &lt;code>IOWebSocketChannel&lt;/code>（真實實作）根據 &lt;code>sink.add&lt;/code> 的參數型別決定 frame type：&lt;code>String&lt;/code> 產生 text frame，&lt;code>List&amp;lt;int&amp;gt;&lt;/code> 或 &lt;code>Uint8List&lt;/code> 產生 binary frame。這個行為是 &lt;code>IOWebSocketChannel&lt;/code> 的實作細節，不是 &lt;code>WebSocketSink&lt;/code> 介面契約的一部分 — API 簽名用 &lt;code>dynamic&lt;/code> 把型別資訊抹除了（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）。&lt;/p>
&lt;p>ttyd 只接受 text frame，收到 binary frame 靜默忽略。從 API 層看，&lt;code>sink.add(Uint8List(...))&lt;/code> 合法；從協議層看，這產生了 ttyd 不處理的 binary frame。斷裂點在 API 層和協議層之間 — mock 模擬了前者，但後者的語意差異只有真實 &lt;code>IOWebSocketChannel&lt;/code> + 真實 ttyd 才會浮現。&lt;/p>
&lt;h3 id="環境層執行環境的行為差異">環境層：執行環境的行為差異&lt;/h3>
&lt;p>環境層描述的是「同一段程式碼在不同執行環境下行為不同」。DNS 解析、TLS 憑證驗證、防火牆規則、作業系統的 socket 實作 — 這些在 test 環境可能和 production 不同。&lt;/p>
&lt;p>環境層的遮蔽比協議層更難處理，因為即使用真實服務做 protocol integration test，test 環境和 production 環境仍可能有差異。本模組不深入環境層議題。&lt;/p>
&lt;h2 id="遮蔽的兩種模式">遮蔽的兩種模式&lt;/h2>
&lt;p>Mock 遮蔽在實務上有兩種不同的表現，需要不同的偵測策略。&lt;/p>
&lt;h3 id="模式一功能存在但行為錯誤">模式一：功能存在但行為錯誤&lt;/h3>
&lt;p>程式碼有對應的實作，但實作的行為和真實服務期望的行為不一致。Mock 讓這個不一致變得不可見，因為 mock 接受了實際上外部服務不會接受的輸入。&lt;/p>
&lt;p>T.C1 就是這種模式。&lt;code>sendData()&lt;/code> 實作了「發送鍵盤輸入」的功能，但發送的是 binary frame 而非 text frame。Mock 的 &lt;code>sink.add(dynamic)&lt;/code> 接受 &lt;code>Uint8List&lt;/code> 不報錯，真實 ttyd 靜默忽略 binary frame。功能存在，行為錯誤，mock 遮蔽了錯誤。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">Mock 遮蔽</a>是 mock 的設計邊界。「遮蔽」描述的是機制 — mock 讓協議層差異變得不可見；「盲區」描述的是結果 — 被遮蔽的範圍形成結構性的驗證缺口。Mock 的職責是模擬程式語言層面的 API 契約 — 方法簽名、參數型別、回傳值結構。協議層行為（frame type、handshake 步驟、編碼格式）不在 API 契約的描述範圍內，mock 沒有模擬這些行為的義務，也不應該被期待模擬。</p>
<h2 id="三層語意與斷裂點">三層語意與斷裂點</h2>
<p>程式碼和外部服務之間的互動經過三層語意轉換，每一層描述不同粒度的行為。Mock 模擬的是最上層，真實行為發生在下面兩層。</p>
<h3 id="api-層程式語言的方法簽名">API 層：程式語言的方法簽名</h3>
<p>API 層描述的是「這個方法接受什麼參數、回傳什麼型別」。Dart 的 <code>WebSocketSink.add</code> 簽名是 <code>void add(dynamic event)</code> — 從 API 層看，傳 <code>String</code> 和傳 <code>Uint8List</code> 都合法，都不會拋出例外。</p>
<p><code>FakeWebSocketChannel</code> 忠實實作了這個 API 契約。<code>sink.add(&quot;hello&quot;)</code> 和 <code>sink.add(Uint8List.fromList([104, 101, 108, 108, 111]))</code> 在 fake 的行為完全相同 — 資料進入內部 buffer，test 可以從 buffer 讀取驗證。Mock 的行為在 API 層是正確的。</p>
<h3 id="協議層通訊標準的語意規則">協議層：通訊標準的語意規則</h3>
<p>協議層描述的是「這個資料在網路上如何被編碼、對方如何解讀」。WebSocket 協議（RFC 6455）定義 text frame 用 opcode 0x1、binary frame 用 opcode 0x2 — 兩者語意不同，接收端可以選擇只處理其中一種。</p>
<p>Dart 的 <code>IOWebSocketChannel</code>（真實實作）根據 <code>sink.add</code> 的參數型別決定 frame type：<code>String</code> 產生 text frame，<code>List&lt;int&gt;</code> 或 <code>Uint8List</code> 產生 binary frame。這個行為是 <code>IOWebSocketChannel</code> 的實作細節，不是 <code>WebSocketSink</code> 介面契約的一部分 — API 簽名用 <code>dynamic</code> 把型別資訊抹除了（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）。</p>
<p>ttyd 只接受 text frame，收到 binary frame 靜默忽略。從 API 層看，<code>sink.add(Uint8List(...))</code> 合法；從協議層看，這產生了 ttyd 不處理的 binary frame。斷裂點在 API 層和協議層之間 — mock 模擬了前者，但後者的語意差異只有真實 <code>IOWebSocketChannel</code> + 真實 ttyd 才會浮現。</p>
<h3 id="環境層執行環境的行為差異">環境層：執行環境的行為差異</h3>
<p>環境層描述的是「同一段程式碼在不同執行環境下行為不同」。DNS 解析、TLS 憑證驗證、防火牆規則、作業系統的 socket 實作 — 這些在 test 環境可能和 production 不同。</p>
<p>環境層的遮蔽比協議層更難處理，因為即使用真實服務做 protocol integration test，test 環境和 production 環境仍可能有差異。本模組不深入環境層議題。</p>
<h2 id="遮蔽的兩種模式">遮蔽的兩種模式</h2>
<p>Mock 遮蔽在實務上有兩種不同的表現，需要不同的偵測策略。</p>
<h3 id="模式一功能存在但行為錯誤">模式一：功能存在但行為錯誤</h3>
<p>程式碼有對應的實作，但實作的行為和真實服務期望的行為不一致。Mock 讓這個不一致變得不可見，因為 mock 接受了實際上外部服務不會接受的輸入。</p>
<p>T.C1 就是這種模式。<code>sendData()</code> 實作了「發送鍵盤輸入」的功能，但發送的是 binary frame 而非 text frame。Mock 的 <code>sink.add(dynamic)</code> 接受 <code>Uint8List</code> 不報錯，真實 ttyd 靜默忽略 binary frame。功能存在，行為錯誤，mock 遮蔽了錯誤。</p>
<p>這種模式的偵測策略是 protocol integration test — 對真實服務發送相同輸入，比對回應是否符合預期。</p>
<h3 id="模式二功能根本沒實作">模式二：功能根本沒實作</h3>
<p>程式碼缺少應有的功能步驟，但 mock 不需要這個步驟就能進入成功狀態。Mock 把多步驟的協議流程簡化成單步操作，讓開發者不知道還有缺少的步驟。</p>
<p>T.C2 就是這種模式。ttyd 要求連線後發送 auth token，但 <code>ConnectionManager</code> 沒有實作這個步驟。<code>FakeWebSocketChannel.ready</code> 立即完成不需認證，<code>stream</code> 由開發者手動控制，不依賴 auth 狀態。Mock 把「TCP 握手 → WS 握手 → auth token → 驗證通過 → 推送資料」這個多步驟流程簡化成「<code>ready</code> 完成 → <code>stream</code> 有資料」（<a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a>）。</p>
<p>功能缺失比功能錯誤更難被偵測。功能錯誤至少有一段程式碼可以被 test 覆蓋（只是斷言的對象不夠深）；功能缺失意味著沒有程式碼可以寫 test。只有 protocol integration test 對真實服務跑完整流程，才能暴露「應該有但沒有」的步驟。</p>
<h2 id="mock-不應該模擬協議行為">Mock 不應該模擬協議行為</h2>
<p>面對 mock 遮蔽的第一個直覺反應通常是「讓 mock 更逼真」— 在 <code>FakeWebSocketChannel</code> 裡加入 frame type 區分、auth handshake 驗證等邏輯。這個方向有結構性問題。</p>
<p>Mock 的價值在於簡化 — 把複雜的外部依賴替換成行為可預測的替身，讓 unit test 專注在程式碼邏輯。如果 mock 開始模擬協議行為，mock 本身變成需要維護和驗證的複雜元件。Mock 的正確性由誰保證？如果外部服務更新了協議版本，誰負責更新 mock？</p>
<p>更根本的問題是：即使 mock 完美複製了當前版本的協議行為，它仍然是開發者對協議的理解的副本，不是協議本身。如果開發者對協議的理解就有偏差（例如不知道 ttyd 需要 auth token），mock 會忠實複製這個偏差。</p>
<p>正確的分工是：mock 負責 API 層，protocol integration test 負責協議層。每一層用正確的工具驗證。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>如何辨認偽裝成 integration test 的 mock test → <a href="/blog/testing/01-test-strategy-layers/nominal-integration-test/" data-link-title="「名義 integration test」的識別與修正" data-link-desc="test 名稱含 integration 但核心依賴全用 fake — 如何辨認、為什麼有害、怎麼修正命名和測試策略">名義 integration test 的識別與修正</a></li>
<li>判斷自己的服務是否存在這種斷裂 → <a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a></li>
<li>想看 SDK 自動攔截如何影響 mock 遮蔽 → <a href="/blog/monitoring/03-sdk-design/" data-link-title="模組三：SDK 設計模式" data-link-desc="跨平台 SDK 的自動攔截、手動上報、攢批送出、離線 buffer 設計">monitoring 模組三 SDK 設計</a></li>
</ul>
]]></content:encoded></item><item><title>「名義 integration test」的識別與修正</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/nominal-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/nominal-integration-test/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/nominal-integration-test/" data-link-title="名義 Integration Test" data-link-desc="名稱含 integration 但核心依賴全用 fake 的 test，驗證內部狀態機而非真實服務互動">名義 integration test&lt;/a> 是指 test 的名稱或檔案路徑包含「integration」或「端對端」，但實際上核心外部依賴全部被 fake 替換，驗證的是內部狀態機而非真實服務互動。這類 test 的核心問題是命名造成的認知偏差：團隊以為「integration test 有寫」，實際上協議層完全沒被驗證。它們驗證的邏輯可能完全正確 — 問題在命名，不在品質。&lt;/p>
&lt;h2 id="辨識特徵">辨識特徵&lt;/h2>
&lt;p>app_tunnel 的 &lt;code>connection_flow_test.dart&lt;/code> 是具體案例。檔名標題是端對端整合測試，但內部使用了三個核心替身：&lt;code>FakeWebSocketChannel&lt;/code>、&lt;code>FakeBiometricService&lt;/code>、&lt;code>InMemoryCredentialRepository&lt;/code>（&lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2&lt;/a>）。&lt;/p>
&lt;p>名義 integration test 有三個共同特徵可用來辨識。&lt;/p>
&lt;h3 id="特徵一核心外部依賴全替換">特徵一：核心外部依賴全替換&lt;/h3>
&lt;p>Integration test 的價值在於驗證程式碼與外部系統的互動邊界。如果所有外部依賴都被 fake 取代，test 驗證的實際上是「假設外部系統行為符合開發者預期，內部邏輯是否正確」。這和 unit test 的差別只在 scope 大小 — 多個內部元件一起測 — 不在驗證對象的本質。&lt;/p>
&lt;p>判斷方式：列出 test 的所有依賴注入點，計算有多少外部服務被替換成 fake。如果 100% 的外部依賴都是 fake，這個 test 不驗證任何真實互動。&lt;/p>
&lt;h3 id="特徵二沒有真實的-io-操作">特徵二：沒有真實的 I/O 操作&lt;/h3>
&lt;p>真正的 integration test 會產生真實的網路連線、讀寫真實的檔案、或呼叫真實的 API endpoint。名義 integration test 的所有 I/O 都在 process 內部完成 — &lt;code>StreamController&lt;/code> 替代網路 stream，&lt;code>Map&amp;lt;String, String&amp;gt;&lt;/code> 替代資料庫，&lt;code>Future.value()&lt;/code> 替代非同步 I/O。&lt;/p>
&lt;p>這些替身讓 test 執行速度快、結果穩定，但代價是完全跳過了 I/O 邊界上的所有行為差異。&lt;/p>
&lt;h3 id="特徵三沒有環境前置條件">特徵三：沒有環境前置條件&lt;/h3>
&lt;p>真正的 integration test 需要外部環境準備：啟動服務、建立連線、準備測試資料。名義 integration test 的 &lt;code>setUp()&lt;/code> 只建立 fake 物件，不啟動任何外部程序，不需要網路，可以在任何環境下執行。&lt;/p>
&lt;p>環境前置條件的缺席是一個實用的快速判斷訊號。如果 &lt;code>setUp()&lt;/code> 裡沒有 &lt;code>docker compose up&lt;/code>、&lt;code>Process.start&lt;/code>、&lt;code>HttpClient.connect&lt;/code> 之類的操作，這個 test 很可能不接觸真實外部服務。&lt;/p>
&lt;h2 id="名義-integration-test-造成的認知偏差">名義 integration test 造成的認知偏差&lt;/h2>
&lt;p>名義 integration test 的技術問題可以修正（改名或補寫真實 integration test），但它造成的認知偏差更難修正。&lt;/p>
&lt;p>當團隊看到 test suite 包含「integration test」資料夾且全部通過，決策者的推論是「integration 已經驗證過了」。這個推論在名義 integration test 下是錯的 — 協議層和環境層完全沒被驗證 — 但決策者沒有動機去檢查 test 的內部實作。&lt;/p>
&lt;p>app_tunnel 的 11 個 &lt;code>connection_flow_test&lt;/code> 全過，開發者合理認為「連線流程的整合測試已通過」。實際上這 11 個 test 驗證的是 &lt;code>ConnectionManager&lt;/code> 的內部狀態機在各種情境下的轉換正確性（斷線重連、錯誤處理、狀態回報），不是「和 ttyd 的連線流程是否正確」。Auth handshake 缺失直到實機測試才被發現。&lt;/p>
&lt;h2 id="修正策略">修正策略&lt;/h2>
&lt;h3 id="修正命名">修正命名&lt;/h3>
&lt;p>最低成本的修正是讓 test 名稱反映真實驗證對象。命名改動不影響 test 本身的價值 — 這些 test 驗證內部狀態機的邏輯仍然有用 — 只是消除命名造成的認知偏差。&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>connection_flow_test&lt;/td>
 &lt;td>connection_state_machine_test&lt;/td>
 &lt;td>測的是狀態機邏輯，不是真實連線流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>端對端整合測試&lt;/td>
 &lt;td>狀態機分支覆蓋&lt;/td>
 &lt;td>測的是分支覆蓋，不是端對端&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>integration_test/&lt;/td>
 &lt;td>state_machine_test/&lt;/td>
 &lt;td>資料夾名稱影響團隊對 test 覆蓋範圍的認知&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="補寫真實-integration-test">補寫真實 integration test&lt;/h3>
&lt;p>命名修正只消除誤解，不補上缺失的驗證層。如果服務的協議互動是關鍵路徑（連線、認證、資料交換），需要補寫對真實服務的 protocol integration test。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/nominal-integration-test/" data-link-title="名義 Integration Test" data-link-desc="名稱含 integration 但核心依賴全用 fake 的 test，驗證內部狀態機而非真實服務互動">名義 integration test</a> 是指 test 的名稱或檔案路徑包含「integration」或「端對端」，但實際上核心外部依賴全部被 fake 替換，驗證的是內部狀態機而非真實服務互動。這類 test 的核心問題是命名造成的認知偏差：團隊以為「integration test 有寫」，實際上協議層完全沒被驗證。它們驗證的邏輯可能完全正確 — 問題在命名，不在品質。</p>
<h2 id="辨識特徵">辨識特徵</h2>
<p>app_tunnel 的 <code>connection_flow_test.dart</code> 是具體案例。檔名標題是端對端整合測試，但內部使用了三個核心替身：<code>FakeWebSocketChannel</code>、<code>FakeBiometricService</code>、<code>InMemoryCredentialRepository</code>（<a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a>）。</p>
<p>名義 integration test 有三個共同特徵可用來辨識。</p>
<h3 id="特徵一核心外部依賴全替換">特徵一：核心外部依賴全替換</h3>
<p>Integration test 的價值在於驗證程式碼與外部系統的互動邊界。如果所有外部依賴都被 fake 取代，test 驗證的實際上是「假設外部系統行為符合開發者預期，內部邏輯是否正確」。這和 unit test 的差別只在 scope 大小 — 多個內部元件一起測 — 不在驗證對象的本質。</p>
<p>判斷方式：列出 test 的所有依賴注入點，計算有多少外部服務被替換成 fake。如果 100% 的外部依賴都是 fake，這個 test 不驗證任何真實互動。</p>
<h3 id="特徵二沒有真實的-io-操作">特徵二：沒有真實的 I/O 操作</h3>
<p>真正的 integration test 會產生真實的網路連線、讀寫真實的檔案、或呼叫真實的 API endpoint。名義 integration test 的所有 I/O 都在 process 內部完成 — <code>StreamController</code> 替代網路 stream，<code>Map&lt;String, String&gt;</code> 替代資料庫，<code>Future.value()</code> 替代非同步 I/O。</p>
<p>這些替身讓 test 執行速度快、結果穩定，但代價是完全跳過了 I/O 邊界上的所有行為差異。</p>
<h3 id="特徵三沒有環境前置條件">特徵三：沒有環境前置條件</h3>
<p>真正的 integration test 需要外部環境準備：啟動服務、建立連線、準備測試資料。名義 integration test 的 <code>setUp()</code> 只建立 fake 物件，不啟動任何外部程序，不需要網路，可以在任何環境下執行。</p>
<p>環境前置條件的缺席是一個實用的快速判斷訊號。如果 <code>setUp()</code> 裡沒有 <code>docker compose up</code>、<code>Process.start</code>、<code>HttpClient.connect</code> 之類的操作，這個 test 很可能不接觸真實外部服務。</p>
<h2 id="名義-integration-test-造成的認知偏差">名義 integration test 造成的認知偏差</h2>
<p>名義 integration test 的技術問題可以修正（改名或補寫真實 integration test），但它造成的認知偏差更難修正。</p>
<p>當團隊看到 test suite 包含「integration test」資料夾且全部通過，決策者的推論是「integration 已經驗證過了」。這個推論在名義 integration test 下是錯的 — 協議層和環境層完全沒被驗證 — 但決策者沒有動機去檢查 test 的內部實作。</p>
<p>app_tunnel 的 11 個 <code>connection_flow_test</code> 全過，開發者合理認為「連線流程的整合測試已通過」。實際上這 11 個 test 驗證的是 <code>ConnectionManager</code> 的內部狀態機在各種情境下的轉換正確性（斷線重連、錯誤處理、狀態回報），不是「和 ttyd 的連線流程是否正確」。Auth handshake 缺失直到實機測試才被發現。</p>
<h2 id="修正策略">修正策略</h2>
<h3 id="修正命名">修正命名</h3>
<p>最低成本的修正是讓 test 名稱反映真實驗證對象。命名改動不影響 test 本身的價值 — 這些 test 驗證內部狀態機的邏輯仍然有用 — 只是消除命名造成的認知偏差。</p>
<table>
  <thead>
      <tr>
          <th>原名稱</th>
          <th>修正後名稱</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>connection_flow_test</td>
          <td>connection_state_machine_test</td>
          <td>測的是狀態機邏輯，不是真實連線流程</td>
      </tr>
      <tr>
          <td>端對端整合測試</td>
          <td>狀態機分支覆蓋</td>
          <td>測的是分支覆蓋，不是端對端</td>
      </tr>
      <tr>
          <td>integration_test/</td>
          <td>state_machine_test/</td>
          <td>資料夾名稱影響團隊對 test 覆蓋範圍的認知</td>
      </tr>
  </tbody>
</table>
<h3 id="補寫真實-integration-test">補寫真實 integration test</h3>
<p>命名修正只消除誤解，不補上缺失的驗證層。如果服務的協議互動是關鍵路徑（連線、認證、資料交換），需要補寫對真實服務的 protocol integration test。</p>
<p>補寫的判斷原則不在本章展開 — 見 <a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a>。</p>
<h3 id="在-test-檔案內標明依賴替換清單">在 test 檔案內標明依賴替換清單</h3>
<p>在 test 檔案的頂部註釋中列出所有被 fake 取代的依賴，讓後續讀者不需要逐行追蹤就能判斷這個 test 的驗證邊界。</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">// Faked dependencies: WebSocketChannel, BiometricService, CredentialRepository
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// Verifies: ConnectionManager state machine transitions
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span><span class="o">//</span> <span class="n">Does</span> <span class="n">NOT</span> <span class="nl">verify:</span> <span class="n">real</span> <span class="n">WS</span> <span class="n">protocol</span><span class="p">,</span> <span class="n">auth</span> <span class="n">handshake</span><span class="p">,</span> <span class="n">biometric</span> <span class="n">hardware</span></span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>判斷是否需要補寫真實 integration test → <a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a></li>
<li>Mock 遮蔽機制的完整分析 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>想建 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
</ul>
]]></content:encoded></item><item><title>判斷原則：什麼時候需要 protocol integration test</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/when-protocol-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/when-protocol-integration-test/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">Protocol integration test&lt;/a> 有成本 — 需要真實服務實例、環境準備、執行速度較慢、結果可能因環境差異而不穩定。判斷是否需要這一層測試，依據的是服務架構的特徵，而非主觀的「寫多一點比較安心」。&lt;/p>
&lt;h2 id="三個判斷維度">三個判斷維度&lt;/h2>
&lt;h3 id="維度一協議複雜度">維度一：協議複雜度&lt;/h3>
&lt;p>程式碼和外部服務之間的協議是否存在 API 層無法描述的語意？&lt;/p>
&lt;p>HTTP REST API 的協議複雜度相對低：request body 是 JSON、response body 是 JSON、status code 有明確語意。Mock 一個 REST endpoint（回傳固定 JSON）和真實 endpoint 的行為差異主要在效能和邊界案例，核心語意差距小。&lt;/p>
&lt;p>WebSocket 協議的複雜度較高：連線握手、frame type（text / binary / ping / pong / close）、分片（fragmentation）、壓縮擴展（permessage-deflate）、子協議協商 — 這些語意在 API 層（&lt;code>sink.add(dynamic)&lt;/code>）是不可見的。gRPC 的 streaming、deadline propagation、metadata header 也有類似特徵。&lt;/p>
&lt;p>判斷問題：&lt;strong>API 簽名是否隱藏了協議層的行為分支？&lt;/strong> 如果 API 用 &lt;code>dynamic&lt;/code>、&lt;code>Object&lt;/code>、&lt;code>Any&lt;/code> 等寬泛型別接受輸入，而協議層對不同輸入有不同處理方式，這就是需要 protocol integration test 的訊號。&lt;/p>
&lt;p>app_tunnel 的 &lt;code>sink.add(dynamic)&lt;/code> 就是這個模式 — API 簽名不區分 &lt;code>String&lt;/code> 和 &lt;code>Uint8List&lt;/code>，但協議層對兩者產生不同的 frame type（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）。&lt;/p>
&lt;h3 id="維度二mock-寬鬆度">維度二：Mock 寬鬆度&lt;/h3>
&lt;p>Mock 的行為是否比真實服務更寬容？&lt;/p>
&lt;p>Mock 通常是「最小可用」的實作 — 能讓 test 通過就好。這意味著 mock 的行為往往比真實服務寬鬆：不檢查認證、不限制速率、不要求特定順序、不區分輸入格式。&lt;/p>
&lt;p>寬鬆本身不是問題，但寬鬆程度和真實服務的差距決定了 mock 遮蔽的風險大小。判斷問題：&lt;strong>Mock 跳過了真實服務的哪些步驟？每個被跳過的步驟在業務上是否關鍵？&lt;/strong>&lt;/p>
&lt;p>app_tunnel 的 &lt;code>FakeWebSocketChannel&lt;/code> 跳過了 auth handshake — &lt;code>ready&lt;/code> 立即完成不需認證。Auth handshake 在業務上是關鍵步驟（沒有認證，ttyd 不推送資料），mock 跳過這一步讓「功能根本沒實作」變得不可見（&lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2&lt;/a>）。&lt;/p>
&lt;p>逐項列出 mock 跳過的步驟是一個實用的 audit 方法。寫出「&lt;code>FakeWebSocketChannel&lt;/code> 和 &lt;code>IOWebSocketChannel&lt;/code> 的行為差異清單」，每一個差異點就是潛在的遮蔽風險。&lt;/p>
&lt;h3 id="維度三失敗靜默度">維度三：失敗靜默度&lt;/h3>
&lt;p>外部服務收到非預期輸入時，回應是明確的錯誤還是靜默忽略？&lt;/p>
&lt;p>如果外部服務對錯誤輸入回傳 HTTP 400 或斷線，問題在實機測試時會快速浮現 — 程式碼進入 error 狀態，開發者看到明確的錯誤訊息。但如果外部服務靜默忽略，問題表現為「連線成功但沒有回應」，debug 方向可能完全錯誤。&lt;/p>
&lt;p>ttyd 收到 binary frame 時靜默忽略，不回傳錯誤碼也不斷線。這讓問題的表現從「frame type 錯誤」變成「終端機無回應」，開發者的 debug 方向是「為什麼 terminal 沒反應」而非「為什麼 frame type 不對」。&lt;/p>
&lt;p>判斷問題：&lt;strong>外部服務是否有靜默忽略的行為？&lt;/strong> 如果有，protocol integration test 的價值更高 — 因為即使在實機測試階段，靜默忽略也會增加 debug 成本。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">Protocol integration test</a> 有成本 — 需要真實服務實例、環境準備、執行速度較慢、結果可能因環境差異而不穩定。判斷是否需要這一層測試，依據的是服務架構的特徵，而非主觀的「寫多一點比較安心」。</p>
<h2 id="三個判斷維度">三個判斷維度</h2>
<h3 id="維度一協議複雜度">維度一：協議複雜度</h3>
<p>程式碼和外部服務之間的協議是否存在 API 層無法描述的語意？</p>
<p>HTTP REST API 的協議複雜度相對低：request body 是 JSON、response body 是 JSON、status code 有明確語意。Mock 一個 REST endpoint（回傳固定 JSON）和真實 endpoint 的行為差異主要在效能和邊界案例，核心語意差距小。</p>
<p>WebSocket 協議的複雜度較高：連線握手、frame type（text / binary / ping / pong / close）、分片（fragmentation）、壓縮擴展（permessage-deflate）、子協議協商 — 這些語意在 API 層（<code>sink.add(dynamic)</code>）是不可見的。gRPC 的 streaming、deadline propagation、metadata header 也有類似特徵。</p>
<p>判斷問題：<strong>API 簽名是否隱藏了協議層的行為分支？</strong> 如果 API 用 <code>dynamic</code>、<code>Object</code>、<code>Any</code> 等寬泛型別接受輸入，而協議層對不同輸入有不同處理方式，這就是需要 protocol integration test 的訊號。</p>
<p>app_tunnel 的 <code>sink.add(dynamic)</code> 就是這個模式 — API 簽名不區分 <code>String</code> 和 <code>Uint8List</code>，但協議層對兩者產生不同的 frame type（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）。</p>
<h3 id="維度二mock-寬鬆度">維度二：Mock 寬鬆度</h3>
<p>Mock 的行為是否比真實服務更寬容？</p>
<p>Mock 通常是「最小可用」的實作 — 能讓 test 通過就好。這意味著 mock 的行為往往比真實服務寬鬆：不檢查認證、不限制速率、不要求特定順序、不區分輸入格式。</p>
<p>寬鬆本身不是問題，但寬鬆程度和真實服務的差距決定了 mock 遮蔽的風險大小。判斷問題：<strong>Mock 跳過了真實服務的哪些步驟？每個被跳過的步驟在業務上是否關鍵？</strong></p>
<p>app_tunnel 的 <code>FakeWebSocketChannel</code> 跳過了 auth handshake — <code>ready</code> 立即完成不需認證。Auth handshake 在業務上是關鍵步驟（沒有認證，ttyd 不推送資料），mock 跳過這一步讓「功能根本沒實作」變得不可見（<a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a>）。</p>
<p>逐項列出 mock 跳過的步驟是一個實用的 audit 方法。寫出「<code>FakeWebSocketChannel</code> 和 <code>IOWebSocketChannel</code> 的行為差異清單」，每一個差異點就是潛在的遮蔽風險。</p>
<h3 id="維度三失敗靜默度">維度三：失敗靜默度</h3>
<p>外部服務收到非預期輸入時，回應是明確的錯誤還是靜默忽略？</p>
<p>如果外部服務對錯誤輸入回傳 HTTP 400 或斷線，問題在實機測試時會快速浮現 — 程式碼進入 error 狀態，開發者看到明確的錯誤訊息。但如果外部服務靜默忽略，問題表現為「連線成功但沒有回應」，debug 方向可能完全錯誤。</p>
<p>ttyd 收到 binary frame 時靜默忽略，不回傳錯誤碼也不斷線。這讓問題的表現從「frame type 錯誤」變成「終端機無回應」，開發者的 debug 方向是「為什麼 terminal 沒反應」而非「為什麼 frame type 不對」。</p>
<p>判斷問題：<strong>外部服務是否有靜默忽略的行為？</strong> 如果有，protocol integration test 的價值更高 — 因為即使在實機測試階段，靜默忽略也會增加 debug 成本。</p>
<h2 id="決策流程">決策流程</h2>
<p>以下流程不追求完備覆蓋所有情境，而是提供一個起點，根據上述三個維度的組合判斷 protocol integration test 的必要性。</p>
<p><strong>協議複雜度高（API 層和協議層有語意斷裂）：</strong> 需要 protocol integration test。即使 mock 寬鬆度低、失敗回報明確，語意斷裂本身就是 mock 結構性無法覆蓋的盲區。</p>
<p><strong>協議複雜度低，但 mock 寬鬆度高（mock 跳過業務關鍵步驟）：</strong> 需要 protocol integration test。Mock 跳過的步驟越多，「功能缺失不可見」的風險越大。</p>
<p><strong>協議複雜度低，mock 寬鬆度低：</strong> 依失敗靜默度判斷。如果外部服務靜默忽略錯誤，protocol integration test 有較高價值；如果錯誤回報明確，可以依賴實機測試階段的 error 來發現問題。</p>
<p><strong>成本極低的情境：</strong> 當外部服務可以在 test 環境輕鬆啟動時（自用工具 server+client 同機、Docker 一行啟動的 open source service），protocol integration test 的成本門檻大幅降低，三個維度中任何一個有疑慮就值得寫。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想實作 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>理解 mock 遮蔽的結構性原因 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>反模式：試圖用更多 mock test 補救 → <a href="/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/" data-link-title="反模式：用 mock 數量彌補 mock 盲區" data-link-desc="為什麼增加 mock test 數量無法跨越 mock 的結構性盲區 — 從 192 個 test 全過的案例拆解數量與覆蓋率的真正關係">反模式：用 mock 數量彌補 mock 盲區</a></li>
</ul>
]]></content:encoded></item><item><title>反模式：用 mock 數量彌補 mock 盲區</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/</guid><description>&lt;p>當 mock test 全過但實機出問題時，常見的第一反應是「test 不夠多」或「覆蓋率不夠高」。這個反應假設 mock test 的問題在數量，而實際上問題在層級 — mock test 驗證的對象和實機暴露的問題不在同一層。增加 mock test 數量擴展的是同一層的覆蓋範圍，不會跨越到另一層。&lt;/p>
&lt;h2 id="數量與層級的區別">數量與層級的區別&lt;/h2>
&lt;p>app_tunnel 的 192 個 unit test 覆蓋了 &lt;code>ConnectionManager&lt;/code>、&lt;code>AnsiParser&lt;/code>、&lt;code>TerminalBuffer&lt;/code> 等元件的邏輯分支。如果在 mock test 全過但實機失敗後，反應是「再寫 50 個 test」，新寫的 test 會使用同一個 &lt;code>FakeWebSocketChannel&lt;/code>，測試更多的邏輯分支 — 更多的輸入組合、更多的邊界條件、更多的錯誤處理路徑。&lt;/p>
&lt;p>這 50 個新 test 和原來的 192 個 test 在同一個 mock 環境中執行，受到同一個 &lt;code>FakeWebSocketChannel&lt;/code> 的行為限制。&lt;code>FakeWebSocketChannel&lt;/code> 不區分 text frame 和 binary frame — 這個限制在第 1 個 test 和第 242 個 test 中都一樣。數量增加了，遮蔽範圍沒有改變。&lt;/p>
&lt;p>用類比說明：用純水測試淨水器的過濾效果，不管測 1 杯還是 1000 杯，結論都是「水很乾淨」。問題在測試材料 — 需要用含有雜質的水測試才能驗證過濾功能。Mock 是純水，真實服務互動是含雜質的水。&lt;/p>
&lt;h2 id="覆蓋率指標的盲點">覆蓋率指標的盲點&lt;/h2>
&lt;p>Line coverage 和 branch coverage 衡量的是「程式碼中有多少行 / 分支被 test 執行過」。這些指標在同一層 test 內有意義 — 100% branch coverage 的 unit test 確保每個 if/else 都被走過。&lt;/p>
&lt;p>但覆蓋率指標不區分 test 的依賴環境。一個使用 &lt;code>FakeWebSocketChannel&lt;/code> 的 test 和一個使用 &lt;code>IOWebSocketChannel&lt;/code> 的 test 走過同一行 &lt;code>sink.add(data)&lt;/code> — 在覆蓋率報告中是同一行被覆蓋，但驗證的語意完全不同。&lt;/p>
&lt;p>覆蓋率 100% 意味著「在 mock 環境中，所有程式碼分支都被走過」。這不等於「在真實環境中，所有程式碼分支的行為都是正確的」。app_tunnel 的 &lt;code>sendData()&lt;/code> 在覆蓋率報告中是「已覆蓋」的，但覆蓋它的 test 用的是不區分 frame type 的 fake。&lt;/p>
&lt;h2 id="這個反模式如何在團隊中擴散">這個反模式如何在團隊中擴散&lt;/h2>
&lt;p>「test 不夠多」是一個容易執行、容易衡量的回應。在沒有獨立 QA 驗收流程的團隊中（覆蓋率報告是主要品質指標），寫更多 test 可以提高覆蓋率數字，覆蓋率數字上升給團隊信心。相比之下，「需要一個新的 test 層級」需要建置新的 test 環境、學習不同的 test 技術、接受較慢的執行速度。&lt;/p>
&lt;p>這個成本差異讓團隊傾向於在既有的 mock test 層加量，而非引入新的 test 層。每一輪加量後覆蓋率上升，團隊信心增加，但 mock 遮蔽的盲區從未被觸及。問題在下一次實機測試或 production incident 中再次浮現，觸發新一輪的「test 不夠多」反應。&lt;/p>
&lt;p>打破這個循環的起點是區分「同層覆蓋率不足」和「層級缺失」。如果問題是同層覆蓋率不足（某個分支沒被 test 走到），加 test 有效。如果問題是層級缺失（mock 結構性地遮蔽了某類行為），加同一層的 test 無效，需要引入新的 test 層級。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;p>以下訊號指向「層級缺失」而非「數量不足」：&lt;/p>
&lt;p>&lt;strong>test 全過但實機失敗的 bug 類型集中在外部互動&lt;/strong>：連線問題、認證問題、資料格式問題、逾時問題 — 這些問題的共同特徵是發生在程式碼與外部服務的邊界上，不是程式碼內部的邏輯錯誤。&lt;/p>
&lt;p>&lt;strong>修復後原有 test 不需要改動&lt;/strong>：如果 bug 修復只加了新程式碼（例如新增 auth handshake 步驟）而原有 test 全部不受影響，說明原有 test 從一開始就沒有覆蓋這個行為 — 整個 test 層級不涵蓋這類行為。&lt;/p>
&lt;p>&lt;strong>bug 修復是型別轉換或編碼調整&lt;/strong>：&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code> 這類修復改變的是資料在協議層的表現，不是程式邏輯。在 mock 環境中，這個修改前後的行為完全相同 — mock 不區分 frame type。&lt;/p>
&lt;p>區分「同層覆蓋率不足」和「層級缺失」之後，回到&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層定義與職責表&lt;/a>確認每層的邊界。Mock 遮蔽的結構性原因在 &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析&lt;/a>中展開。如果判斷結果是層級缺失，&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test&lt;/a> 提供引入新層級的決策流程。&lt;/p></description><content:encoded><![CDATA[<p>當 mock test 全過但實機出問題時，常見的第一反應是「test 不夠多」或「覆蓋率不夠高」。這個反應假設 mock test 的問題在數量，而實際上問題在層級 — mock test 驗證的對象和實機暴露的問題不在同一層。增加 mock test 數量擴展的是同一層的覆蓋範圍，不會跨越到另一層。</p>
<h2 id="數量與層級的區別">數量與層級的區別</h2>
<p>app_tunnel 的 192 個 unit test 覆蓋了 <code>ConnectionManager</code>、<code>AnsiParser</code>、<code>TerminalBuffer</code> 等元件的邏輯分支。如果在 mock test 全過但實機失敗後，反應是「再寫 50 個 test」，新寫的 test 會使用同一個 <code>FakeWebSocketChannel</code>，測試更多的邏輯分支 — 更多的輸入組合、更多的邊界條件、更多的錯誤處理路徑。</p>
<p>這 50 個新 test 和原來的 192 個 test 在同一個 mock 環境中執行，受到同一個 <code>FakeWebSocketChannel</code> 的行為限制。<code>FakeWebSocketChannel</code> 不區分 text frame 和 binary frame — 這個限制在第 1 個 test 和第 242 個 test 中都一樣。數量增加了，遮蔽範圍沒有改變。</p>
<p>用類比說明：用純水測試淨水器的過濾效果，不管測 1 杯還是 1000 杯，結論都是「水很乾淨」。問題在測試材料 — 需要用含有雜質的水測試才能驗證過濾功能。Mock 是純水，真實服務互動是含雜質的水。</p>
<h2 id="覆蓋率指標的盲點">覆蓋率指標的盲點</h2>
<p>Line coverage 和 branch coverage 衡量的是「程式碼中有多少行 / 分支被 test 執行過」。這些指標在同一層 test 內有意義 — 100% branch coverage 的 unit test 確保每個 if/else 都被走過。</p>
<p>但覆蓋率指標不區分 test 的依賴環境。一個使用 <code>FakeWebSocketChannel</code> 的 test 和一個使用 <code>IOWebSocketChannel</code> 的 test 走過同一行 <code>sink.add(data)</code> — 在覆蓋率報告中是同一行被覆蓋，但驗證的語意完全不同。</p>
<p>覆蓋率 100% 意味著「在 mock 環境中，所有程式碼分支都被走過」。這不等於「在真實環境中，所有程式碼分支的行為都是正確的」。app_tunnel 的 <code>sendData()</code> 在覆蓋率報告中是「已覆蓋」的，但覆蓋它的 test 用的是不區分 frame type 的 fake。</p>
<h2 id="這個反模式如何在團隊中擴散">這個反模式如何在團隊中擴散</h2>
<p>「test 不夠多」是一個容易執行、容易衡量的回應。在沒有獨立 QA 驗收流程的團隊中（覆蓋率報告是主要品質指標），寫更多 test 可以提高覆蓋率數字，覆蓋率數字上升給團隊信心。相比之下，「需要一個新的 test 層級」需要建置新的 test 環境、學習不同的 test 技術、接受較慢的執行速度。</p>
<p>這個成本差異讓團隊傾向於在既有的 mock test 層加量，而非引入新的 test 層。每一輪加量後覆蓋率上升，團隊信心增加，但 mock 遮蔽的盲區從未被觸及。問題在下一次實機測試或 production incident 中再次浮現，觸發新一輪的「test 不夠多」反應。</p>
<p>打破這個循環的起點是區分「同層覆蓋率不足」和「層級缺失」。如果問題是同層覆蓋率不足（某個分支沒被 test 走到），加 test 有效。如果問題是層級缺失（mock 結構性地遮蔽了某類行為），加同一層的 test 無效，需要引入新的 test 層級。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<p>以下訊號指向「層級缺失」而非「數量不足」：</p>
<p><strong>test 全過但實機失敗的 bug 類型集中在外部互動</strong>：連線問題、認證問題、資料格式問題、逾時問題 — 這些問題的共同特徵是發生在程式碼與外部服務的邊界上，不是程式碼內部的邏輯錯誤。</p>
<p><strong>修復後原有 test 不需要改動</strong>：如果 bug 修復只加了新程式碼（例如新增 auth handshake 步驟）而原有 test 全部不受影響，說明原有 test 從一開始就沒有覆蓋這個行為 — 整個 test 層級不涵蓋這類行為。</p>
<p><strong>bug 修復是型別轉換或編碼調整</strong>：<code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code> 這類修復改變的是資料在協議層的表現，不是程式邏輯。在 mock 環境中，這個修改前後的行為完全相同 — mock 不區分 frame type。</p>
<p>區分「同層覆蓋率不足」和「層級缺失」之後，回到<a href="/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層定義與職責表</a>確認每層的邊界。Mock 遮蔽的結構性原因在 <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a>中展開。如果判斷結果是層級缺失，<a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a> 提供引入新層級的決策流程。</p>
]]></content:encoded></item></channel></rss>