<?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>Integration-Test on Tarragon</title><link>https://tarrragon.github.io/blog/tags/integration-test/</link><description>Recent content in Integration-Test 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/integration-test/index.xml" rel="self" type="application/rss+xml"/><item><title>Mock 邊界判斷決策表</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/mock-boundary-decision/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/mock-boundary-decision/</guid><description>&lt;p>Mock 的適用範圍由它模擬的層級決定。Mock 忠實模擬 API 層的契約（方法簽名、參數型別），但無法模擬協議層的語意差異和環境層的行為差異。判斷「這個 test 用 mock 夠不夠」的依據是：test 要驗證的行為發生在哪一層。&lt;/p>
&lt;h2 id="決策依據">決策依據&lt;/h2>
&lt;h3 id="mock-夠用的場景">Mock 夠用的場景&lt;/h3>
&lt;p>Test 驗證的行為完全在程式碼內部 — 函式邏輯、狀態機轉換、資料轉換、錯誤處理分支。這些行為不依賴外部服務的協議細節，mock 提供的 API 層模擬已經足夠。&lt;/p>
&lt;p>判斷問題：&lt;strong>如果把 mock 替換成真實服務，test 的斷言結果會不會改變？&lt;/strong> 如果不會改變，mock 夠用。&lt;/p>
&lt;p>例：&lt;code>ConnectionManager&lt;/code> 收到 error 後是否正確切換到 error 狀態 — 不管 error 來自 mock 還是真實 WebSocket，狀態機邏輯相同。Mock 夠用。&lt;/p>
&lt;h3 id="mock-不夠的場景">Mock 不夠的場景&lt;/h3>
&lt;p>Test 要驗證的行為涉及外部服務的協議行為 — frame type 差異、認證流程、編碼格式、逾時行為。Mock 的 API 層模擬跳過了這些行為，test 通過不代表真實互動也通過。&lt;/p>
&lt;p>判斷問題：&lt;strong>Mock 跳過了外部服務的哪些步驟？這些步驟的行為是否影響 test 要驗證的結果？&lt;/strong> 如果是，需要 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 之間的一層">testing 模組三&lt;/a>）。&lt;/p>
&lt;p>例：&lt;code>sendData()&lt;/code> 發送鍵盤輸入 — mock 的 &lt;code>sink.add(dynamic)&lt;/code> 接受任何型別，但真實 &lt;code>IOWebSocketChannel&lt;/code> 對 &lt;code>String&lt;/code> 和 &lt;code>Uint8List&lt;/code> 產生不同 frame type。Mock 不夠。&lt;/p>
&lt;h2 id="決策表">決策表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>驗證對象&lt;/th>
 &lt;th>Mock 夠用？&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &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;/tr>
 &lt;tr>
 &lt;td>錯誤處理分支&lt;/td>
 &lt;td>夠&lt;/td>
 &lt;td>error 來源不影響處理邏輯&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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;/tr>
 &lt;tr>
 &lt;td>認證流程完整性&lt;/td>
 &lt;td>不夠&lt;/td>
 &lt;td>mock 可能跳過認證步驟&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>資料編碼格式&lt;/td>
 &lt;td>不夠&lt;/td>
 &lt;td>mock 不區分編碼差異（text vs binary）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>逾時行為&lt;/td>
 &lt;td>不夠&lt;/td>
 &lt;td>mock 的回應時間和真實服務不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>多步驟協議流程&lt;/td>
 &lt;td>不夠&lt;/td>
 &lt;td>mock 可能簡化多步驟為單步&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>並行/競爭條件&lt;/td>
 &lt;td>不夠&lt;/td>
 &lt;td>mock 通常同步回應，無法模擬真實的並行行為&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="灰色地帶的判斷">灰色地帶的判斷&lt;/h2>
&lt;p>有些 test 介於「mock 夠用」和「mock 不夠」之間。例如驗證「連線失敗時顯示 error 訊息」— 觸發失敗的方式可以是 mock 回傳 error（驗證顯示邏輯），也可以是真實服務拒絕連線（驗證真實失敗場景的處理）。&lt;/p>
&lt;p>灰色地帶的判斷策略是：用 mock test 驗證「收到 error 後的處理邏輯」，用 protocol integration test 驗證「真實服務在什麼情況下回傳 error」。兩層 test 各自回答不同問題，不互相替代（&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 各層職責、驗證目標與盲區的完整論述">testing 模組一 三層定義&lt;/a>）。&lt;/p>
&lt;p>Mock 邊界確定後，另一個影響 test 有效性的因素是&lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 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 無法也不應該模擬協議行為">testing 模組一 Mock 遮蔽機制分析&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 或實機測試替代 — 根據服務啟動成本和協議複雜度判斷">testing 模組三 成本判斷表&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Mock 的適用範圍由它模擬的層級決定。Mock 忠實模擬 API 層的契約（方法簽名、參數型別），但無法模擬協議層的語意差異和環境層的行為差異。判斷「這個 test 用 mock 夠不夠」的依據是：test 要驗證的行為發生在哪一層。</p>
<h2 id="決策依據">決策依據</h2>
<h3 id="mock-夠用的場景">Mock 夠用的場景</h3>
<p>Test 驗證的行為完全在程式碼內部 — 函式邏輯、狀態機轉換、資料轉換、錯誤處理分支。這些行為不依賴外部服務的協議細節，mock 提供的 API 層模擬已經足夠。</p>
<p>判斷問題：<strong>如果把 mock 替換成真實服務，test 的斷言結果會不會改變？</strong> 如果不會改變，mock 夠用。</p>
<p>例：<code>ConnectionManager</code> 收到 error 後是否正確切換到 error 狀態 — 不管 error 來自 mock 還是真實 WebSocket，狀態機邏輯相同。Mock 夠用。</p>
<h3 id="mock-不夠的場景">Mock 不夠的場景</h3>
<p>Test 要驗證的行為涉及外部服務的協議行為 — frame type 差異、認證流程、編碼格式、逾時行為。Mock 的 API 層模擬跳過了這些行為，test 通過不代表真實互動也通過。</p>
<p>判斷問題：<strong>Mock 跳過了外部服務的哪些步驟？這些步驟的行為是否影響 test 要驗證的結果？</strong> 如果是，需要 protocol integration test（<a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三</a>）。</p>
<p>例：<code>sendData()</code> 發送鍵盤輸入 — mock 的 <code>sink.add(dynamic)</code> 接受任何型別，但真實 <code>IOWebSocketChannel</code> 對 <code>String</code> 和 <code>Uint8List</code> 產生不同 frame type。Mock 不夠。</p>
<h2 id="決策表">決策表</h2>
<table>
  <thead>
      <tr>
          <th>驗證對象</th>
          <th>Mock 夠用？</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>函式回傳值</td>
          <td>夠</td>
          <td>回傳值只依賴程式碼邏輯</td>
      </tr>
      <tr>
          <td>狀態機轉換</td>
          <td>夠</td>
          <td>轉換邏輯在程式碼內部</td>
      </tr>
      <tr>
          <td>錯誤處理分支</td>
          <td>夠</td>
          <td>error 來源不影響處理邏輯</td>
      </tr>
      <tr>
          <td>資料格式轉換</td>
          <td>夠</td>
          <td>轉換邏輯在程式碼內部</td>
      </tr>
      <tr>
          <td>連線建立成功/失敗</td>
          <td>視情況</td>
          <td>如果只驗證「收到成功/失敗後做什麼」→ 夠</td>
      </tr>
      <tr>
          <td>認證流程完整性</td>
          <td>不夠</td>
          <td>mock 可能跳過認證步驟</td>
      </tr>
      <tr>
          <td>資料編碼格式</td>
          <td>不夠</td>
          <td>mock 不區分編碼差異（text vs binary）</td>
      </tr>
      <tr>
          <td>逾時行為</td>
          <td>不夠</td>
          <td>mock 的回應時間和真實服務不同</td>
      </tr>
      <tr>
          <td>多步驟協議流程</td>
          <td>不夠</td>
          <td>mock 可能簡化多步驟為單步</td>
      </tr>
      <tr>
          <td>並行/競爭條件</td>
          <td>不夠</td>
          <td>mock 通常同步回應，無法模擬真實的並行行為</td>
      </tr>
  </tbody>
</table>
<h2 id="灰色地帶的判斷">灰色地帶的判斷</h2>
<p>有些 test 介於「mock 夠用」和「mock 不夠」之間。例如驗證「連線失敗時顯示 error 訊息」— 觸發失敗的方式可以是 mock 回傳 error（驗證顯示邏輯），也可以是真實服務拒絕連線（驗證真實失敗場景的處理）。</p>
<p>灰色地帶的判斷策略是：用 mock test 驗證「收到 error 後的處理邏輯」，用 protocol integration test 驗證「真實服務在什麼情況下回傳 error」。兩層 test 各自回答不同問題，不互相替代（<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 各層職責、驗證目標與盲區的完整論述">testing 模組一 三層定義</a>）。</p>
<p>Mock 邊界確定後，另一個影響 test 有效性的因素是<a href="/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">測試資料的代表性</a> — 測試輸入能否反映真實環境。Mock 遮蔽的結構性原因在 <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">testing 模組一 Mock 遮蔽機制分析</a>中完整展開，判定需要真實服務後的成本評估見 <a href="/blog/testing/03-protocol-integration-test/cost-judgment/" data-link-title="成本判斷表" data-link-desc="什麼時候值得寫 protocol integration test、什麼時候用 contract test 或實機測試替代 — 根據服務啟動成本和協議複雜度判斷">testing 模組三 成本判斷表</a>。</p>
]]></content:encoded></item><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>三層定義與職責表</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>模組一：測試策略分層</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/</guid><description>&lt;p>回答「什麼測試抓什麼問題」。三層測試各自有明確的職責和盲區。192 個 mock test 全過但實機全壞的根因在層級缺失，不在數量不足。&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-1&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;/td>
 &lt;td>mock 模擬 API 層不模擬協議層 — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-2&lt;/td>
 &lt;td>&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>mock happy path 比真實服務寬鬆 → 功能缺失不可見&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-3&lt;/td>
 &lt;td>&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>「名義 integration」全用 fake → 驗證內部狀態機非真實互動&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 三層定義與職責表（從 _index.md 的表格擴展為完整論述）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Mock 遮蔽機制分析（API 層 vs 協議層 vs 環境層的斷裂點）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 「名義 integration test」的識別與修正&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 判斷原則：什麼時候需要 protocol integration test（決策表）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 反模式：用 mock 數量彌補 mock 盲區&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 的自動攔截機制影響哪些錯誤能被 test 覆蓋&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機&lt;/a>：狀態矩陣直接轉成 screen state test case&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/" data-link-title="模組二：Gate 與 Fallback 設計" data-link-desc="Biometric / Network / Auth / Permission — 每個 gate 成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">ux-design 模組二 Gate Fallback&lt;/a>：開發環境遮蔽 gate 問題的機制和 mock 遮蔽結構相同&lt;/li>
&lt;li>← work-log 案例入口：&lt;a href="https://tarrragon.github.io/blog/work-log/192-%E5%80%8B%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E5%AF%A6%E6%A9%9F%E5%85%A8%E5%A3%9Emock-%E9%81%AE%E8%94%BD%E7%9C%9F%E5%AF%A6%E8%A1%8C%E7%82%BA%E7%9A%84%E4%B8%89%E5%B1%A4%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「什麼測試抓什麼問題」。三層測試各自有明確的職責和盲區。192 個 mock test 全過但實機全壞的根因在層級缺失，不在數量不足。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TF-1</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></td>
          <td>mock 模擬 API 層不模擬協議層 — <strong>本模組主寫</strong></td>
      </tr>
      <tr>
          <td>TF-2</td>
          <td><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>mock happy path 比真實服務寬鬆 → 功能缺失不可見</td>
      </tr>
      <tr>
          <td>TF-3</td>
          <td><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>「名義 integration」全用 fake → 驗證內部狀態機非真實互動</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 三層定義與職責表（從 _index.md 的表格擴展為完整論述）</li>
<li><input checked="" disabled="" type="checkbox"> Mock 遮蔽機制分析（API 層 vs 協議層 vs 環境層的斷裂點）</li>
<li><input checked="" disabled="" type="checkbox"> 「名義 integration test」的識別與修正</li>
<li><input checked="" disabled="" type="checkbox"> 判斷原則：什麼時候需要 protocol integration test（決策表）</li>
<li><input checked="" disabled="" type="checkbox"> 反模式：用 mock 數量彌補 mock 盲區</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 的自動攔截機制影響哪些錯誤能被 test 覆蓋</li>
<li>→ <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a>：狀態矩陣直接轉成 screen state test case</li>
<li>← <a href="/blog/ux-design/02-gate-fallback/" data-link-title="模組二：Gate 與 Fallback 設計" data-link-desc="Biometric / Network / Auth / Permission — 每個 gate 成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">ux-design 模組二 Gate Fallback</a>：開發環境遮蔽 gate 問題的機制和 mock 遮蔽結構相同</li>
<li>← work-log 案例入口：<a href="/blog/work-log/192-%E5%80%8B%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E5%AF%A6%E6%A9%9F%E5%85%A8%E5%A3%9Emock-%E9%81%AE%E8%94%BD%E7%9C%9F%E5%AF%A6%E8%A1%8C%E7%82%BA%E7%9A%84%E4%B8%89%E5%B1%A4%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5/" data-link-title="192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略" data-link-desc="unit test 全綠、實機部署後功能整片壞掉。mock-only 策略的結構盲區（text vs binary frame、缺 auth handshake、ANSI 多樣性被 FakeWebSocketChannel 遮蔽），以及分層測試各抓什麼、各遮蔽什麼。">192 個測試全過、實機全壞</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>5.2 WebSocket integration test</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> integration test 的核心目標是驗證 client 與 server 透過真實連線互動後，協定行為是否正確。它比單元測試慢，但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>httptest.Server&lt;/code> 建立真實 WebSocket 測試入口&lt;/li>
&lt;li>將 &lt;code>http://&lt;/code> 測試 URL 轉成 &lt;code>ws://&lt;/code>&lt;/li>
&lt;li>用 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 避免 read/write 永久卡住&lt;/li>
&lt;li>驗證 subscribe、push、error response 與 cleanup&lt;/li>
&lt;li>分辨 integration test 與 unit test 的責任邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察websocket-的錯誤常出現在元件交界">【觀察】WebSocket 的錯誤常出現在元件交界&lt;/h2>
&lt;p>WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過，但真實連線仍可能因為 upgrade path、read pump、write pump、send &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 或 unregister 流程出錯。&lt;/p>
&lt;p>Integration test 適合驗證這些交界：&lt;/p>
&lt;ul>
&lt;li>client 能否成功 dial 到 &lt;code>/ws&lt;/code>&lt;/li>
&lt;li>server 是否接受 client action&lt;/li>
&lt;li>subscribe 後是否收到 acknowledgement&lt;/li>
&lt;li>server broadcast 是否能推到 client&lt;/li>
&lt;li>client 關閉後 hub 是否清理連線&lt;/li>
&lt;li>錯誤 action 是否回 error message 而不是斷線&lt;/li>
&lt;/ul>
&lt;p>這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。&lt;/p>
&lt;h2 id="判讀integration-test-補的是協作信心">【判讀】integration test 補的是協作信心&lt;/h2>
&lt;p>Integration test 的核心責任是覆蓋協定流程，不是取代所有規則測試。Router validation、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization、dedup key、state transition 應主要用單元測試；WebSocket integration test 只挑關鍵端到端流程。&lt;/p>
&lt;p>建議分工：&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>unit test&lt;/td>
 &lt;td>router、payload validation、subscription state、TrySend&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>integration test&lt;/td>
 &lt;td>dial、upgrade、read/write pump、server response、cleanup&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>race test&lt;/td>
 &lt;td>hub、client state、repository 的並發存取&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>如果每個 validation case 都啟動 WebSocket server，測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。&lt;/p>
&lt;h2 id="執行用-httptestserver-建立真實入口">【執行】用 httptest.Server 建立真實入口&lt;/h2>
&lt;p>WebSocket integration test 的核心起點是 &lt;code>httptest.Server&lt;/code>。它提供真實 HTTP server，不需要手動管理 port。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-go" data-lang="go">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestWebSocketSubscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">t&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">testing&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">T&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="nx">server&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">httptest&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewServer&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nf">newRouter&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Close&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wsURL&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;ws&amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimPrefix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">URL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;http&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;/ws&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">conn&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">websocket&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">DefaultDialer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Dial&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">wsURL&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">nil&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fatalf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;dial websocket: %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Cleanup&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">conn&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>httptest.NewServer&lt;/code> 產生的是 &lt;code>http://127.0.0.1:port&lt;/code>，WebSocket client 需要 &lt;code>ws://127.0.0.1:port/ws&lt;/code>，所以常用字串轉換。&lt;/p>
&lt;p>若 handler 需要 hub、router、fake repository，應在測試中明確組裝。這讓 integration test 的依賴可控。&lt;/p>
&lt;h2 id="策略測試-helper-應封裝連線樣板">【策略】測試 helper 應封裝連線樣板&lt;/h2>
&lt;p>Integration test 的核心樣板很多：建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複，但不要把協定斷言藏起來。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> integration test 的核心目標是驗證 client 與 server 透過真實連線互動後，協定行為是否正確。它比單元測試慢，但能覆蓋 HTTP upgrade、read/write pump、router、server message、push flow 與 cleanup。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>httptest.Server</code> 建立真實 WebSocket 測試入口</li>
<li>將 <code>http://</code> 測試 URL 轉成 <code>ws://</code></li>
<li>用 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 避免 read/write 永久卡住</li>
<li>驗證 subscribe、push、error response 與 cleanup</li>
<li>分辨 integration test 與 unit test 的責任邊界</li>
</ol>
<hr>
<h2 id="觀察websocket-的錯誤常出現在元件交界">【觀察】WebSocket 的錯誤常出現在元件交界</h2>
<p>WebSocket 測試的核心困難是很多錯誤不在單一函式裡。Router 單元測試可能通過，但真實連線仍可能因為 upgrade path、read pump、write pump、send <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 或 unregister 流程出錯。</p>
<p>Integration test 適合驗證這些交界：</p>
<ul>
<li>client 能否成功 dial 到 <code>/ws</code></li>
<li>server 是否接受 client action</li>
<li>subscribe 後是否收到 acknowledgement</li>
<li>server broadcast 是否能推到 client</li>
<li>client 關閉後 hub 是否清理連線</li>
<li>錯誤 action 是否回 error message 而不是斷線</li>
</ul>
<p>這些不是每個單元測試都該覆蓋的內容。Integration test 的價值在於證明多個元件能透過真實協定協作。</p>
<h2 id="判讀integration-test-補的是協作信心">【判讀】integration test 補的是協作信心</h2>
<p>Integration test 的核心責任是覆蓋協定流程，不是取代所有規則測試。Router validation、<a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization、dedup key、state transition 應主要用單元測試；WebSocket integration test 只挑關鍵端到端流程。</p>
<p>建議分工：</p>
<table>
  <thead>
      <tr>
          <th>測試類型</th>
          <th>負責內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>unit test</td>
          <td>router、payload validation、subscription state、TrySend</td>
      </tr>
      <tr>
          <td>integration test</td>
          <td>dial、upgrade、read/write pump、server response、cleanup</td>
      </tr>
      <tr>
          <td>race test</td>
          <td>hub、client state、repository 的並發存取</td>
      </tr>
  </tbody>
</table>
<p>如果每個 validation case 都啟動 WebSocket server，測試會變慢且失敗定位不清楚。Integration test 應少量、關鍵、穩定。</p>
<h2 id="執行用-httptestserver-建立真實入口">【執行】用 httptest.Server 建立真實入口</h2>
<p>WebSocket integration test 的核心起點是 <code>httptest.Server</code>。它提供真實 HTTP server，不需要手動管理 port。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestWebSocketSubscribe</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">server</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewServer</span><span class="p">(</span><span class="nf">newRouter</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">Close</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">wsURL</span> <span class="o">:=</span> <span class="s">&#34;ws&#34;</span> <span class="o">+</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimPrefix</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">URL</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s">&#34;/ws&#34;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">websocket</span><span class="p">.</span><span class="nx">DefaultDialer</span><span class="p">.</span><span class="nf">Dial</span><span class="p">(</span><span class="nx">wsURL</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;dial websocket: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>httptest.NewServer</code> 產生的是 <code>http://127.0.0.1:port</code>，WebSocket client 需要 <code>ws://127.0.0.1:port/ws</code>，所以常用字串轉換。</p>
<p>若 handler 需要 hub、router、fake repository，應在測試中明確組裝。這讓 integration test 的依賴可控。</p>
<h2 id="策略測試-helper-應封裝連線樣板">【策略】測試 helper 應封裝連線樣板</h2>
<p>Integration test 的核心樣板很多：建立 server、轉 URL、dial、設定 cleanup。可以用 helper 降低重複，但不要把協定斷言藏起來。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">handler</span> <span class="nx">http</span><span class="p">.</span><span class="nx">Handler</span><span class="p">)</span> <span class="p">(</span><span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">,</span> <span class="o">*</span><span class="nx">httptest</span><span class="p">.</span><span class="nx">Server</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</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">    <span class="nx">server</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewServer</span><span class="p">(</span><span class="nx">handler</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">Close</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">wsURL</span> <span class="o">:=</span> <span class="s">&#34;ws&#34;</span> <span class="o">+</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimPrefix</span><span class="p">(</span><span class="nx">server</span><span class="p">.</span><span class="nx">URL</span><span class="p">,</span> <span class="s">&#34;http&#34;</span><span class="p">)</span> <span class="o">+</span> <span class="s">&#34;/ws&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">websocket</span><span class="p">.</span><span class="nx">DefaultDialer</span><span class="p">.</span><span class="nf">Dial</span><span class="p">(</span><span class="nx">wsURL</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;dial websocket: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Cleanup</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">_</span> <span class="p">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">return</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">server</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Helper 負責重複 setup。測試本文仍應清楚寫出「送什麼 message、期待什麼 response」。</p>
<h2 id="執行action-測試要檢查協定語意">【執行】action 測試要檢查協定語意</h2>
<p>Action 測試的核心流程是送 client message、讀 server message、檢查協定欄位。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribeActionReturnsAcknowledgement</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouter</span><span class="p">())</span>
</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">    <span class="nx">request</span> <span class="o">:=</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span> <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}),</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">WriteJSON</span><span class="p">(</span><span class="nx">request</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;write subscribe: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;topic_subscribed&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;response type = %q, want topic_subscribed&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;alerts&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;response topic = %q, want alerts&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試檢查的是協定語意，不只是連線沒有斷。Subscribe 的成功條件是 server 明確回覆訂閱成功。</p>
<h2 id="執行每次讀取前設定-deadline">【執行】每次讀取前設定 deadline</h2>
<p>WebSocket integration test 的核心風險是永久卡住。每次等待 server message 前，都應設定 read deadline。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">conn</span> <span class="o">*</span><span class="nx">websocket</span><span class="p">.</span><span class="nx">Conn</span><span class="p">)</span> <span class="nx">ServerMessage</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</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">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">SetReadDeadline</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">));</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;set read deadline: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">var</span> <span class="nx">response</span> <span class="nx">ServerMessage</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">ReadJSON</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">response</span><span class="p">);</span> <span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;read server message: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="nx">response</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>Deadline 是測試保護。若 server 沒有送出預期訊息，測試會在合理時間內失敗，而不是卡住整個測試套件。</p>
<p><a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">Timeout</a> 不應過短。CI 可能比本機慢，測試應給足合理緩衝，但仍要能快速暴露失敗。</p>
<h2 id="執行推送測試要先建立可觀察觸發點">【執行】推送測試要先建立可觀察觸發點</h2>
<p>Server push 的核心測試流程是先讓 client 訂閱 topic，再從 server 端觸發 broadcast，最後讀取 client 收到的 message。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestSubscribedClientReceivesBroadcast</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">go</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">Run</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouterWithHub</span><span class="p">(</span><span class="nx">hub</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">writeClientMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="nx">ActionSubscribeTopic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Data</span><span class="p">:</span>   <span class="nf">mustJSON</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">SubscribeTopicRequest</span><span class="p">{</span><span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">}),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">hub</span><span class="p">.</span><span class="nf">Broadcast</span><span class="p">(</span><span class="s">&#34;alerts&#34;</span><span class="p">,</span> <span class="nx">ServerMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="nx">Type</span><span class="p">:</span>  <span class="s">&#34;notification&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">pushed</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="k">if</span> <span class="nx">pushed</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;notification&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;pushed type = %q, want notification&#34;</span><span class="p">,</span> <span class="nx">pushed</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試證明 subscribe state、hub broadcast、write pump 能透過真實 connection 協作。若只想測 <code>Broadcast</code> 是否檢查 topic，應寫 hub unit test，不必走 WebSocket。</p>
<h2 id="策略非同步清理用-eventually不用固定-sleep">【策略】非同步清理用 eventually，不用固定 sleep</h2>
<p>連線清理測試的核心問題是 cleanup 通常非同步發生。測試應等待可觀察條件，而不是固定 sleep。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">,</span> <span class="nx">timeout</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">,</span> <span class="nx">condition</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">t</span><span class="p">.</span><span class="nf">Helper</span><span class="p">()</span>
</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">    <span class="nx">deadline</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Add</span><span class="p">(</span><span class="nx">timeout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">Before</span><span class="p">(</span><span class="nx">deadline</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">if</span> <span class="nf">condition</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="k">return</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</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">    <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;condition was not met within %s&#34;</span><span class="p">,</span> <span class="nx">timeout</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>使用方式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestClientIsRemovedAfterClose</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">hub</span> <span class="o">:=</span> <span class="nf">NewHub</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouterWithHub</span><span class="p">(</span><span class="nx">hub</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">ClientCount</span><span class="p">()</span> <span class="o">==</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">conn</span><span class="p">.</span><span class="nf">Close</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nf">eventually</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Second</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="k">return</span> <span class="nx">hub</span><span class="p">.</span><span class="nf">ClientCount</span><span class="p">()</span> <span class="o">==</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>eventually</code> 不是任意等待；它等待具體條件。失敗時，測試會指出 cleanup 沒發生，而不是把時間耗掉後仍然不清楚原因。</p>
<h2 id="判讀error-action-應測協定不只測-log">【判讀】error action 應測協定，不只測 log</h2>
<p>WebSocket action 失敗的核心語意是單次 action 失敗，不一定代表連線失敗。Integration test 應確認 server 回 error message，並且連線仍可繼續使用。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-go" data-lang="go"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">func</span> <span class="nf">TestUnknownActionReturnsErrorMessage</span><span class="p">(</span><span class="nx">t</span> <span class="o">*</span><span class="nx">testing</span><span class="p">.</span><span class="nx">T</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">conn</span><span class="p">,</span> <span class="nx">_</span> <span class="o">:=</span> <span class="nf">newTestWebSocket</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nf">newRouter</span><span class="p">())</span>
</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">    <span class="nf">writeClientMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">,</span> <span class="nx">ClientMessage</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">Action</span><span class="p">:</span> <span class="s">&#34;unknown_action&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="p">})</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">response</span> <span class="o">:=</span> <span class="nf">readServerMessage</span><span class="p">(</span><span class="nx">t</span><span class="p">,</span> <span class="nx">conn</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span> <span class="o">!=</span> <span class="s">&#34;error&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;response type = %q, want error&#34;</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Type</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>若設計上 unknown action 應直接關閉連線，也應明確測出 close 行為。重點是協定行為要可驗證，不要只依賴 server <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一 Go server 內的 WebSocket 協定協作；跨節點 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 與壓力測試，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/07-distributed-operations/cross-node-websocket/" data-link-title="7.3 跨節點 WebSocket、presence 與重連協定" data-link-desc="把單一 server 的 WebSocket hub 擴展到多節點推送與連線狀態">Go 進階：跨節點 WebSocket、presence 與重連協定</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 WebSocket handler、pump 與 heartbeat；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/06-practical/new-websocket-action/" data-link-title="6.1 如何新增一個即時訊息 action" data-link-desc="修改 client message、路由與 handler">Go：如何新增一個即時訊息 action</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/read-write-pump/" data-link-title="2.1 read pump / write pump 模式" data-link-desc="分離 WebSocket 讀取、寫入與心跳">Go：read/write pump 模式</a></li>
<li><a href="/blog/go-advanced/02-networking-websocket/heartbeat-deadline/" data-link-title="2.2 heartbeat、deadline 與連線清理" data-link-desc="用 ping/pong 和 deadline 偵測失效連線">Go：heartbeat、deadline 與連線清理</a></li>
<li><a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">Go：graceful shutdown 與 signal handling</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">Go 進階：CI、fuzz、load test 與 chaos testing</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>WebSocket integration test 應少量覆蓋關鍵端到端協定：dial、送 action、收 response、server push、錯誤回應與 cleanup。測試要使用真實 <code>httptest.Server</code>，每次 read 前設定 deadline，等待非同步清理時使用 <code>eventually</code>。單元測試負責大量規則，integration test 負責證明真實連線能把規則串起來。</p>
]]></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>HTTP contract test 設計</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/http-contract-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/http-contract-test/</guid><description>&lt;p>HTTP REST API 的協議複雜度比 WebSocket 低 — request body 是 JSON、response body 是 JSON、status code 有標準語意。但 mock HTTP client（回傳固定 JSON）和真實 API 之間仍然存在差異：error response 的格式、header 的必要性、認證 token 的有效期、rate limit 行為。&lt;/p>
&lt;h2 id="http-protocol-test-的驗證對象">HTTP protocol test 的驗證對象&lt;/h2>
&lt;h3 id="request-格式">Request 格式&lt;/h3>
&lt;p>Client 端發送的 request 是否符合 API 規格。Content-Type header、JSON body 的欄位名稱和型別、query parameter 的格式 — 這些在 mock client 中通常不被驗證（mock 接受任何 request），但真實 API 可能因為格式不符而拒絕。&lt;/p>
&lt;h3 id="response-解析">Response 解析&lt;/h3>
&lt;p>Client 端能否正確解析真實 API 的 response。Mock response 通常是開發者手寫的 JSON，可能和真實 API 的 response 有微妙差異 — 欄位名稱大小寫、數值型別（integer vs float）、null vs 缺失欄位、巢狀結構。&lt;/p>
&lt;h3 id="error-response-處理">Error response 處理&lt;/h3>
&lt;p>真實 API 的 error response 格式可能和 success response 不同。Mock client 通常只模擬 success case，偶爾模擬簡化的 error case。真實 API 的 400/401/403/404/500 各自可能有不同的 error body 結構。&lt;/p>
&lt;h3 id="認證流程">認證流程&lt;/h3>
&lt;p>API 的認證流程（API key、OAuth token、session cookie）在 mock 中通常被跳過。真實 API 的認證包括 token 取得、token 過期、refresh flow — 每一步都可能失敗。&lt;/p>
&lt;h2 id="test-結構">Test 結構&lt;/h2>
&lt;p>HTTP protocol test 的結構和 WebSocket protocol test 類似 — 對真實 API 發送真實 request、驗證真實 response。&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">test(&amp;#39;POST /api/resource creates resource&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> response = await httpClient.post(
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &amp;#39;http://localhost:8080/api/resource&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> body: jsonEncode({&amp;#39;name&amp;#39;: &amp;#39;test&amp;#39;, &amp;#39;type&amp;#39;: &amp;#39;A&amp;#39;}),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> headers: {&amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;, &amp;#39;Authorization&amp;#39;: &amp;#39;Bearer ...&amp;#39;},
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> )
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> expect(response.statusCode, 201)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> body = jsonDecode(response.body)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> expect(body[&amp;#39;id&amp;#39;], isNotNull)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> expect(body[&amp;#39;name&amp;#39;], &amp;#39;test&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;POST /api/resource with invalid body returns 400&amp;#39;):
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> response = await httpClient.post(
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &amp;#39;http://localhost:8080/api/resource&amp;#39;,
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> body: jsonEncode({&amp;#39;invalid_field&amp;#39;: &amp;#39;value&amp;#39;}),
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> headers: {&amp;#39;Content-Type&amp;#39;: &amp;#39;application/json&amp;#39;, &amp;#39;Authorization&amp;#39;: &amp;#39;Bearer ...&amp;#39;},
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> )
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> expect(response.statusCode, 400)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> body = jsonDecode(response.body)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> expect(body[&amp;#39;error&amp;#39;], isNotNull) // 驗證 error body 結構&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="consumer-driven-contract-test">Consumer-driven contract test&lt;/h2>
&lt;p>當 client 和 server 由不同團隊開發時，consumer-driven contract test 是 protocol integration test 的延伸。Client 團隊定義「我期望的 request/response 格式」（contract），server 團隊驗證 server 實作是否符合 contract。&lt;/p></description><content:encoded><![CDATA[<p>HTTP REST API 的協議複雜度比 WebSocket 低 — request body 是 JSON、response body 是 JSON、status code 有標準語意。但 mock HTTP client（回傳固定 JSON）和真實 API 之間仍然存在差異：error response 的格式、header 的必要性、認證 token 的有效期、rate limit 行為。</p>
<h2 id="http-protocol-test-的驗證對象">HTTP protocol test 的驗證對象</h2>
<h3 id="request-格式">Request 格式</h3>
<p>Client 端發送的 request 是否符合 API 規格。Content-Type header、JSON body 的欄位名稱和型別、query parameter 的格式 — 這些在 mock client 中通常不被驗證（mock 接受任何 request），但真實 API 可能因為格式不符而拒絕。</p>
<h3 id="response-解析">Response 解析</h3>
<p>Client 端能否正確解析真實 API 的 response。Mock response 通常是開發者手寫的 JSON，可能和真實 API 的 response 有微妙差異 — 欄位名稱大小寫、數值型別（integer vs float）、null vs 缺失欄位、巢狀結構。</p>
<h3 id="error-response-處理">Error response 處理</h3>
<p>真實 API 的 error response 格式可能和 success response 不同。Mock client 通常只模擬 success case，偶爾模擬簡化的 error case。真實 API 的 400/401/403/404/500 各自可能有不同的 error body 結構。</p>
<h3 id="認證流程">認證流程</h3>
<p>API 的認證流程（API key、OAuth token、session cookie）在 mock 中通常被跳過。真實 API 的認證包括 token 取得、token 過期、refresh flow — 每一步都可能失敗。</p>
<h2 id="test-結構">Test 結構</h2>
<p>HTTP protocol test 的結構和 WebSocket protocol test 類似 — 對真實 API 發送真實 request、驗證真實 response。</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">test(&#39;POST /api/resource creates resource&#39;):
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  response = await httpClient.post(
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    &#39;http://localhost:8080/api/resource&#39;,
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    body: jsonEncode({&#39;name&#39;: &#39;test&#39;, &#39;type&#39;: &#39;A&#39;}),
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    headers: {&#39;Content-Type&#39;: &#39;application/json&#39;, &#39;Authorization&#39;: &#39;Bearer ...&#39;},
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  )
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  expect(response.statusCode, 201)
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  body = jsonDecode(response.body)
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  expect(body[&#39;id&#39;], isNotNull)
</span></span><span class="line"><span class="ln">10</span><span class="cl">  expect(body[&#39;name&#39;], &#39;test&#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;POST /api/resource with invalid body returns 400&#39;):
</span></span><span class="line"><span class="ln">13</span><span class="cl">  response = await httpClient.post(
</span></span><span class="line"><span class="ln">14</span><span class="cl">    &#39;http://localhost:8080/api/resource&#39;,
</span></span><span class="line"><span class="ln">15</span><span class="cl">    body: jsonEncode({&#39;invalid_field&#39;: &#39;value&#39;}),
</span></span><span class="line"><span class="ln">16</span><span class="cl">    headers: {&#39;Content-Type&#39;: &#39;application/json&#39;, &#39;Authorization&#39;: &#39;Bearer ...&#39;},
</span></span><span class="line"><span class="ln">17</span><span class="cl">  )
</span></span><span class="line"><span class="ln">18</span><span class="cl">  expect(response.statusCode, 400)
</span></span><span class="line"><span class="ln">19</span><span class="cl">  body = jsonDecode(response.body)
</span></span><span class="line"><span class="ln">20</span><span class="cl">  expect(body[&#39;error&#39;], isNotNull)  // 驗證 error body 結構</span></span></code></pre></div><h2 id="consumer-driven-contract-test">Consumer-driven contract test</h2>
<p>當 client 和 server 由不同團隊開發時，consumer-driven contract test 是 protocol integration test 的延伸。Client 團隊定義「我期望的 request/response 格式」（contract），server 團隊驗證 server 實作是否符合 contract。</p>
<p>Consumer-driven contract test 的工具（Pact、Spring Cloud Contract）自動化了 contract 的定義、驗證和版本管理。適合 API 有多個 consumer 且需要獨立部署的場景。</p>
<p>自用工具或 client/server 同一人開發的場景不需要 contract test 工具 — 直接對真實 server 跑 protocol integration test 更簡單。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>CI 中如何管理 test 用的 server → <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>WebSocket 的 protocol test → <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></li>
<li>什麼時候用 contract test 替代 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>
<li>Backend 的 contract testing 實務 → <a href="/blog/backend/06-reliability/contract-testing/" data-link-title="6.10 Contract Testing 與 Schema 演進" data-link-desc="把跨服務 / API / event schema 的隱性期待變成可驗證契約，控制演進相容性">Backend 可靠性 contract testing</a></li>
</ul>
]]></content:encoded></item><item><title>名義 Integration Test</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/nominal-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/nominal-integration-test/</guid><description>&lt;p>名義 integration test 的核心概念是「test 標題或路徑包含 integration，但所有外部依賴都被 fake 替換，實際驗證的是內部邏輯而非真實服務互動」。它的問題在命名造成的認知偏差 — 團隊以為 integration 已驗證，實際上協議層完全沒被覆蓋。可先對照 &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>和 &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;p>名義 integration test 在 test 分類中介於 unit test 和真正的 integration test 之間。它的 scope 比 unit test 大（多個內部元件一起測），但驗證對象和 unit test 相同（程式碼內部邏輯）。它和 &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>的關係是：名義 integration test 用 mock 替換所有外部依賴，mock 遮蔽了協議層行為，而 test 名稱讓團隊以為這些行為已被驗證。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>辨識名義 integration test 的三個特徵：核心外部依賴 100% 被 fake 取代、沒有真實的 I/O 操作（網路、檔案、資料庫）、&lt;code>setUp()&lt;/code> 不需要啟動外部程序或建立網路連線。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>修正策略分兩步：改名（讓 test 名稱反映真實驗證對象，如 &lt;code>connection_state_machine_test&lt;/code>）和補寫（如果協議互動是關鍵路徑，補寫對真實服務的 &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>）。在 test 檔案頂部標明被 fake 取代的依賴清單，讓後續讀者快速判斷驗證邊界。&lt;/p></description><content:encoded><![CDATA[<p>名義 integration test 的核心概念是「test 標題或路徑包含 integration，但所有外部依賴都被 fake 替換，實際驗證的是內部邏輯而非真實服務互動」。它的問題在命名造成的認知偏差 — 團隊以為 integration 已驗證，實際上協議層完全沒被覆蓋。可先對照 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>和 <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>
<p>名義 integration test 在 test 分類中介於 unit test 和真正的 integration test 之間。它的 scope 比 unit test 大（多個內部元件一起測），但驗證對象和 unit test 相同（程式碼內部邏輯）。它和 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>的關係是：名義 integration test 用 mock 替換所有外部依賴，mock 遮蔽了協議層行為，而 test 名稱讓團隊以為這些行為已被驗證。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>辨識名義 integration test 的三個特徵：核心外部依賴 100% 被 fake 取代、沒有真實的 I/O 操作（網路、檔案、資料庫）、<code>setUp()</code> 不需要啟動外部程序或建立網路連線。</p>
<h2 id="設計責任">設計責任</h2>
<p>修正策略分兩步：改名（讓 test 名稱反映真實驗證對象，如 <code>connection_state_machine_test</code>）和補寫（如果協議互動是關鍵路徑，補寫對真實服務的 <a href="/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">protocol integration test</a>）。在 test 檔案頂部標明被 fake 取代的依賴清單，讓後續讀者快速判斷驗證邊界。</p>
]]></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>CI 中的服務 fixture 管理</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/service-fixture-management/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/service-fixture-management/</guid><description>&lt;p>Protocol integration test 需要真實的外部服務實例。在 CI 中管理這些服務實例的啟動、初始化、健康檢查和停止，是 protocol integration test 基礎設施的核心問題。&lt;/p>
&lt;h2 id="三種服務管理方案">三種服務管理方案&lt;/h2>
&lt;h3 id="processstart直接啟動程序">Process.start（直接啟動程序）&lt;/h3>
&lt;p>在 test 的 setUp 中用 &lt;code>Process.start&lt;/code> 啟動服務程序，tearDown 中用 &lt;code>process.kill&lt;/code> 停止。&lt;/p>
&lt;p>適合的前提：服務是單一二進位檔（不需要 Docker），啟動速度快（&amp;lt; 2 秒），不需要持久化狀態。&lt;/p>
&lt;p>app_tunnel 的 ttyd 就是這個模式。&lt;code>ttyd bash&lt;/code> 一行指令啟動，不需要設定檔，不需要資料庫，啟動到可接受連線約 500ms。Test harness 只需要：&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">setUp: process = Process.start(&amp;#39;ttyd&amp;#39;, [&amp;#39;--port&amp;#39;, &amp;#39;7681&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"> await waitForPort(7681, timeout: 3s)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">tearDown: process.kill()&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="docker-compose">Docker Compose&lt;/h3>
&lt;p>用 Docker Compose 定義服務堆疊，CI 的 before_all 階段 &lt;code>docker compose up&lt;/code>，after_all 階段 &lt;code>docker compose down&lt;/code>。&lt;/p>
&lt;p>適合的前提：服務有依賴（database + cache + app server）、需要特定 OS 環境、需要精確的版本控制。&lt;/p>
&lt;p>Docker Compose 的成本是 image pull 時間（首次或 image 更新時）和容器啟動時間。CI 中可以用 image cache 減少 pull 時間，但冷啟動仍比直接啟動程序慢。&lt;/p>
&lt;h3 id="testcontainers">Testcontainers&lt;/h3>
&lt;p>在 test 程式碼中用 testcontainers 套件管理 Docker 容器。每個 test class 或 test suite 啟動自己的容器，test 結束後自動清理。&lt;/p>
&lt;p>適合的前提：和 Docker Compose 類似，但需要更細粒度的控制（不同 test 用不同的服務設定），或需要在 test 程式碼中動態決定服務的啟動參數。&lt;/p>
&lt;p>Testcontainers 的優勢是 test 和 fixture 在同一個程式碼檔案中，容易理解每個 test 需要什麼環境。缺點是每個 test suite 啟動自己的容器，比共用容器慢。&lt;/p>
&lt;h2 id="健康檢查">健康檢查&lt;/h2>
&lt;p>服務啟動後到可以接受請求之間有延遲。直接在啟動後發送 test request 會因為服務尚未 ready 而失敗。&lt;/p>
&lt;p>健康檢查的方式依服務類型而定：&lt;/p>
&lt;p>&lt;strong>TCP port 可達&lt;/strong>：&lt;code>waitForPort(port, timeout)&lt;/code> 反覆嘗試 TCP 連線，成功即表示服務在監聽。最簡單，適合所有 TCP 服務。&lt;/p>
&lt;p>&lt;strong>HTTP health endpoint&lt;/strong>：對 &lt;code>/health&lt;/code> 或 &lt;code>/ready&lt;/code> 發送 GET request，收到 200 表示服務 ready。比 port check 更可靠 — port 監聽不代表應用層 ready。&lt;/p>
&lt;p>&lt;strong>特定操作成功&lt;/strong>：執行一個輕量的業務操作（例如 WebSocket 連線 + 簡單指令），成功表示服務完全 ready。最可靠但最慢。&lt;/p>
&lt;h2 id="服務狀態隔離">服務狀態隔離&lt;/h2>
&lt;p>不同 test 之間的服務狀態需要隔離 — test A 在服務中建立的資料不應該影響 test B。&lt;/p>
&lt;p>三種隔離策略：&lt;/p>
&lt;p>&lt;strong>每 test 重啟服務&lt;/strong>：最強隔離，最慢。適合服務啟動快（&amp;lt; 1 秒）的場景。&lt;/p>
&lt;p>&lt;strong>每 test 重設狀態&lt;/strong>：服務持續運行，test 開始前清理狀態（truncate tables, flush cache）。適合服務啟動慢但重設快的場景。&lt;/p>
&lt;p>&lt;strong>每 test 用獨立 namespace&lt;/strong>：服務持續運行，每個 test 使用獨立的 database schema / topic / channel。適合支援多租戶的服務。&lt;/p>
&lt;p>app_tunnel 的 ttyd 是無狀態服務（每次連線是獨立的 terminal session），不需要狀態隔離。每個 test 建立新的 WebSocket 連線 = 新的 session。&lt;/p></description><content:encoded><![CDATA[<p>Protocol integration test 需要真實的外部服務實例。在 CI 中管理這些服務實例的啟動、初始化、健康檢查和停止，是 protocol integration test 基礎設施的核心問題。</p>
<h2 id="三種服務管理方案">三種服務管理方案</h2>
<h3 id="processstart直接啟動程序">Process.start（直接啟動程序）</h3>
<p>在 test 的 setUp 中用 <code>Process.start</code> 啟動服務程序，tearDown 中用 <code>process.kill</code> 停止。</p>
<p>適合的前提：服務是單一二進位檔（不需要 Docker），啟動速度快（&lt; 2 秒），不需要持久化狀態。</p>
<p>app_tunnel 的 ttyd 就是這個模式。<code>ttyd bash</code> 一行指令啟動，不需要設定檔，不需要資料庫，啟動到可接受連線約 500ms。Test harness 只需要：</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">setUp: process = Process.start(&#39;ttyd&#39;, [&#39;--port&#39;, &#39;7681&#39;, &#39;bash&#39;])
</span></span><span class="line"><span class="ln">2</span><span class="cl">       await waitForPort(7681, timeout: 3s)
</span></span><span class="line"><span class="ln">3</span><span class="cl">tearDown: process.kill()</span></span></code></pre></div><h3 id="docker-compose">Docker Compose</h3>
<p>用 Docker Compose 定義服務堆疊，CI 的 before_all 階段 <code>docker compose up</code>，after_all 階段 <code>docker compose down</code>。</p>
<p>適合的前提：服務有依賴（database + cache + app server）、需要特定 OS 環境、需要精確的版本控制。</p>
<p>Docker Compose 的成本是 image pull 時間（首次或 image 更新時）和容器啟動時間。CI 中可以用 image cache 減少 pull 時間，但冷啟動仍比直接啟動程序慢。</p>
<h3 id="testcontainers">Testcontainers</h3>
<p>在 test 程式碼中用 testcontainers 套件管理 Docker 容器。每個 test class 或 test suite 啟動自己的容器，test 結束後自動清理。</p>
<p>適合的前提：和 Docker Compose 類似，但需要更細粒度的控制（不同 test 用不同的服務設定），或需要在 test 程式碼中動態決定服務的啟動參數。</p>
<p>Testcontainers 的優勢是 test 和 fixture 在同一個程式碼檔案中，容易理解每個 test 需要什麼環境。缺點是每個 test suite 啟動自己的容器，比共用容器慢。</p>
<h2 id="健康檢查">健康檢查</h2>
<p>服務啟動後到可以接受請求之間有延遲。直接在啟動後發送 test request 會因為服務尚未 ready 而失敗。</p>
<p>健康檢查的方式依服務類型而定：</p>
<p><strong>TCP port 可達</strong>：<code>waitForPort(port, timeout)</code> 反覆嘗試 TCP 連線，成功即表示服務在監聽。最簡單，適合所有 TCP 服務。</p>
<p><strong>HTTP health endpoint</strong>：對 <code>/health</code> 或 <code>/ready</code> 發送 GET request，收到 200 表示服務 ready。比 port check 更可靠 — port 監聽不代表應用層 ready。</p>
<p><strong>特定操作成功</strong>：執行一個輕量的業務操作（例如 WebSocket 連線 + 簡單指令），成功表示服務完全 ready。最可靠但最慢。</p>
<h2 id="服務狀態隔離">服務狀態隔離</h2>
<p>不同 test 之間的服務狀態需要隔離 — test A 在服務中建立的資料不應該影響 test B。</p>
<p>三種隔離策略：</p>
<p><strong>每 test 重啟服務</strong>：最強隔離，最慢。適合服務啟動快（&lt; 1 秒）的場景。</p>
<p><strong>每 test 重設狀態</strong>：服務持續運行，test 開始前清理狀態（truncate tables, flush cache）。適合服務啟動慢但重設快的場景。</p>
<p><strong>每 test 用獨立 namespace</strong>：服務持續運行，每個 test 使用獨立的 database schema / topic / channel。適合支援多租戶的服務。</p>
<p>app_tunnel 的 ttyd 是無狀態服務（每次連線是獨立的 terminal session），不需要狀態隔離。每個 test 建立新的 WebSocket 連線 = 新的 session。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<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>
<li>Protocol integration 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></li>
<li>WebSocket 的 protocol test 實作 → <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></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><item><title>模組五：測試與可靠性</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/</guid><description>&lt;p>並發服務測試的核心目標是讓時間、連線、goroutine、共享狀態與錯誤路徑變得可重現。只測 happy path 不足以保護長時間運行的 Go 服務；真正需要測的是取消、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、cleanup、data race 與協定邊界。&lt;/p>
&lt;p>本模組承接前面的並發、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> 與架構邊界：時間注入讓狀態轉移可測，WebSocket integration test 驗證真實連線互動，race detector 檢查共享狀態，table-driven test 幫助案例保持清楚。&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;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">5.1&lt;/a>&lt;/td>
 &lt;td>時間注入與狀態轉移測試&lt;/td>
 &lt;td>不用 sleep 也能測 timeout、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a> 與狀態轉移&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">5.2&lt;/a>&lt;/td>
 &lt;td>WebSocket integration test&lt;/td>
 &lt;td>用真實 test server 驗證 client/server 協定&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">5.3&lt;/a>&lt;/td>
 &lt;td>race condition 檢查&lt;/td>
 &lt;td>用 &lt;code>go test -race&lt;/code> 搭配併發測試找資料競爭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">5.4&lt;/a>&lt;/td>
 &lt;td>table-driven test 的設計邊界&lt;/td>
 &lt;td>讓測試表只描述單一行為維度&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;p>本模組使用虛構的即時通知服務作為範例。範例包含 job 狀態轉移、WebSocket subscribe flow、client cleanup、repository concurrent access 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization。&lt;/p>
&lt;p>範例只用來展示 Go 測試設計，不假設讀者正在維護任何特定專案。&lt;/p>
&lt;h2 id="本模組的-go-核心概念">本模組的 Go 核心概念&lt;/h2>
&lt;ul>
&lt;li>用 &lt;code>now time.Time&lt;/code> 或 &lt;code>func() time.Time&lt;/code> 控制時間。&lt;/li>
&lt;li>用 &lt;code>httptest.Server&lt;/code> 建立真實 WebSocket integration test。&lt;/li>
&lt;li>用 read/write deadline 避免測試永久卡住。&lt;/li>
&lt;li>用 &lt;code>eventually&lt;/code> helper 等待非同步清理，而不是固定 sleep。&lt;/li>
&lt;li>用 &lt;code>go test -race ./...&lt;/code> 檢查執行到的 data race。&lt;/li>
&lt;li>用小而清楚的 table-driven test 表達同一個行為的多組案例。&lt;/li>
&lt;/ul>
&lt;h2 id="學習重點">學習重點&lt;/h2>
&lt;p>學完本模組後，你應該能判斷：&lt;/p>
&lt;ol>
&lt;li>哪些邏輯應該用純函式測，哪些需要 integration test&lt;/li>
&lt;li>測試裡的時間應該如何注入，而不是等待真實時間&lt;/li>
&lt;li>WebSocket 測試如何避免永久卡住&lt;/li>
&lt;li>race detector 能找什麼，不能證明什麼&lt;/li>
&lt;li>table-driven test 何時該拆成多個測試&lt;/li>
&lt;/ol>
&lt;h2 id="本模組不處理">本模組不處理&lt;/h2>
&lt;p>本模組不建立完整測試框架，也不討論大型 CI 平台、壓力測試或 chaos testing。這些主題很重要，但本模組先聚焦單一 Go 服務內最常見、最容易失控的可靠性測試；後續可接 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing&lt;/a>。&lt;/p>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 3-4 小時&lt;/p></description><content:encoded><![CDATA[<p>並發服務測試的核心目標是讓時間、連線、goroutine、共享狀態與錯誤路徑變得可重現。只測 happy path 不足以保護長時間運行的 Go 服務；真正需要測的是取消、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、cleanup、data race 與協定邊界。</p>
<p>本模組承接前面的並發、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> 與架構邊界：時間注入讓狀態轉移可測，WebSocket integration test 驗證真實連線互動，race detector 檢查共享狀態，table-driven test 幫助案例保持清楚。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">5.1</a></td>
          <td>時間注入與狀態轉移測試</td>
          <td>不用 sleep 也能測 timeout、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a> 與狀態轉移</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">5.2</a></td>
          <td>WebSocket integration test</td>
          <td>用真實 test server 驗證 client/server 協定</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">5.3</a></td>
          <td>race condition 檢查</td>
          <td>用 <code>go test -race</code> 搭配併發測試找資料競爭</td>
      </tr>
      <tr>
          <td><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">5.4</a></td>
          <td>table-driven test 的設計邊界</td>
          <td>讓測試表只描述單一行為維度</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<p>本模組使用虛構的即時通知服務作為範例。範例包含 job 狀態轉移、WebSocket subscribe flow、client cleanup、repository concurrent access 與 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization。</p>
<p>範例只用來展示 Go 測試設計，不假設讀者正在維護任何特定專案。</p>
<h2 id="本模組的-go-核心概念">本模組的 Go 核心概念</h2>
<ul>
<li>用 <code>now time.Time</code> 或 <code>func() time.Time</code> 控制時間。</li>
<li>用 <code>httptest.Server</code> 建立真實 WebSocket integration test。</li>
<li>用 read/write deadline 避免測試永久卡住。</li>
<li>用 <code>eventually</code> helper 等待非同步清理，而不是固定 sleep。</li>
<li>用 <code>go test -race ./...</code> 檢查執行到的 data race。</li>
<li>用小而清楚的 table-driven test 表達同一個行為的多組案例。</li>
</ul>
<h2 id="學習重點">學習重點</h2>
<p>學完本模組後，你應該能判斷：</p>
<ol>
<li>哪些邏輯應該用純函式測，哪些需要 integration test</li>
<li>測試裡的時間應該如何注入，而不是等待真實時間</li>
<li>WebSocket 測試如何避免永久卡住</li>
<li>race detector 能找什麼，不能證明什麼</li>
<li>table-driven test 何時該拆成多個測試</li>
</ol>
<h2 id="本模組不處理">本模組不處理</h2>
<p>本模組不建立完整測試框架，也不討論大型 CI 平台、壓力測試或 chaos testing。這些主題很重要，但本模組先聚焦單一 Go 服務內最常見、最容易失控的可靠性測試；後續可接 <a href="/blog/go-advanced/07-distributed-operations/reliability-pipeline/" data-link-title="7.6 CI、fuzz、load test 與 chaos testing" data-link-desc="把單元測試與整合測試擴展成服務可靠性驗證流程">CI、fuzz、load test 與 chaos testing</a>。</p>
<h2 id="學習時間">學習時間</h2>
<p>預計 3-4 小時</p>
]]></content:encoded></item><item><title>192 個測試全過、實機全壞：Mock 遮蔽真實行為的三層測試策略</title><link>https://tarrragon.github.io/blog/work-log/192-%E5%80%8B%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E5%AF%A6%E6%A9%9F%E5%85%A8%E5%A3%9Emock-%E9%81%AE%E8%94%BD%E7%9C%9F%E5%AF%A6%E8%A1%8C%E7%82%BA%E7%9A%84%E4%B8%89%E5%B1%A4%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/192-%E5%80%8B%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E5%AF%A6%E6%A9%9F%E5%85%A8%E5%A3%9Emock-%E9%81%AE%E8%94%BD%E7%9C%9F%E5%AF%A6%E8%A1%8C%E7%82%BA%E7%9A%84%E4%B8%89%E5%B1%A4%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>192 個 unit test 全綠、實機部署後全部功能壞掉。&lt;/p>&lt;/blockquote>
&lt;p>這不是測試寫得差 — 每個 test 都有明確斷言、覆蓋了正常和錯誤路徑。問題出在測試策略的結構：所有 test 都用 &lt;code>FakeWebSocketChannel&lt;/code> 替代真實 WebSocket，永遠不會觸碰真實協議行為。結果是 mock 和真實服務之間的差異，在整個測試套件中完全不可見。&lt;/p>
&lt;p>本文拆解三個被 mock 遮蔽的真實問題、分析 mock 遮蔽的機制、提出三層測試策略作為防護。&lt;/p>
&lt;hr>
&lt;h2 id="三個被-mock-遮蔽的真實問題">三個被 Mock 遮蔽的真實問題&lt;/h2>
&lt;h3 id="問題-1text-frame-vs-binary-frame">問題 1：text frame vs binary frame&lt;/h3>
&lt;p>ttyd 的 WebSocket 協議期望 &lt;strong>text frame&lt;/strong>，Flutter 的 &lt;code>WebSocketChannel.sink.add(Uint8List)&lt;/code> 預設發送 &lt;strong>binary frame&lt;/strong>。兩者在 WebSocket 協議層是不同的 opcode（0x1 text vs 0x2 binary），ttyd 收到 binary frame 會靜默忽略。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 原始寫法 — Uint8List 走 binary frame，ttyd 靜默忽略
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">sendData&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">dynamic&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// data 是 Uint8List → binary frame
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="c1">// 修正 — 轉成 String 走 text frame
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">sendData&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">dynamic&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span> &lt;span class="k">is&lt;/span> &lt;span class="n">Uint8List&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">String&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">fromCharCodes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">));&lt;/span> &lt;span class="c1">// text frame
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>為什麼 mock 抓不到&lt;/strong>：&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>sink.add&lt;/code> 接受 &lt;code>dynamic&lt;/code>，不區分 &lt;code>String&lt;/code> 和 &lt;code>Uint8List&lt;/code>，兩者都直接存入 &lt;code>_sinkItems&lt;/code> list。Mock 層沒有 frame type 的概念 — 它模擬的是 Dart API，不是 WebSocket 協議。&lt;/p>
&lt;h3 id="問題-2auth-token-handshake-缺失">問題 2：auth token handshake 缺失&lt;/h3>
&lt;p>ttyd 連線後需要發送一個 auth token JSON frame 完成認證，否則 ttyd 關閉連線。整個 auth handshake 的邏輯根本沒實作，因為 &lt;code>FakeWebSocketChannel&lt;/code> 不需要認證就能「連線成功」。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 缺失的 auth handshake — 連線建立後需發送
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kt">void&lt;/span> &lt;span class="n">_sendAuthTokenIfNeeded&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Credential&lt;/span> &lt;span class="n">credential&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">base64Encode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">utf8&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">credential&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ttydUser&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">:&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="n">credential&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">ttydPass&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">frame&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_protocol&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">buildAuthTokenFrame&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">authToken:&lt;/span> &lt;span class="n">token&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">frame&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">_channel&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">sink&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">frame&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>為什麼 mock 抓不到&lt;/strong>：&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>ready&lt;/code> 立即完成、&lt;code>stream&lt;/code> 立即可用。真實 ttyd 需要收到正確的 auth token 才會開始推送 terminal output；mock 不需要，所以 test 永遠看到「連線成功」。&lt;/p>
&lt;h3 id="問題-3ansi-控制序列多樣性">問題 3：ANSI 控制序列多樣性&lt;/h3>
&lt;p>真實 shell 輸出包含 OSC 序列（&lt;code>ESC]...BEL&lt;/code> 終端機標題設定）、CSI private mode（&lt;code>ESC[?...h/l&lt;/code> 游標隱藏、括號貼上模式）等控制序列。ANSI parser 只處理基本 SGR 色彩碼，其他序列全部殘留在輸出中顯示為亂碼。&lt;/p>
&lt;p>&lt;strong>為什麼 mock 抓不到&lt;/strong>：test 的輸入資料是手寫的乾淨 ANSI 字串（如 &lt;code>\x1B[31mred\x1B[0m&lt;/code>），不包含真實 shell 會產生的 OSC/CSI private mode 序列。真實 zsh prompt 一打開就送幾十種控制序列，但 test data 是人工挑選的乾淨子集。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>192 個 unit test 全綠、實機部署後全部功能壞掉。</p></blockquote>
<p>這不是測試寫得差 — 每個 test 都有明確斷言、覆蓋了正常和錯誤路徑。問題出在測試策略的結構：所有 test 都用 <code>FakeWebSocketChannel</code> 替代真實 WebSocket，永遠不會觸碰真實協議行為。結果是 mock 和真實服務之間的差異，在整個測試套件中完全不可見。</p>
<p>本文拆解三個被 mock 遮蔽的真實問題、分析 mock 遮蔽的機制、提出三層測試策略作為防護。</p>
<hr>
<h2 id="三個被-mock-遮蔽的真實問題">三個被 Mock 遮蔽的真實問題</h2>
<h3 id="問題-1text-frame-vs-binary-frame">問題 1：text frame vs binary frame</h3>
<p>ttyd 的 WebSocket 協議期望 <strong>text frame</strong>，Flutter 的 <code>WebSocketChannel.sink.add(Uint8List)</code> 預設發送 <strong>binary frame</strong>。兩者在 WebSocket 協議層是不同的 opcode（0x1 text vs 0x2 binary），ttyd 收到 binary frame 會靜默忽略。</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">// 原始寫法 — Uint8List 走 binary frame，ttyd 靜默忽略
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">sendData</span><span class="p">(</span><span class="kt">dynamic</span> <span class="n">data</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">data</span><span class="p">);</span> <span class="c1">// data 是 Uint8List → binary frame
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1">// 修正 — 轉成 String 走 text frame
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">sendData</span><span class="p">(</span><span class="kt">dynamic</span> <span class="n">data</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">data</span> <span class="k">is</span> <span class="n">Uint8List</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="kt">String</span><span class="p">.</span><span class="n">fromCharCodes</span><span class="p">(</span><span class="n">data</span><span class="p">));</span> <span class="c1">// text frame
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">data</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>為什麼 mock 抓不到</strong>：<code>FakeWebSocketChannel</code> 的 <code>sink.add</code> 接受 <code>dynamic</code>，不區分 <code>String</code> 和 <code>Uint8List</code>，兩者都直接存入 <code>_sinkItems</code> list。Mock 層沒有 frame type 的概念 — 它模擬的是 Dart API，不是 WebSocket 協議。</p>
<h3 id="問題-2auth-token-handshake-缺失">問題 2：auth token handshake 缺失</h3>
<p>ttyd 連線後需要發送一個 auth token JSON frame 完成認證，否則 ttyd 關閉連線。整個 auth handshake 的邏輯根本沒實作，因為 <code>FakeWebSocketChannel</code> 不需要認證就能「連線成功」。</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">// 缺失的 auth handshake — 連線建立後需發送
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kt">void</span> <span class="n">_sendAuthTokenIfNeeded</span><span class="p">(</span><span class="n">Credential</span> <span class="n">credential</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">final</span> <span class="n">token</span> <span class="o">=</span> <span class="n">base64Encode</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;</span><span class="si">${</span><span class="n">credential</span><span class="p">.</span><span class="n">ttydUser</span><span class="si">}</span><span class="s1">:</span><span class="si">${</span><span class="n">credential</span><span class="p">.</span><span class="n">ttydPass</span><span class="si">}</span><span class="s1">&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kd">final</span> <span class="n">frame</span> <span class="o">=</span> <span class="n">_protocol</span><span class="p">.</span><span class="n">buildAuthTokenFrame</span><span class="p">(</span><span class="nl">authToken:</span> <span class="n">token</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="n">frame</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="n">_channel</span><span class="o">!</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="n">frame</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><strong>為什麼 mock 抓不到</strong>：<code>FakeWebSocketChannel</code> 的 <code>ready</code> 立即完成、<code>stream</code> 立即可用。真實 ttyd 需要收到正確的 auth token 才會開始推送 terminal output；mock 不需要，所以 test 永遠看到「連線成功」。</p>
<h3 id="問題-3ansi-控制序列多樣性">問題 3：ANSI 控制序列多樣性</h3>
<p>真實 shell 輸出包含 OSC 序列（<code>ESC]...BEL</code> 終端機標題設定）、CSI private mode（<code>ESC[?...h/l</code> 游標隱藏、括號貼上模式）等控制序列。ANSI parser 只處理基本 SGR 色彩碼，其他序列全部殘留在輸出中顯示為亂碼。</p>
<p><strong>為什麼 mock 抓不到</strong>：test 的輸入資料是手寫的乾淨 ANSI 字串（如 <code>\x1B[31mred\x1B[0m</code>），不包含真實 shell 會產生的 OSC/CSI private mode 序列。真實 zsh prompt 一打開就送幾十種控制序列，但 test data 是人工挑選的乾淨子集。</p>
<hr>
<h2 id="mock-遮蔽的機制">Mock 遮蔽的機制</h2>
<p>三個問題有共同的結構：</p>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>Mock 模擬的層級</th>
          <th>真實差異存在的層級</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>text vs binary frame</td>
          <td>Dart API（<code>sink.add</code>）</td>
          <td>WebSocket 協議（opcode）</td>
      </tr>
      <tr>
          <td>auth handshake</td>
          <td>連線生命週期（<code>ready</code> future）</td>
          <td>應用層協議（ttyd 握手）</td>
      </tr>
      <tr>
          <td>ANSI 多樣性</td>
          <td>輸入資料（手寫測試字串）</td>
          <td>真實環境（shell output）</td>
      </tr>
  </tbody>
</table>
<p><strong>共同模式</strong>：mock 忠實模擬了 Dart API 的行為契約，但 Dart API 和真實服務之間還有一層協議語意（WebSocket frame type、ttyd auth handshake、shell 完整輸出），mock 把這層完全跳過了。</p>
<p><strong>這是 mock 的本質</strong>。Mock 的職責是讓 unit test 快速、確定性、不依賴外部服務。但當被測元件的正確性取決於「與外部服務的協議契約」時，mock 從結構上就無法驗證這件事。</p>
<hr>
<h2 id="三層測試策略">三層測試策略</h2>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>職責</th>
          <th>驗證什麼</th>
          <th>抓不到什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Unit（mock）</strong></td>
          <td>內部邏輯正確性</td>
          <td>狀態轉換、錯誤處理、資料轉換</td>
          <td>協議差異、真實服務行為、環境特異性</td>
      </tr>
      <tr>
          <td><strong>Protocol integration</strong></td>
          <td>協議契約正確性</td>
          <td>frame type、auth handshake、序列完整性</td>
          <td>UI 互動、畫面渲染、用戶體驗</td>
      </tr>
      <tr>
          <td><strong>Screen state（widget test）</strong></td>
          <td>UI 行為正確性</td>
          <td>狀態轉換 UI、導航、用戶操作</td>
          <td>底層協議、網路行為</td>
      </tr>
  </tbody>
</table>
<h3 id="unit-test已有保留">Unit test（已有，保留）</h3>
<p>用 <code>FakeWebSocketChannel</code> 驗證 <code>ConnectionManager</code> 的狀態機：idle → connecting → connected → disconnected，錯誤處理路徑（biometric 失敗、credential 缺失、timeout）。192 個 test 全部保留。</p>
<h3 id="protocol-integration-test新增">Protocol integration test（新增）</h3>
<p><strong>對真實 ttyd + proxy 驗證 WebSocket 協議契約。</strong> 這一層的關鍵是：不用 mock，直接連真實服務。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 概念示例 — 對真實 ttyd 驗證協議
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;auth token handshake succeeds against real ttyd&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 前提：本機 ttyd 已啟動（test fixture 或 CI 腳本啟動）
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">channel</span> <span class="o">=</span> <span class="n">IOWebSocketChannel</span><span class="p">.</span><span class="n">connect</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">Uri</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span><span class="s1">&#39;ws://127.0.0.1:7681/ws&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nl">protocols:</span> <span class="p">[</span><span class="s1">&#39;tty&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">channel</span><span class="p">.</span><span class="n">ready</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="c1">// 發送 auth token
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">token</span> <span class="o">=</span> <span class="n">base64Encode</span><span class="p">(</span><span class="n">utf8</span><span class="p">.</span><span class="n">encode</span><span class="p">(</span><span class="s1">&#39;testuser:testpass&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="n">channel</span><span class="p">.</span><span class="n">sink</span><span class="p">.</span><span class="n">add</span><span class="p">(</span><span class="s1">&#39;{&#34;AuthToken&#34;:&#34;</span><span class="si">$</span><span class="n">token</span><span class="s1">&#34;}&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="c1">// 驗證收到 terminal output（text frame，prefix &#39;0&#39;）
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">firstFrame</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">channel</span><span class="p">.</span><span class="n">stream</span><span class="p">.</span><span class="n">first</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">firstFrame</span><span class="p">,</span> <span class="n">isA</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span><span class="p">());</span> <span class="c1">// text frame, not binary
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">firstFrame</span><span class="p">[</span><span class="m">0</span><span class="p">],</span> <span class="s1">&#39;0&#39;</span><span class="p">);</span>        <span class="c1">// ttyd output prefix
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p><strong>為什麼這層成本低</strong>：ttyd 和 proxy 都在本機，<code>ttyd --port 7681 --credential &quot;test:test&quot; /bin/echo hello</code> 一行就能啟動一個最小測試服務。CI 腳本先啟動 ttyd → 跑 Dart integration test → 停止 ttyd。不需要模擬器、不需要真實手機。</p>
<h3 id="screen-state-test補強">Screen state test（補強）</h3>
<p>Widget test 覆蓋所有畫面狀態的 UI 行為：每個狀態顯示什麼 widget、哪些按鈕可按、按了之後導航到哪裡。這層已有 7 個 test，但不覆蓋 back 按鈕和 text input。</p>
<hr>
<h2 id="判斷原則什麼時候需要-protocol-integration-test">判斷原則：什麼時候需要 protocol integration test</h2>
<p>不是所有專案都需要三層。判斷標準：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>需要 protocol integration test</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>被測元件直接對接外部協議（WS、gRPC、SMTP）</td>
          <td>是</td>
      </tr>
      <tr>
          <td>Mock 和真實服務之間有協議語意差異</td>
          <td>是</td>
      </tr>
      <tr>
          <td>外部服務可在本機啟動（成本低）</td>
          <td>強烈建議</td>
      </tr>
      <tr>
          <td>被測元件只做資料轉換（不碰網路）</td>
          <td>不需要</td>
      </tr>
      <tr>
          <td>外部服務只能在雲端啟動（成本高）</td>
          <td>用 contract test 替代</td>
      </tr>
  </tbody>
</table>
<p><strong>app_tunnel 的特殊優勢</strong>：server 和 client 都在同一台機器上。啟動 ttyd + proxy 然後跑 Dart test，成本極低但價值極高 — 三個實機問題中的兩個（text/binary frame、auth handshake）都能在這層直接抓到。</p>
<hr>
<h2 id="反模式用-mock-數量彌補-mock-盲區">反模式：用 mock 數量彌補 mock 盲區</h2>
<p>「192 個 test 全過」給了虛假的信心。常見的反應是「測試不夠多」然後再加更多 mock test，但問題在層級覆蓋 — 300 個用同一個 <code>FakeWebSocketChannel</code> 的 test 仍然抓不到 text vs binary frame。</p>
<p><strong>測試策略的品質用層級覆蓋衡量，而非數量。</strong> 一個對真實 ttyd 的 5 行 protocol test，比 50 個新增的 mock test 更能防止實機部署失敗。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本文的觀察和判讀在 <a href="/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="整理測試策略分層、協議整合驗證、客戶端可觀測性、錯誤收集與自動化驗證 — 從「測試全過但實機全壞」的結構性盲區出發，建立可操作的品質驗證體系">Testing 測試策略</a> 教學系列中展開為系統性的教學模組：<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>、<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/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">Protocol integration test</a>。</p>
]]></content:encoded></item></channel></rss>