<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>模組三：協議整合測試 on Tarragon</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/</link><description>Recent content in 模組三：協議整合測試 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/index.xml" rel="self" type="application/rss+xml"/><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>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>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>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></channel></rss>