<?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>Protocol on Tarragon</title><link>https://tarrragon.github.io/blog/tags/protocol/</link><description>Recent content in Protocol 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/tags/protocol/index.xml" rel="self" type="application/rss+xml"/><item><title>Protocol Integration Test</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/protocol-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/protocol-integration-test/</guid><description>&lt;p>Protocol integration test 的核心概念是「對真實服務實例驗證協議層行為」。它跳過 mock，直接連線到真實的外部服務，觀察連線握手、認證流程、資料編碼和回應格式是否符合協議規格。和 &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 遮蔽的盲區正是 protocol integration test 的驗證範圍。可先對照&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>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Protocol integration test 位在 unit test 和 E2E test 之間。Unit test 用 mock 驗證程式碼邏輯，E2E test 經過 UI 驗證完整流程，protocol integration test 用程式碼直接呼叫 client 端連線函式、對真實服務執行操作。它填補「程式碼邏輯正確但協議互動錯誤」這個 mock 結構性無法覆蓋的空隙。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 protocol integration test 的訊號是：API 簽名用寬泛型別（&lt;code>dynamic&lt;/code>、&lt;code>Object&lt;/code>、&lt;code>Any&lt;/code>）隱藏了協議層的行為分支、mock 跳過了業務關鍵步驟（認證、握手）、或外部服務對錯誤輸入靜默忽略。WebSocket 的 text/binary frame 差異、gRPC 的 streaming deadline、MQTT 的 QoS level 都是典型場景。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Protocol integration test 要決定服務 fixture 的管理方式（Process.start / Docker / testcontainers）、健康檢查策略（port 可達 / HTTP health / 業務操作成功）、和狀態隔離方式（每 test 重啟 / 重設狀態 / 獨立 namespace）。成本判斷依據服務啟動成本和協議複雜度兩個維度。&lt;/p></description><content:encoded><![CDATA[<p>Protocol integration test 的核心概念是「對真實服務實例驗證協議層行為」。它跳過 mock，直接連線到真實的外部服務，觀察連線握手、認證流程、資料編碼和回應格式是否符合協議規格。和 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>互補 — mock 遮蔽的盲區正是 protocol integration test 的驗證範圍。可先對照<a href="/blog/testing/knowledge-cards/nominal-integration-test/" data-link-title="名義 Integration Test" data-link-desc="名稱含 integration 但核心依賴全用 fake 的 test，驗證內部狀態機而非真實服務互動">名義 integration test</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Protocol integration test 位在 unit test 和 E2E test 之間。Unit test 用 mock 驗證程式碼邏輯，E2E test 經過 UI 驗證完整流程，protocol integration test 用程式碼直接呼叫 client 端連線函式、對真實服務執行操作。它填補「程式碼邏輯正確但協議互動錯誤」這個 mock 結構性無法覆蓋的空隙。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 protocol integration test 的訊號是：API 簽名用寬泛型別（<code>dynamic</code>、<code>Object</code>、<code>Any</code>）隱藏了協議層的行為分支、mock 跳過了業務關鍵步驟（認證、握手）、或外部服務對錯誤輸入靜默忽略。WebSocket 的 text/binary frame 差異、gRPC 的 streaming deadline、MQTT 的 QoS level 都是典型場景。</p>
<h2 id="設計責任">設計責任</h2>
<p>Protocol integration test 要決定服務 fixture 的管理方式（Process.start / Docker / testcontainers）、健康檢查策略（port 可達 / HTTP health / 業務操作成功）、和狀態隔離方式（每 test 重啟 / 重設狀態 / 獨立 namespace）。成本判斷依據服務啟動成本和協議複雜度兩個維度。</p>
]]></content:encoded></item><item><title>Protocol integration test 定義</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/definition-and-boundary/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/definition-and-boundary/</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> 驗證的是程式碼和真實外部服務之間的協議互動 — 連線方式、認證流程、資料編碼、回應格式。它和 unit test 的差別是不用 mock，和 E2E test 的差別是不經過 UI。&lt;/p>
&lt;h2 id="三種-test-的邊界">三種 test 的邊界&lt;/h2>
&lt;h3 id="unit-test">Unit test&lt;/h3>
&lt;p>驗證程式碼邏輯。外部依賴全部用 mock 替代。斷言對象是函式的回傳值、狀態變化、例外拋出。&lt;/p>
&lt;p>Unit test 無法驗證的：程式碼和真實外部服務之間的行為差異（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;/p>
&lt;h3 id="protocol-integration-test">Protocol integration test&lt;/h3>
&lt;p>驗證程式碼和真實服務的協議互動。不用 mock — 對真實的服務實例發送請求、觀察真實的回應。不經過 UI — 直接呼叫 client 端的連線函式或 HTTP client。&lt;/p>
&lt;p>Protocol integration test 驗證的是：連線能否建立、認證流程是否正確、發送的資料格式是否被接受、回應是否符合預期。&lt;/p>
&lt;h3 id="e2e-test">E2E test&lt;/h3>
&lt;p>驗證完整的使用者操作流程。從 UI 操作開始（點擊按鈕），經過 client 端邏輯，到達真實服務，再回到 UI 顯示結果。&lt;/p>
&lt;p>E2E test 的覆蓋範圍最廣但成本最高 — 需要啟動 app、操作 UI、等待網路回應、斷言 UI 狀態。E2E test 通常執行慢、不穩定（UI 動畫、網路延遲、裝置狀態影響結果）。&lt;/p>
&lt;h2 id="protocol-integration-test-的定位">Protocol integration test 的定位&lt;/h2>
&lt;p>Protocol integration test 填補 unit test 和 E2E test 之間的空隙。Unit test 覆蓋程式碼邏輯，E2E test 覆蓋端到端流程，protocol integration test 覆蓋「程式碼和外部服務的互動」這個特定層。&lt;/p>
&lt;p>這一層的 test 用程式碼直接呼叫 client 端的連線函式（跳過 UI），對真實的服務實例執行操作（跳過 mock），然後斷言服務的回應是否符合協議規格。&lt;/p>
&lt;p>以 app_tunnel 為例，一個 protocol integration test 的結構：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">1. 啟動本機 ttyd 服務
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">2. 用 IOWebSocketChannel 連線到 ttyd
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">3. 發送 auth token JSON frame
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">4. 斷言收到 terminal output
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">5. 發送 Uint8List 鍵盤輸入
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">6. 斷言 ttyd 沒有回應（binary frame 被忽略）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">7. 發送 String 鍵盤輸入
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">8. 斷言 ttyd 有回應（text frame 被處理）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個 test 不需要 Flutter UI、不需要 FakeWebSocketChannel，直接驗證「我的程式碼送出的資料，真實 ttyd 是否正確處理」。&lt;/p>
&lt;p>以 WebSocket 為例的具體實作在 &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/websocket-protocol-test/" data-link-title="WebSocket 協議測試實作" data-link-desc="對真實 ttyd 驗證 frame type 和 auth handshake — 從 T.C1 和 T.C2 的教訓推導出的 protocol integration test 設計">WebSocket 協議測試實作&lt;/a>中展開。在投入建置之前，用&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/cost-judgment/" data-link-title="成本判斷表" data-link-desc="什麼時候值得寫 protocol integration test、什麼時候用 contract test 或實機測試替代 — 根據服務啟動成本和協議複雜度判斷">成本判斷表&lt;/a>評估服務啟動成本和協議複雜度是否值得這一層 test。Protocol integration test 和 mock test 的分工邊界回到 &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略分層&lt;/a>的三層框架。&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> 驗證的是程式碼和真實外部服務之間的協議互動 — 連線方式、認證流程、資料編碼、回應格式。它和 unit test 的差別是不用 mock，和 E2E test 的差別是不經過 UI。</p>
<h2 id="三種-test-的邊界">三種 test 的邊界</h2>
<h3 id="unit-test">Unit test</h3>
<p>驗證程式碼邏輯。外部依賴全部用 mock 替代。斷言對象是函式的回傳值、狀態變化、例外拋出。</p>
<p>Unit test 無法驗證的：程式碼和真實外部服務之間的行為差異（mock 遮蔽了這些差異，見 <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a>）。</p>
<h3 id="protocol-integration-test">Protocol integration test</h3>
<p>驗證程式碼和真實服務的協議互動。不用 mock — 對真實的服務實例發送請求、觀察真實的回應。不經過 UI — 直接呼叫 client 端的連線函式或 HTTP client。</p>
<p>Protocol integration test 驗證的是：連線能否建立、認證流程是否正確、發送的資料格式是否被接受、回應是否符合預期。</p>
<h3 id="e2e-test">E2E test</h3>
<p>驗證完整的使用者操作流程。從 UI 操作開始（點擊按鈕），經過 client 端邏輯，到達真實服務，再回到 UI 顯示結果。</p>
<p>E2E test 的覆蓋範圍最廣但成本最高 — 需要啟動 app、操作 UI、等待網路回應、斷言 UI 狀態。E2E test 通常執行慢、不穩定（UI 動畫、網路延遲、裝置狀態影響結果）。</p>
<h2 id="protocol-integration-test-的定位">Protocol integration test 的定位</h2>
<p>Protocol integration test 填補 unit test 和 E2E test 之間的空隙。Unit test 覆蓋程式碼邏輯，E2E test 覆蓋端到端流程，protocol integration test 覆蓋「程式碼和外部服務的互動」這個特定層。</p>
<p>這一層的 test 用程式碼直接呼叫 client 端的連線函式（跳過 UI），對真實的服務實例執行操作（跳過 mock），然後斷言服務的回應是否符合協議規格。</p>
<p>以 app_tunnel 為例，一個 protocol integration test 的結構：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 啟動本機 ttyd 服務
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 用 IOWebSocketChannel 連線到 ttyd
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 發送 auth token JSON frame
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 斷言收到 terminal output
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. 發送 Uint8List 鍵盤輸入
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 斷言 ttyd 沒有回應（binary frame 被忽略）
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. 發送 String 鍵盤輸入
</span></span><span class="line"><span class="ln">8</span><span class="cl">8. 斷言 ttyd 有回應（text frame 被處理）</span></span></code></pre></div><p>這個 test 不需要 Flutter UI、不需要 FakeWebSocketChannel，直接驗證「我的程式碼送出的資料，真實 ttyd 是否正確處理」。</p>
<p>以 WebSocket 為例的具體實作在 <a href="/blog/testing/03-protocol-integration-test/websocket-protocol-test/" data-link-title="WebSocket 協議測試實作" data-link-desc="對真實 ttyd 驗證 frame type 和 auth handshake — 從 T.C1 和 T.C2 的教訓推導出的 protocol integration test 設計">WebSocket 協議測試實作</a>中展開。在投入建置之前，用<a href="/blog/testing/03-protocol-integration-test/cost-judgment/" data-link-title="成本判斷表" data-link-desc="什麼時候值得寫 protocol integration test、什麼時候用 contract test 或實機測試替代 — 根據服務啟動成本和協議複雜度判斷">成本判斷表</a>評估服務啟動成本和協議複雜度是否值得這一層 test。Protocol integration test 和 mock test 的分工邊界回到 <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略分層</a>的三層框架。</p>
]]></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>Terminal app 輸入設計</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/terminal-input-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/terminal-input-design/</guid><description>&lt;p>Terminal app 在手機上的輸入需求和一般文字輸入有根本差異。CLI 指令是結構化語法，路徑分隔符、flag 縮寫、管線符號都有精確語意 — 手機鍵盤為自然語言設計的自動行為（校正、建議、學習）在 CLI 場景中全部變成干擾。&lt;/p>
&lt;h2 id="cli-輸入的特殊性">CLI 輸入的特殊性&lt;/h2>
&lt;p>桌面終端機的鍵盤直接傳送按鍵事件，沒有中間的輸入法處理層。使用者按 &lt;code>l&lt;/code> 就是 &lt;code>l&lt;/code>，按 Tab 就是 Tab，按 Ctrl+C 就是 interrupt signal。&lt;/p>
&lt;p>手機鍵盤在使用者和 app 之間插入了 IME 層。使用者按 &lt;code>l&lt;/code> 時，IME 可能等待後續按鍵組合成完整詞彙再傳送；使用者按的按鍵可能被自動校正替換；使用者的輸入被記錄到 IME 詞庫供跨 app 學習。&lt;/p>
&lt;p>Terminal app 需要繞過或控制 IME 層的這些行為。app_tunnel 的 TextField 用 &lt;code>TextInputType.visiblePassword&lt;/code> + &lt;code>autocorrect: false&lt;/code> + &lt;code>enableSuggestions: false&lt;/code> + &lt;code>enableIMEPersonalizedLearning: false&lt;/code> 四個參數關閉 IME 的自動行為（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>）。&lt;/p>
&lt;h2 id="整行送出-vs-逐字元protocol-層的影響">整行送出 vs 逐字元：protocol 層的影響&lt;/h2>
&lt;p>整行送出和逐字元送出在 UI 層看起來只是「按 Enter 送出整行」和「每個按鍵即時送出」的差別，但在 protocol 層是兩種不同的通訊模式。&lt;/p>
&lt;h3 id="整行送出">整行送出&lt;/h3>
&lt;p>Client 端累積使用者輸入，使用者按 Enter 時傳送完整指令字串加換行符（&lt;code>ls -la\n&lt;/code>）。Server 端收到完整行後處理。&lt;/p>
&lt;p>Protocol 設計簡單：每個 WebSocket frame 是一個完整指令。Server 不需要管理部分輸入的狀態，也不需要即時回應 Tab 或方向鍵。&lt;/p>
&lt;p>代價：使用者無法在手機上使用 Tab 補全（Tab 被 IME 攔截或不存在）、無法用方向鍵在指令中移動游標（移動的是 TextField 的游標，不是 server 端的 readline 游標）。&lt;/p>
&lt;h3 id="逐字元送出">逐字元送出&lt;/h3>
&lt;p>Client 端每個按鍵即時傳送一個 WebSocket frame。Server 端的 shell 即時處理每個字元，包括 Tab 補全（server 回傳補全結果）、Ctrl+C（server 中斷當前程序）、方向鍵（server 端 readline 移動游標）。&lt;/p>
&lt;p>Protocol 設計複雜：每個按鍵一個 frame，frame 內容是單一字元或控制序列。Server 端必須維護 readline 狀態。Client 端必須正確編碼控制字元（Ctrl+C = 0x03, Tab = 0x09）。&lt;/p>
&lt;p>代價：protocol 複雜度高，每個按鍵都有網路延遲。在高延遲網路上輸入體驗差（打字後要等 round-trip 才看到回顯）。&lt;/p>
&lt;h3 id="決策在-protocol-層做">決策在 protocol 層做&lt;/h3>
&lt;p>app_tunnel 選擇整行送出，犧牲 Tab 補全換取簡單的 protocol 設計。這個決策應該在 protocol spec 階段做 — 因為它影響 server 端（ttyd）的行為預期和 client 端的 frame 格式。在 UI 實作時才臨時決定，可能和 server 端的行為預期不一致。&lt;/p>
&lt;h2 id="特殊按鍵的-ui-方案">特殊按鍵的 UI 方案&lt;/h2>
&lt;p>手機沒有 Esc、Tab、Ctrl、方向鍵。Terminal app 需要額外的 UI 元件提供這些按鍵。&lt;/p>
&lt;h3 id="底部工具列">底部工具列&lt;/h3>
&lt;p>固定在鍵盤上方的一排按鈕，提供常用特殊鍵。app_tunnel 的工具列包含 Esc、Tab、Ctrl、四個方向鍵。&lt;/p>
&lt;p>工具列的設計考量：按鈕大小（手指能精確觸碰的最小尺寸約 44x44 pt）、排列順序（最常用的放中間）、長按行為（長按 Ctrl 是否支援 Ctrl 組合鍵）。&lt;/p>
&lt;h3 id="ctrl-組合鍵">Ctrl 組合鍵&lt;/h3>
&lt;p>Ctrl+C（中斷）、Ctrl+D（EOF）、Ctrl+Z（暫停）在 CLI 操作中頻繁使用。手機上的實作方式通常是：按下 Ctrl 按鈕後進入「Ctrl 模式」，下一個按鍵自動加 Ctrl 前綴。&lt;/p></description><content:encoded><![CDATA[<p>Terminal app 在手機上的輸入需求和一般文字輸入有根本差異。CLI 指令是結構化語法，路徑分隔符、flag 縮寫、管線符號都有精確語意 — 手機鍵盤為自然語言設計的自動行為（校正、建議、學習）在 CLI 場景中全部變成干擾。</p>
<h2 id="cli-輸入的特殊性">CLI 輸入的特殊性</h2>
<p>桌面終端機的鍵盤直接傳送按鍵事件，沒有中間的輸入法處理層。使用者按 <code>l</code> 就是 <code>l</code>，按 Tab 就是 Tab，按 Ctrl+C 就是 interrupt signal。</p>
<p>手機鍵盤在使用者和 app 之間插入了 IME 層。使用者按 <code>l</code> 時，IME 可能等待後續按鍵組合成完整詞彙再傳送；使用者按的按鍵可能被自動校正替換；使用者的輸入被記錄到 IME 詞庫供跨 app 學習。</p>
<p>Terminal app 需要繞過或控制 IME 層的這些行為。app_tunnel 的 TextField 用 <code>TextInputType.visiblePassword</code> + <code>autocorrect: false</code> + <code>enableSuggestions: false</code> + <code>enableIMEPersonalizedLearning: false</code> 四個參數關閉 IME 的自動行為（<a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a>）。</p>
<h2 id="整行送出-vs-逐字元protocol-層的影響">整行送出 vs 逐字元：protocol 層的影響</h2>
<p>整行送出和逐字元送出在 UI 層看起來只是「按 Enter 送出整行」和「每個按鍵即時送出」的差別，但在 protocol 層是兩種不同的通訊模式。</p>
<h3 id="整行送出">整行送出</h3>
<p>Client 端累積使用者輸入，使用者按 Enter 時傳送完整指令字串加換行符（<code>ls -la\n</code>）。Server 端收到完整行後處理。</p>
<p>Protocol 設計簡單：每個 WebSocket frame 是一個完整指令。Server 不需要管理部分輸入的狀態，也不需要即時回應 Tab 或方向鍵。</p>
<p>代價：使用者無法在手機上使用 Tab 補全（Tab 被 IME 攔截或不存在）、無法用方向鍵在指令中移動游標（移動的是 TextField 的游標，不是 server 端的 readline 游標）。</p>
<h3 id="逐字元送出">逐字元送出</h3>
<p>Client 端每個按鍵即時傳送一個 WebSocket frame。Server 端的 shell 即時處理每個字元，包括 Tab 補全（server 回傳補全結果）、Ctrl+C（server 中斷當前程序）、方向鍵（server 端 readline 移動游標）。</p>
<p>Protocol 設計複雜：每個按鍵一個 frame，frame 內容是單一字元或控制序列。Server 端必須維護 readline 狀態。Client 端必須正確編碼控制字元（Ctrl+C = 0x03, Tab = 0x09）。</p>
<p>代價：protocol 複雜度高，每個按鍵都有網路延遲。在高延遲網路上輸入體驗差（打字後要等 round-trip 才看到回顯）。</p>
<h3 id="決策在-protocol-層做">決策在 protocol 層做</h3>
<p>app_tunnel 選擇整行送出，犧牲 Tab 補全換取簡單的 protocol 設計。這個決策應該在 protocol spec 階段做 — 因為它影響 server 端（ttyd）的行為預期和 client 端的 frame 格式。在 UI 實作時才臨時決定，可能和 server 端的行為預期不一致。</p>
<h2 id="特殊按鍵的-ui-方案">特殊按鍵的 UI 方案</h2>
<p>手機沒有 Esc、Tab、Ctrl、方向鍵。Terminal app 需要額外的 UI 元件提供這些按鍵。</p>
<h3 id="底部工具列">底部工具列</h3>
<p>固定在鍵盤上方的一排按鈕，提供常用特殊鍵。app_tunnel 的工具列包含 Esc、Tab、Ctrl、四個方向鍵。</p>
<p>工具列的設計考量：按鈕大小（手指能精確觸碰的最小尺寸約 44x44 pt）、排列順序（最常用的放中間）、長按行為（長按 Ctrl 是否支援 Ctrl 組合鍵）。</p>
<h3 id="ctrl-組合鍵">Ctrl 組合鍵</h3>
<p>Ctrl+C（中斷）、Ctrl+D（EOF）、Ctrl+Z（暫停）在 CLI 操作中頻繁使用。手機上的實作方式通常是：按下 Ctrl 按鈕後進入「Ctrl 模式」，下一個按鍵自動加 Ctrl 前綴。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四維度決策表 → <a href="/blog/ux-design/03-input-mechanism/four-dimension-decision/" data-link-title="輸入機制決策表" data-link-desc="Keyboard type / submit model / IME policy / special keys 四個維度的決策框架 — 每個維度都是設計決策，影響 UI layout 和 protocol">輸入機制決策表</a></li>
<li>安全敏感輸入框的 IME 控制 → <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">IME 安全 checklist</a></li>
<li>表單場景的輸入設計 → <a href="/blog/ux-design/03-input-mechanism/form-ux-pattern/" data-link-title="表單 UX 模式" data-link-desc="表單輸入的驗證時機、auto-fill 支援、錯誤回饋設計 — 和 terminal 輸入的決策維度相同但選項不同">表單 UX 模式</a></li>
</ul>
]]></content:encoded></item><item><title>WebSocket 協議測試實作</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/websocket-protocol-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/websocket-protocol-test/</guid><description>&lt;p>WebSocket 協議測試的目標是驗證 client 端的 WebSocket 操作在真實服務上的行為。這個層級的 test 直接使用 &lt;code>IOWebSocketChannel&lt;/code>（真實實作）連線到真實 ttyd 服務，不用 &lt;code>FakeWebSocketChannel&lt;/code>。&lt;/p>
&lt;h2 id="要驗證什麼">要驗證什麼&lt;/h2>
&lt;p>從 T.C1 和 T.C2 的案例推導出 WebSocket protocol test 至少需要覆蓋的場景：&lt;/p>
&lt;h3 id="frame-type-驗證">Frame type 驗證&lt;/h3>
&lt;p>&lt;code>IOWebSocketChannel&lt;/code> 對 &lt;code>String&lt;/code> 和 &lt;code>Uint8List&lt;/code> 產生不同的 frame type（text vs binary）。ttyd 只接受 text frame，收到 binary frame 靜默忽略（&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>Protocol test 需要驗證：&lt;/p>
&lt;ul>
&lt;li>發送 &lt;code>String&lt;/code> → ttyd 回應（text frame 被處理）&lt;/li>
&lt;li>發送 &lt;code>Uint8List&lt;/code> → ttyd 不回應（binary frame 被忽略）&lt;/li>
&lt;li>確認 &lt;code>sendData()&lt;/code> 函式實際發送的是 text frame&lt;/li>
&lt;/ul>
&lt;h3 id="auth-handshake-驗證">Auth handshake 驗證&lt;/h3>
&lt;p>ttyd 連線後需要發送 auth token JSON frame 完成認證，認證通過後才推送 terminal output（&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>Protocol test 需要驗證：&lt;/p>
&lt;ul>
&lt;li>連線後發送正確的 auth token → 收到 terminal output&lt;/li>
&lt;li>連線後不發送 auth token → 逾時無 output&lt;/li>
&lt;li>連線後發送錯誤的 auth token → 連線被斷開或無 output&lt;/li>
&lt;/ul>
&lt;h3 id="連線生命週期驗證">連線生命週期驗證&lt;/h3>
&lt;p>WebSocket 連線的建立、維持、斷開在 mock 環境中都是瞬間完成的。真實環境中有延遲、可能失敗、可能逾時。&lt;/p>
&lt;p>Protocol test 需要驗證：&lt;/p>
&lt;ul>
&lt;li>連線建立的成功路徑（TCP → WS 升級 → ready）&lt;/li>
&lt;li>連線逾時的行為（server 不可達時 client 的回應）&lt;/li>
&lt;li>連線斷開後的狀態（stream 是否正確關閉）&lt;/li>
&lt;/ul>
&lt;h2 id="test-結構">Test 結構&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">setUp: 啟動本機 ttyd（Process.start(&amp;#39;ttyd&amp;#39;, [&amp;#39;bash&amp;#39;])）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">tearDown: 停止 ttyd（process.kill()）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">test(&amp;#39;text frame is accepted by ttyd&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> channel = IOWebSocketChannel.connect(&amp;#39;ws://localhost:7681/ws&amp;#39;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> await channel.ready
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> channel.sink.add(&amp;#39;{&amp;#34;AuthToken&amp;#34;:&amp;#34;base64(user:pass)&amp;#34;}&amp;#39;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> channel.sink.add(&amp;#39;echo hello&amp;#39;) // String → text frame
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> output = await channel.stream.first.timeout(5s)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> expect(output, contains(&amp;#39;hello&amp;#39;))
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">test(&amp;#39;binary frame is silently ignored by ttyd&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> channel = IOWebSocketChannel.connect(...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> await channel.ready
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> channel.sink.add(&amp;#39;{&amp;#34;AuthToken&amp;#34;:&amp;#34;...&amp;#34;}&amp;#39;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> channel.sink.add(Uint8List.fromList(utf8.encode(&amp;#39;echo hello&amp;#39;)))
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> expect(channel.stream.first.timeout(2s), throwsTimeoutException)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">test(&amp;#39;auth token required before output&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> channel = IOWebSocketChannel.connect(...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> await channel.ready
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> // 不發 auth token，直接發指令
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> channel.sink.add(&amp;#39;echo hello&amp;#39;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> expect(channel.stream.first.timeout(2s), throwsTimeoutException)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="執行成本">執行成本&lt;/h2>
&lt;p>app_tunnel 的 server（ttyd）和 client 在同一台機器上。啟動 ttyd 是一行指令（&lt;code>ttyd bash&lt;/code>），不需要 Docker、不需要雲端服務、不需要網路。整個 test suite 的執行時間主要是連線建立和逾時等待，每個 test case 約 2-5 秒。&lt;/p></description><content:encoded><![CDATA[<p>WebSocket 協議測試的目標是驗證 client 端的 WebSocket 操作在真實服務上的行為。這個層級的 test 直接使用 <code>IOWebSocketChannel</code>（真實實作）連線到真實 ttyd 服務，不用 <code>FakeWebSocketChannel</code>。</p>
<h2 id="要驗證什麼">要驗證什麼</h2>
<p>從 T.C1 和 T.C2 的案例推導出 WebSocket protocol test 至少需要覆蓋的場景：</p>
<h3 id="frame-type-驗證">Frame type 驗證</h3>
<p><code>IOWebSocketChannel</code> 對 <code>String</code> 和 <code>Uint8List</code> 產生不同的 frame type（text vs binary）。ttyd 只接受 text frame，收到 binary frame 靜默忽略（<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>Protocol test 需要驗證：</p>
<ul>
<li>發送 <code>String</code> → ttyd 回應（text frame 被處理）</li>
<li>發送 <code>Uint8List</code> → ttyd 不回應（binary frame 被忽略）</li>
<li>確認 <code>sendData()</code> 函式實際發送的是 text frame</li>
</ul>
<h3 id="auth-handshake-驗證">Auth handshake 驗證</h3>
<p>ttyd 連線後需要發送 auth token JSON frame 完成認證，認證通過後才推送 terminal output（<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>Protocol test 需要驗證：</p>
<ul>
<li>連線後發送正確的 auth token → 收到 terminal output</li>
<li>連線後不發送 auth token → 逾時無 output</li>
<li>連線後發送錯誤的 auth token → 連線被斷開或無 output</li>
</ul>
<h3 id="連線生命週期驗證">連線生命週期驗證</h3>
<p>WebSocket 連線的建立、維持、斷開在 mock 環境中都是瞬間完成的。真實環境中有延遲、可能失敗、可能逾時。</p>
<p>Protocol test 需要驗證：</p>
<ul>
<li>連線建立的成功路徑（TCP → WS 升級 → ready）</li>
<li>連線逾時的行為（server 不可達時 client 的回應）</li>
<li>連線斷開後的狀態（stream 是否正確關閉）</li>
</ul>
<h2 id="test-結構">Test 結構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">setUp: 啟動本機 ttyd（Process.start(&#39;ttyd&#39;, [&#39;bash&#39;])）
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">tearDown: 停止 ttyd（process.kill()）
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">test(&#39;text frame is accepted by ttyd&#39;):
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  channel = IOWebSocketChannel.connect(&#39;ws://localhost:7681/ws&#39;)
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  await channel.ready
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  channel.sink.add(&#39;{&#34;AuthToken&#34;:&#34;base64(user:pass)&#34;}&#39;)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  channel.sink.add(&#39;echo hello&#39;)  // String → text frame
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  output = await channel.stream.first.timeout(5s)
</span></span><span class="line"><span class="ln">10</span><span class="cl">  expect(output, contains(&#39;hello&#39;))
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">test(&#39;binary frame is silently ignored by ttyd&#39;):
</span></span><span class="line"><span class="ln">13</span><span class="cl">  channel = IOWebSocketChannel.connect(...)
</span></span><span class="line"><span class="ln">14</span><span class="cl">  await channel.ready
</span></span><span class="line"><span class="ln">15</span><span class="cl">  channel.sink.add(&#39;{&#34;AuthToken&#34;:&#34;...&#34;}&#39;)
</span></span><span class="line"><span class="ln">16</span><span class="cl">  channel.sink.add(Uint8List.fromList(utf8.encode(&#39;echo hello&#39;)))
</span></span><span class="line"><span class="ln">17</span><span class="cl">  expect(channel.stream.first.timeout(2s), throwsTimeoutException)
</span></span><span class="line"><span class="ln">18</span><span class="cl">
</span></span><span class="line"><span class="ln">19</span><span class="cl">test(&#39;auth token required before output&#39;):
</span></span><span class="line"><span class="ln">20</span><span class="cl">  channel = IOWebSocketChannel.connect(...)
</span></span><span class="line"><span class="ln">21</span><span class="cl">  await channel.ready
</span></span><span class="line"><span class="ln">22</span><span class="cl">  // 不發 auth token，直接發指令
</span></span><span class="line"><span class="ln">23</span><span class="cl">  channel.sink.add(&#39;echo hello&#39;)
</span></span><span class="line"><span class="ln">24</span><span class="cl">  expect(channel.stream.first.timeout(2s), throwsTimeoutException)</span></span></code></pre></div><h2 id="執行成本">執行成本</h2>
<p>app_tunnel 的 server（ttyd）和 client 在同一台機器上。啟動 ttyd 是一行指令（<code>ttyd bash</code>），不需要 Docker、不需要雲端服務、不需要網路。整個 test suite 的執行時間主要是連線建立和逾時等待，每個 test case 約 2-5 秒。</p>
<p>這個低成本是自用工具的結構優勢 — server 可以在 test 的 setUp 中啟動、tearDown 中停止，不需要共享的 test 環境（本章合成，TF-8 Derive）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>HTTP 的 contract test 設計 → <a href="/blog/testing/03-protocol-integration-test/http-contract-test/" data-link-title="HTTP contract test 設計" data-link-desc="HTTP REST API 的 protocol integration test — request/response 格式、status code 語意、error body 結構的驗證">HTTP contract test 設計</a></li>
<li>CI 中的服務管理 → <a href="/blog/testing/03-protocol-integration-test/service-fixture-management/" data-link-title="CI 中的服務 fixture 管理" data-link-desc="在 CI 中啟動和停止真實服務的 test harness 設計 — Process.start / Docker / testcontainers 三種方案的適用場景">CI 中的服務 fixture 管理</a></li>
<li>什麼時候值得寫 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/cost-judgment/" data-link-title="成本判斷表" data-link-desc="什麼時候值得寫 protocol integration test、什麼時候用 contract test 或實機測試替代 — 根據服務啟動成本和協議複雜度判斷">成本判斷表</a></li>
</ul>
]]></content:encoded></item><item><title>模組三：協議整合測試</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/</guid><description>&lt;p>回答「我的 client 跟真實服務的互動是否正確」。這一層的關鍵是不用 mock，直接連真實服務。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>TF-8&lt;/td>
 &lt;td>&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;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;/td>
 &lt;td>自用工具 server+client 同機 → protocol integration test 成本極低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Protocol integration test 定義（跟 unit test / E2E 的邊界）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> WebSocket 協議測試實作（對真實 ttyd 驗證 frame type + auth handshake）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> HTTP contract test 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> CI 中的服務 fixture 管理（啟動/停止真實服務的 test harness）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 成本判斷表：什麼時候值得、什麼時候用 contract test 替代&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/" data-link-title="模組三：SDK 設計模式" data-link-desc="跨平台 SDK 的自動攔截、手動上報、攢批送出、離線 buffer 設計">monitoring 模組三 SDK 設計&lt;/a>：SDK 的 transport 行為也需要 protocol test&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">ux-design 模組三 輸入機制&lt;/a>：輸入設計（整行 vs 逐字元）影響 protocol test 的斷言&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「我的 client 跟真實服務的互動是否正確」。這一層的關鍵是不用 mock，直接連真實服務。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TF-8</td>
          <td><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> + <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></td>
          <td>自用工具 server+client 同機 → protocol integration test 成本極低</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Protocol integration test 定義（跟 unit test / E2E 的邊界）</li>
<li><input checked="" disabled="" type="checkbox"> WebSocket 協議測試實作（對真實 ttyd 驗證 frame type + auth handshake）</li>
<li><input checked="" disabled="" type="checkbox"> HTTP contract test 設計</li>
<li><input checked="" disabled="" type="checkbox"> CI 中的服務 fixture 管理（啟動/停止真實服務的 test harness）</li>
<li><input checked="" disabled="" type="checkbox"> 成本判斷表：什麼時候值得、什麼時候用 contract test 替代</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/03-sdk-design/" data-link-title="模組三：SDK 設計模式" data-link-desc="跨平台 SDK 的自動攔截、手動上報、攢批送出、離線 buffer 設計">monitoring 模組三 SDK 設計</a>：SDK 的 transport 行為也需要 protocol test</li>
<li>← <a href="/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">ux-design 模組三 輸入機制</a>：輸入設計（整行 vs 逐字元）影響 protocol test 的斷言</li>
</ul>
]]></content:encoded></item><item><title>成本判斷表</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/cost-judgment/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/cost-judgment/</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;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽&lt;/a>的協議層盲區。但它有建置成本（服務 fixture 管理）和維護成本（服務更新時 test 要跟著改）。判斷是否值得投資，依據的是兩個維度：服務啟動成本和協議複雜度。&lt;/p>
&lt;h2 id="服務啟動成本">服務啟動成本&lt;/h2>
&lt;p>服務啟動成本決定了 protocol integration test 的執行成本 — test 跑一次要多久、CI 中佔多少時間。&lt;/p>
&lt;h3 id="極低成本同機單程序">極低成本（同機單程序）&lt;/h3>
&lt;p>Server 是一個本機程序，&lt;code>Process.start&lt;/code> 一行啟動，不需要 Docker、不需要網路、不需要設定檔。啟動到 ready 不到 2 秒。&lt;/p>
&lt;p>app_tunnel 的 ttyd 就是這個場景。&lt;code>ttyd bash&lt;/code> 在本機啟動，WebSocket 服務立即可用。整個 protocol integration test suite 的額外成本約 10-15 秒（包含啟動、健康檢查、5 個 test 各 2 秒）（本章合成，TF-8 Derive）。&lt;/p>
&lt;p>在這個成本等級下，protocol integration test 幾乎沒有理由不寫。&lt;/p>
&lt;h3 id="低成本docker-單容器">低成本（Docker 單容器）&lt;/h3>
&lt;p>Server 用 Docker 容器啟動，需要 pull image（首次或更新時），啟動到 ready 約 5-30 秒。Redis、PostgreSQL、Elasticsearch 等 open source 服務屬於這個等級。&lt;/p>
&lt;p>CI 中用 image cache 可以把 pull 時間降到接近零。但容器啟動時間仍比原生程序長。整個 protocol integration test suite 的額外成本約 30-60 秒。&lt;/p>
&lt;p>在這個成本等級下，如果協議有任何複雜度（見下方），protocol integration test 值得寫。&lt;/p>
&lt;h3 id="中等成本多容器堆疊">中等成本（多容器堆疊）&lt;/h3>
&lt;p>Server 依賴多個服務（app server + database + cache + message queue），需要 Docker Compose 管理。啟動到所有服務 ready 約 30-120 秒。&lt;/p>
&lt;p>Protocol integration test 的執行成本顯著上升。適合在 CI 的獨立 stage 跑（和 unit test 分開），避免拖慢 fast feedback loop。&lt;/p>
&lt;h3 id="高成本外部服務--saas">高成本（外部服務 / SaaS）&lt;/h3>
&lt;p>Server 是外部 SaaS（Stripe API、AWS S3、第三方 OAuth provider），無法本地啟動。Test 需要打到 sandbox environment，有速率限制和網路延遲。&lt;/p>
&lt;p>在這個成本等級下，consumer-driven contract test 可能比 protocol integration test 更實用 — 用 contract 定義期望的 request/response，在本地驗證 client 端行為，不需要每次都打到外部服務。&lt;/p>
&lt;h2 id="協議複雜度">協議複雜度&lt;/h2>
&lt;p>協議複雜度決定了 mock 遮蔽的風險大小 — 風險越大，protocol integration test 的價值越高。&lt;/p>
&lt;p>&lt;strong>高複雜度&lt;/strong>：WebSocket（frame type、handshake、子協議）、gRPC（streaming、deadline、metadata）、MQTT（QoS level、retain、will message）。API 簽名隱藏了協議層的行為分支，mock 結構性地無法覆蓋。&lt;/p>
&lt;p>&lt;strong>中複雜度&lt;/strong>：HTTP REST API（多種 status code、error body 格式、認證流程、分頁）。核心語意（JSON request/response）差距小，但 edge case（error response 格式、header 要求）仍可能被 mock 遮蔽。&lt;/p>
&lt;p>&lt;strong>低複雜度&lt;/strong>：本地 IPC（Unix socket、named pipe）、標準格式的檔案讀寫。協議行為簡單，mock 和真實行為差距小。&lt;/p>
&lt;h2 id="判斷矩陣">判斷矩陣&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>服務啟動成本&lt;/th>
 &lt;th>協議複雜度高&lt;/th>
 &lt;th>協議複雜度中&lt;/th>
 &lt;th>協議複雜度低&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>極低&lt;/td>
 &lt;td>protocol test&lt;/td>
 &lt;td>protocol test&lt;/td>
 &lt;td>protocol test&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>低&lt;/td>
 &lt;td>protocol test&lt;/td>
 &lt;td>protocol test&lt;/td>
 &lt;td>可選&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>中&lt;/td>
 &lt;td>protocol test&lt;/td>
 &lt;td>視 mock 寬鬆度決定&lt;/td>
 &lt;td>實機測試替代&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高&lt;/td>
 &lt;td>contract test + 實機&lt;/td>
 &lt;td>contract test&lt;/td>
 &lt;td>實機測試替代&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「可選」代表 protocol integration test 有價值但不是必要 — 實機測試階段的手動驗證可能足夠。「實機測試替代」代表成本太高或收益太低，依賴實機測試階段的人工驗證。&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> 的價值在於用自動化方式驗證 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>的協議層盲區。但它有建置成本（服務 fixture 管理）和維護成本（服務更新時 test 要跟著改）。判斷是否值得投資，依據的是兩個維度：服務啟動成本和協議複雜度。</p>
<h2 id="服務啟動成本">服務啟動成本</h2>
<p>服務啟動成本決定了 protocol integration test 的執行成本 — test 跑一次要多久、CI 中佔多少時間。</p>
<h3 id="極低成本同機單程序">極低成本（同機單程序）</h3>
<p>Server 是一個本機程序，<code>Process.start</code> 一行啟動，不需要 Docker、不需要網路、不需要設定檔。啟動到 ready 不到 2 秒。</p>
<p>app_tunnel 的 ttyd 就是這個場景。<code>ttyd bash</code> 在本機啟動，WebSocket 服務立即可用。整個 protocol integration test suite 的額外成本約 10-15 秒（包含啟動、健康檢查、5 個 test 各 2 秒）（本章合成，TF-8 Derive）。</p>
<p>在這個成本等級下，protocol integration test 幾乎沒有理由不寫。</p>
<h3 id="低成本docker-單容器">低成本（Docker 單容器）</h3>
<p>Server 用 Docker 容器啟動，需要 pull image（首次或更新時），啟動到 ready 約 5-30 秒。Redis、PostgreSQL、Elasticsearch 等 open source 服務屬於這個等級。</p>
<p>CI 中用 image cache 可以把 pull 時間降到接近零。但容器啟動時間仍比原生程序長。整個 protocol integration test suite 的額外成本約 30-60 秒。</p>
<p>在這個成本等級下，如果協議有任何複雜度（見下方），protocol integration test 值得寫。</p>
<h3 id="中等成本多容器堆疊">中等成本（多容器堆疊）</h3>
<p>Server 依賴多個服務（app server + database + cache + message queue），需要 Docker Compose 管理。啟動到所有服務 ready 約 30-120 秒。</p>
<p>Protocol integration test 的執行成本顯著上升。適合在 CI 的獨立 stage 跑（和 unit test 分開），避免拖慢 fast feedback loop。</p>
<h3 id="高成本外部服務--saas">高成本（外部服務 / SaaS）</h3>
<p>Server 是外部 SaaS（Stripe API、AWS S3、第三方 OAuth provider），無法本地啟動。Test 需要打到 sandbox environment，有速率限制和網路延遲。</p>
<p>在這個成本等級下，consumer-driven contract test 可能比 protocol integration test 更實用 — 用 contract 定義期望的 request/response，在本地驗證 client 端行為，不需要每次都打到外部服務。</p>
<h2 id="協議複雜度">協議複雜度</h2>
<p>協議複雜度決定了 mock 遮蔽的風險大小 — 風險越大，protocol integration test 的價值越高。</p>
<p><strong>高複雜度</strong>：WebSocket（frame type、handshake、子協議）、gRPC（streaming、deadline、metadata）、MQTT（QoS level、retain、will message）。API 簽名隱藏了協議層的行為分支，mock 結構性地無法覆蓋。</p>
<p><strong>中複雜度</strong>：HTTP REST API（多種 status code、error body 格式、認證流程、分頁）。核心語意（JSON request/response）差距小，但 edge case（error response 格式、header 要求）仍可能被 mock 遮蔽。</p>
<p><strong>低複雜度</strong>：本地 IPC（Unix socket、named pipe）、標準格式的檔案讀寫。協議行為簡單，mock 和真實行為差距小。</p>
<h2 id="判斷矩陣">判斷矩陣</h2>
<table>
  <thead>
      <tr>
          <th>服務啟動成本</th>
          <th>協議複雜度高</th>
          <th>協議複雜度中</th>
          <th>協議複雜度低</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>極低</td>
          <td>protocol test</td>
          <td>protocol test</td>
          <td>protocol test</td>
      </tr>
      <tr>
          <td>低</td>
          <td>protocol test</td>
          <td>protocol test</td>
          <td>可選</td>
      </tr>
      <tr>
          <td>中</td>
          <td>protocol test</td>
          <td>視 mock 寬鬆度決定</td>
          <td>實機測試替代</td>
      </tr>
      <tr>
          <td>高</td>
          <td>contract test + 實機</td>
          <td>contract test</td>
          <td>實機測試替代</td>
      </tr>
  </tbody>
</table>
<p>「可選」代表 protocol integration test 有價值但不是必要 — 實機測試階段的手動驗證可能足夠。「實機測試替代」代表成本太高或收益太低，依賴實機測試階段的人工驗證。</p>
<p>成本和複雜度的評估結果決定了要建什麼等級的 test 基礎設施。<a href="/blog/testing/03-protocol-integration-test/definition-and-boundary/" data-link-title="Protocol integration test 定義" data-link-desc="Protocol integration test 和 unit test / E2E test 的邊界 — 驗證程式碼和真實服務的協議契約，不驗證 UI 也不用 mock">Protocol integration test 定義</a>提供這一層 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 寬鬆度、失敗靜默度三個維度">testing 模組一的判斷原則</a>從 mock 遮蔽角度補充另一個判斷維度。決定要建之後，<a href="/blog/testing/03-protocol-integration-test/service-fixture-management/" data-link-title="CI 中的服務 fixture 管理" data-link-desc="在 CI 中啟動和停止真實服務的 test harness 設計 — Process.start / Docker / testcontainers 三種方案的適用場景">CI 中的服務 fixture 管理</a>處理啟動和停止真實服務的工程問題。</p>
]]></content:encoded></item></channel></rss>