<?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-Integration on Tarragon</title><link>https://tarrragon.github.io/blog/tags/protocol-integration/</link><description>Recent content in Protocol-Integration 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-integration/index.xml" rel="self" type="application/rss+xml"/><item><title>T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 &lt;code>sink.add(dynamic)&lt;/code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。&lt;code>ConnectionManager.sendData()&lt;/code> 接收 &lt;code>Uint8List&lt;/code> 型別的鍵盤輸入，直接傳給 &lt;code>_channel!.sink.add(data)&lt;/code>。Dart 的 &lt;code>IOWebSocketChannel&lt;/code> 對 &lt;code>Uint8List&lt;/code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>所有鍵盤輸入無效（使用者打字無回應）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>192 個全過（&lt;code>FakeWebSocketChannel.sink.add&lt;/code> 不區分型別）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功但終端機完全無反應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 忠實實作了 &lt;code>WebSocketChannel&lt;/code> 的 Dart interface — &lt;code>sink.add(dynamic)&lt;/code> 接受任何型別。但 &lt;code>IOWebSocketChannel&lt;/code> 的 &lt;code>sink.add&lt;/code> 實際行為是：&lt;code>String&lt;/code> → text frame，&lt;code>List&amp;lt;int&amp;gt;&lt;/code> / &lt;code>Uint8List&lt;/code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>ttyd 的靜默忽略放大了問題&lt;/strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>型別系統幫不上忙&lt;/strong>。Dart 的 &lt;code>WebSocketSink.add&lt;/code> 簽名是 &lt;code>void add(dynamic event)&lt;/code> — &lt;code>dynamic&lt;/code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 &lt;code>dynamic&lt;/code>，型別檢查無法區分協議語意。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test&lt;/strong>：對真實 ttyd 發送 &lt;code>Uint8List&lt;/code> 和 &lt;code>String&lt;/code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。&lt;/li>
&lt;li>&lt;strong>在 sendData 層做型別轉換&lt;/strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。&lt;/li>
&lt;li>&lt;strong>Log 送出的 frame type&lt;/strong>：&lt;code>developer.log('WS send: type=${data.runtimeType}')&lt;/code> 讓 debug 時立即可見。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想寫 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想理解 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;/li>
&lt;li>類似案例（auth handshake） → &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 Auth handshake 缺失&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 <code>sink.add(dynamic)</code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。<code>ConnectionManager.sendData()</code> 接收 <code>Uint8List</code> 型別的鍵盤輸入，直接傳給 <code>_channel!.sink.add(data)</code>。Dart 的 <code>IOWebSocketChannel</code> 對 <code>Uint8List</code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>所有鍵盤輸入無效（使用者打字無回應）</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>192 個全過（<code>FakeWebSocketChannel.sink.add</code> 不區分型別）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功但終端機完全無反應</td>
      </tr>
      <tr>
          <td>修復</td>
          <td><code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code></td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約</strong>。<code>FakeWebSocketChannel</code> 忠實實作了 <code>WebSocketChannel</code> 的 Dart interface — <code>sink.add(dynamic)</code> 接受任何型別。但 <code>IOWebSocketChannel</code> 的 <code>sink.add</code> 實際行為是：<code>String</code> → text frame，<code>List&lt;int&gt;</code> / <code>Uint8List</code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。</p>
</li>
<li>
<p><strong>ttyd 的靜默忽略放大了問題</strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。</p>
</li>
<li>
<p><strong>型別系統幫不上忙</strong>。Dart 的 <code>WebSocketSink.add</code> 簽名是 <code>void add(dynamic event)</code> — <code>dynamic</code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 <code>dynamic</code>，型別檢查無法區分協議語意。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test</strong>：對真實 ttyd 發送 <code>Uint8List</code> 和 <code>String</code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。</li>
<li><strong>在 sendData 層做型別轉換</strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。</li>
<li><strong>Log 送出的 frame type</strong>：<code>developer.log('WS send: type=${data.runtimeType}')</code> 讓 debug 時立即可見。</li>
</ol>
<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>類似案例（auth handshake） → <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 Auth handshake 缺失</a></li>
</ul>
]]></content:encoded></item><item><title>T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（&lt;code>{&amp;quot;AuthToken&amp;quot;:&amp;quot;base64(user:pass)&amp;quot;}&lt;/code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 &lt;code>ConnectionManager&lt;/code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>10 個 ConnectionManager test 全過（&lt;code>FakeWebSocketChannel.ready&lt;/code> 立即完成）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Integration test 結果&lt;/td>
 &lt;td>11 個 connection_flow_test 全過（同樣用 &lt;code>FakeWebSocketChannel&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功，終端機空白無輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>新增 &lt;code>_sendAuthTokenIfNeeded()&lt;/code> 在 &lt;code>_establishWebSocket()&lt;/code> 內呼叫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 的 happy path 比真實服務寬鬆&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>ready&lt;/code> 是 &lt;code>Future.value()&lt;/code>（立即完成），&lt;code>stream&lt;/code> 是開發者手動控制的 &lt;code>StreamController&lt;/code>。真實 ttyd 的行為是：&lt;code>ready&lt;/code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Integration test 名為整合實為 fake&lt;/strong>。&lt;code>connection_flow_test.dart&lt;/code> 標題是「端對端整合測試」，但內部使用 &lt;code>FakeWebSocketChannel&lt;/code> + &lt;code>FakeBiometricService&lt;/code> + &lt;code>InMemoryCredentialRepository&lt;/code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>功能缺失比功能錯誤更難被 test 抓到&lt;/strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test 必須涵蓋 auth handshake&lt;/strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。&lt;/li>
&lt;li>&lt;strong>在企劃階段列出協議握手步驟&lt;/strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。&lt;/li>
&lt;li>&lt;strong>區分「名義 integration」和「真實 integration」&lt;/strong>：test 名稱含 integration 但全用 fake，應標明 &lt;code>fake-integration&lt;/code> 或改名 &lt;code>connection-state-machine-test&lt;/code>。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想區分 mock 層級 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層&lt;/a>&lt;/li>
&lt;li>想建 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想設計 auth 機制的 UX fallback → &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。</p>
<h2 id="觀察">觀察</h2>
<p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（<code>{&quot;AuthToken&quot;:&quot;base64(user:pass)&quot;}</code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 <code>ConnectionManager</code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>10 個 ConnectionManager test 全過（<code>FakeWebSocketChannel.ready</code> 立即完成）</td>
      </tr>
      <tr>
          <td>Integration test 結果</td>
          <td>11 個 connection_flow_test 全過（同樣用 <code>FakeWebSocketChannel</code>）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功，終端機空白無輸出</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>新增 <code>_sendAuthTokenIfNeeded()</code> 在 <code>_establishWebSocket()</code> 內呼叫</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 的 happy path 比真實服務寬鬆</strong>。<code>FakeWebSocketChannel</code> 的 <code>ready</code> 是 <code>Future.value()</code>（立即完成），<code>stream</code> 是開發者手動控制的 <code>StreamController</code>。真實 ttyd 的行為是：<code>ready</code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。</p>
</li>
<li>
<p><strong>Integration test 名為整合實為 fake</strong>。<code>connection_flow_test.dart</code> 標題是「端對端整合測試」，但內部使用 <code>FakeWebSocketChannel</code> + <code>FakeBiometricService</code> + <code>InMemoryCredentialRepository</code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。</p>
</li>
<li>
<p><strong>功能缺失比功能錯誤更難被 test 抓到</strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test 必須涵蓋 auth handshake</strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。</li>
<li><strong>在企劃階段列出協議握手步驟</strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。</li>
<li><strong>區分「名義 integration」和「真實 integration」</strong>：test 名稱含 integration 但全用 fake，應標明 <code>fake-integration</code> 或改名 <code>connection-state-machine-test</code>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想區分 mock 層級 → <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層</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>
<li>想設計 auth 機制的 UX fallback → <a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback</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></channel></rss>