<?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>Ttyd on Tarragon</title><link>https://tarrragon.github.io/blog/tags/ttyd/</link><description>Recent content in Ttyd 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/ttyd/index.xml" rel="self" type="application/rss+xml"/><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>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>