<?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>Testing on Tarragon</title><link>https://tarrragon.github.io/blog/tags/testing/</link><description>Recent content in Testing on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 29 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/testing/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>T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 &lt;code>sink.add(dynamic)&lt;/code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。&lt;code>ConnectionManager.sendData()&lt;/code> 接收 &lt;code>Uint8List&lt;/code> 型別的鍵盤輸入，直接傳給 &lt;code>_channel!.sink.add(data)&lt;/code>。Dart 的 &lt;code>IOWebSocketChannel&lt;/code> 對 &lt;code>Uint8List&lt;/code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>所有鍵盤輸入無效（使用者打字無回應）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>192 個全過（&lt;code>FakeWebSocketChannel.sink.add&lt;/code> 不區分型別）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功但終端機完全無反應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 忠實實作了 &lt;code>WebSocketChannel&lt;/code> 的 Dart interface — &lt;code>sink.add(dynamic)&lt;/code> 接受任何型別。但 &lt;code>IOWebSocketChannel&lt;/code> 的 &lt;code>sink.add&lt;/code> 實際行為是：&lt;code>String&lt;/code> → text frame，&lt;code>List&amp;lt;int&amp;gt;&lt;/code> / &lt;code>Uint8List&lt;/code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>ttyd 的靜默忽略放大了問題&lt;/strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>型別系統幫不上忙&lt;/strong>。Dart 的 &lt;code>WebSocketSink.add&lt;/code> 簽名是 &lt;code>void add(dynamic event)&lt;/code> — &lt;code>dynamic&lt;/code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 &lt;code>dynamic&lt;/code>，型別檢查無法區分協議語意。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test&lt;/strong>：對真實 ttyd 發送 &lt;code>Uint8List&lt;/code> 和 &lt;code>String&lt;/code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。&lt;/li>
&lt;li>&lt;strong>在 sendData 層做型別轉換&lt;/strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。&lt;/li>
&lt;li>&lt;strong>Log 送出的 frame type&lt;/strong>：&lt;code>developer.log('WS send: type=${data.runtimeType}')&lt;/code> 讓 debug 時立即可見。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想寫 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想理解 mock 遮蔽的系統性機制 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析&lt;/a>&lt;/li>
&lt;li>類似案例（auth handshake） → &lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2 Auth handshake 缺失&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 <code>sink.add(dynamic)</code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。<code>ConnectionManager.sendData()</code> 接收 <code>Uint8List</code> 型別的鍵盤輸入，直接傳給 <code>_channel!.sink.add(data)</code>。Dart 的 <code>IOWebSocketChannel</code> 對 <code>Uint8List</code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>所有鍵盤輸入無效（使用者打字無回應）</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>192 個全過（<code>FakeWebSocketChannel.sink.add</code> 不區分型別）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功但終端機完全無反應</td>
      </tr>
      <tr>
          <td>修復</td>
          <td><code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code></td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約</strong>。<code>FakeWebSocketChannel</code> 忠實實作了 <code>WebSocketChannel</code> 的 Dart interface — <code>sink.add(dynamic)</code> 接受任何型別。但 <code>IOWebSocketChannel</code> 的 <code>sink.add</code> 實際行為是：<code>String</code> → text frame，<code>List&lt;int&gt;</code> / <code>Uint8List</code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。</p>
</li>
<li>
<p><strong>ttyd 的靜默忽略放大了問題</strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。</p>
</li>
<li>
<p><strong>型別系統幫不上忙</strong>。Dart 的 <code>WebSocketSink.add</code> 簽名是 <code>void add(dynamic event)</code> — <code>dynamic</code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 <code>dynamic</code>，型別檢查無法區分協議語意。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test</strong>：對真實 ttyd 發送 <code>Uint8List</code> 和 <code>String</code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。</li>
<li><strong>在 sendData 層做型別轉換</strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。</li>
<li><strong>Log 送出的 frame type</strong>：<code>developer.log('WS send: type=${data.runtimeType}')</code> 讓 debug 時立即可見。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想寫 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>想理解 mock 遮蔽的系統性機制 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>類似案例（auth handshake） → <a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2 Auth handshake 缺失</a></li>
</ul>
]]></content:encoded></item><item><title>Widget test 的狀態覆蓋策略</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/</guid><description>&lt;p>Widget test 的狀態覆蓋策略是用&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>）作為 test case 的來源。矩陣的每一行（一個狀態）對應至少一個 test case，矩陣的每一欄（顯示 / 可用操作 / 退出路徑）對應該 test case 中的斷言。&lt;/p>
&lt;h2 id="從矩陣到-test-case-的轉換規則">從矩陣到 test case 的轉換規則&lt;/h2>
&lt;h3 id="每個狀態至少一個-test-case">每個狀態至少一個 test case&lt;/h3>
&lt;p>矩陣中的每一行代表畫面的一個狀態。每個狀態產生一個 test case，驗證三件事：&lt;/p>
&lt;ol>
&lt;li>該狀態下的顯示元素是否存在&lt;/li>
&lt;li>該狀態下的可用操作是否可觸發&lt;/li>
&lt;li>該狀態下的退出路徑是否可到達&lt;/li>
&lt;/ol>
&lt;p>以 app_tunnel Terminal 畫面為例，五個狀態產生五個 test case：&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="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;idle state shows blank and allows cancel&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">idle&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CircularProgressIndicator&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsNothing&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;cancel_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;error state shows message, retry, and back&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">error&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;連線失敗&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;retry_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="退出路徑是獨立的斷言">退出路徑是獨立的斷言&lt;/h3>
&lt;p>退出路徑驗證的是「使用者能否離開當前狀態」。斷言方式是 tap 退出按鈕後驗證導航是否發生：&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="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;error state back button navigates to home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">error&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">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>矩陣中退出路徑為空的狀態 = 沒有退出路徑的 test case = UX 死胡同。如果在填寫 test case 時發現某個狀態沒有退出路徑可以斷言，這本身就是設計缺口的發現。&lt;/p>
&lt;h2 id="覆蓋率的衡量">覆蓋率的衡量&lt;/h2>
&lt;p>Widget test 的狀態覆蓋率 = 有 test case 的狀態數 / 矩陣中的總狀態數。100% 代表矩陣中每個狀態都有對應的 test case。&lt;/p>
&lt;p>狀態覆蓋率和 line coverage 衡量不同的東西。Line coverage 衡量「程式碼中有多少行被執行過」，狀態覆蓋率衡量「設計中有多少狀態被驗證過」。一個狀態的 test case 可能覆蓋很少的程式碼行（只驗證特定狀態下的 UI），但確認了該狀態的設計意圖被正確實作。&lt;/p>
&lt;h2 id="狀態轉換的-test">狀態轉換的 test&lt;/h2>
&lt;p>除了靜態狀態的驗證，狀態之間的轉換也需要 test。矩陣的「進入條件」欄定義了觸發轉換的事件。&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="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;connecting transitions to connected on ws success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">connecting&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="c1">// 模擬 WebSocket 連線成功
&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="n">connectionManager&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">emit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ConnectionState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">connected&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TerminalView&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>狀態轉換 test 的數量 = 矩陣中的狀態轉換邊數。五個狀態的畫面可能有 8-12 條轉換邊，每條邊一個 test case。&lt;/p>
&lt;p>狀態覆蓋和轉換覆蓋確認畫面的邏輯正確性後，&lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test&lt;/a> 進一步驗證 back 按鈕和 route 可達性。矩陣本身的填寫方法和四欄定義見 &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣&lt;/a>。如果需要在視覺層面確認 UI 呈現的一致性，&lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對&lt;/a>提供 visual regression 的實作方式。&lt;/p></description><content:encoded><![CDATA[<p>Widget test 的狀態覆蓋策略是用<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>（<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）作為 test case 的來源。矩陣的每一行（一個狀態）對應至少一個 test case，矩陣的每一欄（顯示 / 可用操作 / 退出路徑）對應該 test case 中的斷言。</p>
<h2 id="從矩陣到-test-case-的轉換規則">從矩陣到 test case 的轉換規則</h2>
<h3 id="每個狀態至少一個-test-case">每個狀態至少一個 test case</h3>
<p>矩陣中的每一行代表畫面的一個狀態。每個狀態產生一個 test case，驗證三件事：</p>
<ol>
<li>該狀態下的顯示元素是否存在</li>
<li>該狀態下的可用操作是否可觸發</li>
<li>該狀態下的退出路徑是否可到達</li>
</ol>
<p>以 app_tunnel Terminal 畫面為例，五個狀態產生五個 test case：</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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;idle state shows blank and allows cancel&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">idle</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">CircularProgressIndicator</span><span class="p">),</span> <span class="n">findsNothing</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;cancel_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;error state shows message, retry, and back&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <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">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">error</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;連線失敗&#39;</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;retry_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><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><h3 id="退出路徑是獨立的斷言">退出路徑是獨立的斷言</h3>
<p>退出路徑驗證的是「使用者能否離開當前狀態」。斷言方式是 tap 退出按鈕後驗證導航是否發生：</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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;error state back button navigates to home&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">error</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>矩陣中退出路徑為空的狀態 = 沒有退出路徑的 test case = UX 死胡同。如果在填寫 test case 時發現某個狀態沒有退出路徑可以斷言，這本身就是設計缺口的發現。</p>
<h2 id="覆蓋率的衡量">覆蓋率的衡量</h2>
<p>Widget test 的狀態覆蓋率 = 有 test case 的狀態數 / 矩陣中的總狀態數。100% 代表矩陣中每個狀態都有對應的 test case。</p>
<p>狀態覆蓋率和 line coverage 衡量不同的東西。Line coverage 衡量「程式碼中有多少行被執行過」，狀態覆蓋率衡量「設計中有多少狀態被驗證過」。一個狀態的 test case 可能覆蓋很少的程式碼行（只驗證特定狀態下的 UI），但確認了該狀態的設計意圖被正確實作。</p>
<h2 id="狀態轉換的-test">狀態轉換的 test</h2>
<p>除了靜態狀態的驗證，狀態之間的轉換也需要 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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;connecting transitions to connected on ws success&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">connecting</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// 模擬 WebSocket 連線成功
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="n">connectionManager</span><span class="p">.</span><span class="n">emit</span><span class="p">(</span><span class="n">ConnectionState</span><span class="p">.</span><span class="n">connected</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalView</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>狀態轉換 test 的數量 = 矩陣中的狀態轉換邊數。五個狀態的畫面可能有 8-12 條轉換邊，每條邊一個 test case。</p>
<p>狀態覆蓋和轉換覆蓋確認畫面的邏輯正確性後，<a href="/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test</a> 進一步驗證 back 按鈕和 route 可達性。矩陣本身的填寫方法和四欄定義見 <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣</a>。如果需要在視覺層面確認 UI 呈現的一致性，<a href="/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對</a>提供 visual regression 的實作方式。</p>
]]></content:encoded></item><item><title>三層 log 設計</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/</guid><description>&lt;p>客戶端 log 分成三層，每層記錄不同粒度的資訊，服務不同的 debug 場景。三層的區別在於回答的問題不同：連線生命週期回答「整體流程走到哪一步」，protocol 訊息回答「通訊細節是什麼」，使用者行為回答「使用者做了什麼操作」。&lt;/p>
&lt;h2 id="連線生命週期-log">連線生命週期 log&lt;/h2>
&lt;p>連線生命週期 log 記錄的是「流程走到第幾步、每步成功或失敗」。這一層的 log 粒度是步驟級 — 不記錄每一個封包或每一次函式呼叫，只記錄流程中的關鍵節點。&lt;/p>
&lt;p>以 app_tunnel 的連線流程為例，連線生命週期包含五步：biometric 認證 → credential 讀取 → WebSocket 連線 → auth token 發送 → stream 訂閱。每步完成時記一條 log，失敗時記一條包含原因的 log。&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">[conn] Step 1/5: biometric auth completed (duration: 320ms)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[conn] Step 2/5: credential loaded (user: admin)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[conn] Step 3/5: WebSocket connected (url: wss://...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">[conn] Step 4/5: auth token sent
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">[conn] Step 5/5: stream subscribed, ready&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>app_tunnel 在實機測試前六個核心元件中只有兩個有 log，且全是 W2 修復時事後補上的（&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>）。W2-002 auth token 問題的 debug 過程中，開發者無法從任何 log 判斷失敗發生在五步中的哪一步。如果有連線生命週期 log，第一次連線就能看到「Step 3 完成，Step 4 未執行」— 直接定位到 auth token 缺失。&lt;/p>
&lt;p>連線生命週期 log 在所有模式（debug 和 release）都應該啟用。這層 log 量小（每次連線 5-10 條），不影響效能，但在 production 問題回報時是第一手資訊來源。&lt;/p>
&lt;h2 id="protocol-訊息-log">Protocol 訊息 log&lt;/h2>
&lt;p>Protocol 訊息 log 記錄的是通訊協議層面的細節：發送和接收的 frame type、payload 前綴、handshake 參數、逾時值。這一層的粒度比連線生命週期更細 — 每一次 send/receive 都記錄。&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">[proto] TX: text frame, payload: {&amp;#34;AuthToken&amp;#34;:&amp;#34;base64...&amp;#34;} (42 bytes)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">[proto] RX: text frame, payload prefix: &amp;#34;0&amp;#34; (output data, 128 bytes)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">[proto] TX: binary frame, payload: [72, 101, 108, 108, 111] (5 bytes)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Protocol log 在 debug 時幫助確認「程式碼發送了什麼、收到了什麼」。app_tunnel 的 text/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>）如果有 protocol log，開發者會在 log 中看到 &lt;code>TX: binary frame&lt;/code> 而非預期的 &lt;code>TX: text frame&lt;/code> — 直接指向 frame type 問題。&lt;/p></description><content:encoded><![CDATA[<p>客戶端 log 分成三層，每層記錄不同粒度的資訊，服務不同的 debug 場景。三層的區別在於回答的問題不同：連線生命週期回答「整體流程走到哪一步」，protocol 訊息回答「通訊細節是什麼」，使用者行為回答「使用者做了什麼操作」。</p>
<h2 id="連線生命週期-log">連線生命週期 log</h2>
<p>連線生命週期 log 記錄的是「流程走到第幾步、每步成功或失敗」。這一層的 log 粒度是步驟級 — 不記錄每一個封包或每一次函式呼叫，只記錄流程中的關鍵節點。</p>
<p>以 app_tunnel 的連線流程為例，連線生命週期包含五步：biometric 認證 → credential 讀取 → WebSocket 連線 → auth token 發送 → stream 訂閱。每步完成時記一條 log，失敗時記一條包含原因的 log。</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">[conn] Step 1/5: biometric auth completed (duration: 320ms)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[conn] Step 2/5: credential loaded (user: admin)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[conn] Step 3/5: WebSocket connected (url: wss://...)
</span></span><span class="line"><span class="ln">4</span><span class="cl">[conn] Step 4/5: auth token sent
</span></span><span class="line"><span class="ln">5</span><span class="cl">[conn] Step 5/5: stream subscribed, ready</span></span></code></pre></div><p>app_tunnel 在實機測試前六個核心元件中只有兩個有 log，且全是 W2 修復時事後補上的（<a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a>）。W2-002 auth token 問題的 debug 過程中，開發者無法從任何 log 判斷失敗發生在五步中的哪一步。如果有連線生命週期 log，第一次連線就能看到「Step 3 完成，Step 4 未執行」— 直接定位到 auth token 缺失。</p>
<p>連線生命週期 log 在所有模式（debug 和 release）都應該啟用。這層 log 量小（每次連線 5-10 條），不影響效能，但在 production 問題回報時是第一手資訊來源。</p>
<h2 id="protocol-訊息-log">Protocol 訊息 log</h2>
<p>Protocol 訊息 log 記錄的是通訊協議層面的細節：發送和接收的 frame type、payload 前綴、handshake 參數、逾時值。這一層的粒度比連線生命週期更細 — 每一次 send/receive 都記錄。</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">[proto] TX: text frame, payload: {&#34;AuthToken&#34;:&#34;base64...&#34;} (42 bytes)
</span></span><span class="line"><span class="ln">2</span><span class="cl">[proto] RX: text frame, payload prefix: &#34;0&#34; (output data, 128 bytes)
</span></span><span class="line"><span class="ln">3</span><span class="cl">[proto] TX: binary frame, payload: [72, 101, 108, 108, 111] (5 bytes)</span></span></code></pre></div><p>Protocol log 在 debug 時幫助確認「程式碼發送了什麼、收到了什麼」。app_tunnel 的 text/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>）如果有 protocol log，開發者會在 log 中看到 <code>TX: binary frame</code> 而非預期的 <code>TX: text frame</code> — 直接指向 frame type 問題。</p>
<p>Protocol log 在 release mode 應該能關閉。這層 log 量大（每次鍵盤輸入一條），且 payload 可能包含敏感資訊。Debug mode 預設啟用，release mode 提供開關（例如隱藏設定頁的 toggle）讓進階使用者在回報問題時開啟。</p>
<h2 id="使用者行為-log">使用者行為 log</h2>
<p>使用者行為 log 記錄的是使用者在 UI 上的操作：按鈕點擊、畫面切換、設定變更。這層 log 的粒度是操作級 — 使用者做了一個有意義的動作記一條。</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">[ui] screen: HomeScreen, action: tap Connect Terminal
</span></span><span class="line"><span class="ln">2</span><span class="cl">[ui] screen: TerminalScreen, state: connecting → connected
</span></span><span class="line"><span class="ln">3</span><span class="cl">[ui] screen: TerminalScreen, action: tap back button
</span></span><span class="line"><span class="ln">4</span><span class="cl">[ui] screen: HomeScreen, state: returned from terminal</span></span></code></pre></div><p>使用者行為 log 在兩個場景有價值：第一，debug 時還原使用者操作路徑 — 「使用者做了什麼導致問題出現」；第二，結合狀態矩陣（<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）做狀態轉換的實際覆蓋率分析 — 哪些狀態轉換在真實使用中經常發生，哪些從未發生。</p>
<p>使用者行為 log 在 release mode 啟用時需要注意隱私。記錄「使用者切換了畫面」是合理的；記錄「使用者輸入了密碼 abc123」需要 redaction 機制（<a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>）。</p>
<h2 id="三層的關係">三層的關係</h2>
<p>三層 log 各自獨立運作，debug 時通常按照從粗到細的順序使用。</p>
<p><strong>粗篩</strong>：先看連線生命週期 log，確認流程走到哪一步。如果 Step 3 失敗，問題在 WebSocket 連線層。</p>
<p><strong>細查</strong>：切到 protocol 訊息 log，看 Step 3 的連線嘗試中發送和接收了什麼。如果看到 binary frame 發送但沒有回應，問題可能在 frame type。</p>
<p><strong>還原</strong>：如果問題和使用者操作有關（例如只在特定操作順序下觸發），看使用者行為 log，還原操作路徑。</p>
<p>三層 log 用同一個時間戳和 correlation ID（例如連線 session ID），讓跨層比對可行。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>在功能規格中定義 log 點 → <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a></li>
<li>事後補 log 和設計產物 log 的品質差異 → <a href="/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異</a></li>
<li>Log 收集方案選擇 → <a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a></li>
<li>事件分類與收集策略 → <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一 監控心智模型</a></li>
</ul>
]]></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>5.1 時間注入與狀態轉移測試</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/</guid><description>&lt;p>時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a>、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、ticker 或過期判斷，測試就不應依賴真實等待。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>用 &lt;code>now time.Time&lt;/code> 測試純狀態轉移&lt;/li>
&lt;li>用 &lt;code>func() time.Time&lt;/code> 注入長生命週期元件的時間來源&lt;/li>
&lt;li>用 table-driven test 覆蓋時間邊界&lt;/li>
&lt;li>把 ticker 排程與單次工作拆開測&lt;/li>
&lt;li>避免 &lt;code>time.Sleep&lt;/code> 造成慢且不穩定的測試&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察直接呼叫-timenow-會讓測試失去控制">【觀察】直接呼叫 time.Now 會讓測試失去控制&lt;/h2>
&lt;p>時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 &lt;code>time.Now()&lt;/code>，測試就無法完整控制輸入。&lt;/p>
&lt;p>反模式：&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">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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="k">if&lt;/span> &lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &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="k">if&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Since&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span> &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="k">return&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &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">return&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&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;/code>&lt;/pre>&lt;/div>&lt;p>這個函式看起來簡單，但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間，結果慢且不穩定。&lt;/p>
&lt;h2 id="判讀時間是狀態轉移的輸入">【判讀】時間是狀態轉移的輸入&lt;/h2>
&lt;p>時間測試的核心判讀是：如果時間會影響結果，時間就是輸入。把 &lt;code>now&lt;/code> 放進函式簽名，會讓狀態轉移規則變得可測。&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">type&lt;/span> &lt;span class="nx">Job&lt;/span> &lt;span class="kd">struct&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">StartedAt&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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">FinishedAt&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&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="kd">func&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">FinishedAt&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sub&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">&amp;gt;&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&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="k">return&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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>now&lt;/code> 是明確輸入，因此測試可以建立任何時間點。這也讓讀者一眼看出 &lt;code>Status&lt;/code> 看的是 &lt;code>Job&lt;/code> 與目前時間的關係。&lt;/p>
&lt;h2 id="執行用-table-driven-test-描述時間邊界">【執行】用 table-driven test 描述時間邊界&lt;/h2>
&lt;p>時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變，table-driven test 能讓這些情境集中呈現。&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">TestStatus&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">startedAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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="nx">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&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;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;active before idle threshold&amp;#34;&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">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">4&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&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="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&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="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;active&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;idle after threshold&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">6&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;completed ignores idle threshold&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">30&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">job&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">startedAt&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="nx">FinishedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nf">ptrTime&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">startedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>&lt;span class="p">)),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;completed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&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">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">job&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&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;Status() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&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>time.Sleep&lt;/code>。案例名稱直接描述時間邊界，失敗時能快速定位是哪個規則壞了。&lt;/p>
&lt;h2 id="策略長生命週期元件用-time-provider">【策略】長生命週期元件用 time provider&lt;/h2>
&lt;p>Time provider 的核心用途是讓元件在多個方法中取得時間，但測試仍能控制時間來源。最輕量的形式是 &lt;code>func() time.Time&lt;/code>。&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">type&lt;/span> &lt;span class="nx">Monitor&lt;/span> &lt;span class="kd">struct&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&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="kd">func&lt;/span> &lt;span class="nf">NewMonitor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Monitor&lt;/span> &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="k">if&lt;/span> &lt;span class="nx">now&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">now&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Now&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &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="k">return&lt;/span> &lt;span class="nx">Monitor&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span> &lt;span class="nx">Monitor&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Snapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">job&lt;/span> &lt;span class="nx">Job&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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="k">return&lt;/span> &lt;span class="nf">Status&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="nx">job&lt;/span>&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;/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">TestMonitorSnapshot&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">fixedNow&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">monitor&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewMonitor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="k">return&lt;/span> &lt;span class="nx">fixedNow&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">monitor&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Snapshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">Job&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="nx">StartedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">fixedNow&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">10&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;idle&amp;#34;&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="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;snapshot = %q, want idle&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&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>這比導入大型 clock framework 更輕量，也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求，再考慮統一 clock interface。&lt;/p></description><content:encoded><![CDATA[<p>時間控制測試的核心原則是把「現在」變成可指定輸入。只要程式邏輯依賴目前時間、<a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a>、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、ticker 或過期判斷，測試就不應依賴真實等待。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>用 <code>now time.Time</code> 測試純狀態轉移</li>
<li>用 <code>func() time.Time</code> 注入長生命週期元件的時間來源</li>
<li>用 table-driven test 覆蓋時間邊界</li>
<li>把 ticker 排程與單次工作拆開測</li>
<li>避免 <code>time.Sleep</code> 造成慢且不穩定的測試</li>
</ol>
<hr>
<h2 id="觀察直接呼叫-timenow-會讓測試失去控制">【觀察】直接呼叫 time.Now 會讓測試失去控制</h2>
<p>時間相關邏輯的核心問題是同一筆資料在不同時間會得到不同結果。若函式內部直接呼叫 <code>time.Now()</code>，測試就無法完整控制輸入。</p>
<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">Status</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;completed&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">if</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Since</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StartedAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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="s">&#34;idle&#34;</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="k">return</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式看起來簡單，但測試無法指定「現在剛好是開始後 4 分鐘」或「現在剛好跨過 5 分鐘」。測試只能依賴真實時間，結果慢且不穩定。</p>
<h2 id="判讀時間是狀態轉移的輸入">【判讀】時間是狀態轉移的輸入</h2>
<p>時間測試的核心判讀是：如果時間會影響結果，時間就是輸入。把 <code>now</code> 放進函式簽名，會讓狀態轉移規則變得可測。</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">type</span> <span class="nx">Job</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">StartedAt</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">FinishedAt</span> <span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><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="kd">func</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</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="nx">job</span><span class="p">.</span><span class="nx">FinishedAt</span> <span class="o">!=</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">return</span> <span class="s">&#34;completed&#34;</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">now</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">job</span><span class="p">.</span><span class="nx">StartedAt</span><span class="p">)</span> <span class="p">&gt;</span> <span class="mi">5</span><span class="o">*</span><span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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="s">&#34;idle&#34;</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="k">return</span> <span class="s">&#34;active&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>now</code> 是明確輸入，因此測試可以建立任何時間點。這也讓讀者一眼看出 <code>Status</code> 看的是 <code>Job</code> 與目前時間的關係。</p>
<h2 id="執行用-table-driven-test-描述時間邊界">【執行】用 table-driven test 描述時間邊界</h2>
<p>時間邊界的核心測試方式是列出切換點前後的案例。狀態通常在某個 duration 前後改變，table-driven test 能讓這些情境集中呈現。</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">TestStatus</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">startedAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">now</span>  <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">job</span>  <span class="nx">Job</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">want</span> <span class="kt">string</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><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;active before idle threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">4</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">job</span><span class="p">:</span>  <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;idle after threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">6</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">job</span><span class="p">:</span>  <span class="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">startedAt</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;idle&#34;</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><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">name</span><span class="p">:</span> <span class="s">&#34;completed ignores idle threshold&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">now</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">30</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">job</span><span class="p">:</span> <span class="nx">Job</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">                <span class="nx">StartedAt</span><span class="p">:</span>  <span class="nx">startedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">                <span class="nx">FinishedAt</span><span class="p">:</span> <span class="nf">ptrTime</span><span class="p">(</span><span class="nx">startedAt</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">Minute</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;completed&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">35</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">now</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">job</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">37</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;Status() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要 <code>time.Sleep</code>。案例名稱直接描述時間邊界，失敗時能快速定位是哪個規則壞了。</p>
<h2 id="策略長生命週期元件用-time-provider">【策略】長生命週期元件用 time provider</h2>
<p>Time provider 的核心用途是讓元件在多個方法中取得時間，但測試仍能控制時間來源。最輕量的形式是 <code>func() time.Time</code>。</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">type</span> <span class="nx">Monitor</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><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="kd">func</span> <span class="nf">NewMonitor</span><span class="p">(</span><span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">Monitor</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="nx">now</span> <span class="o">==</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">now</span> <span class="p">=</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Now</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="k">return</span> <span class="nx">Monitor</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">now</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="kd">func</span> <span class="p">(</span><span class="nx">m</span> <span class="nx">Monitor</span><span class="p">)</span> <span class="nf">Snapshot</span><span class="p">(</span><span class="nx">job</span> <span class="nx">Job</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nf">Status</span><span class="p">(</span><span class="nx">m</span><span class="p">.</span><span class="nf">now</span><span class="p">(),</span> <span class="nx">job</span><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>測試提供固定時間：</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">TestMonitorSnapshot</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">fixedNow</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">monitor</span> <span class="o">:=</span> <span class="nf">NewMonitor</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">fixedNow</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nx">monitor</span><span class="p">.</span><span class="nf">Snapshot</span><span class="p">(</span><span class="nx">Job</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">fixedNow</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="o">-</span><span class="mi">10</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="s">&#34;idle&#34;</span> <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">Fatalf</span><span class="p">(</span><span class="s">&#34;snapshot = %q, want idle&#34;</span><span class="p">,</span> <span class="nx">got</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>這比導入大型 clock framework 更輕量，也比在測試裡等待真實時間更可靠。若整個專案有大量時間需求，再考慮統一 clock interface。</p>
<h2 id="判讀ticker-測試要拆排程與工作">【判讀】Ticker 測試要拆排程與工作</h2>
<p>Ticker 的核心問題是它同時包含「何時觸發」與「觸發時做什麼」。測試時應把單次工作抽出來，避免為了測狀態規則而等待 ticker。</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">type</span> <span class="nx">Worker</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">syncOnce</span> <span class="kd">func</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><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="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">interval</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">ticker</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">NewTicker</span><span class="p">(</span><span class="nx">interval</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">defer</span> <span class="nx">ticker</span><span class="p">.</span><span class="nf">Stop</span><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="k">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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">ctx</span><span class="p">.</span><span class="nf">Err</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ticker</span><span class="p">.</span><span class="nx">C</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">w</span><span class="p">.</span><span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</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">15</span><span class="cl">                <span class="k">return</span> <span class="nx">err</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 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="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">w</span> <span class="nx">Worker</span><span class="p">)</span> <span class="nf">SyncOnce</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">return</span> <span class="nx">w</span><span class="p">.</span><span class="nf">syncOnce</span><span class="p">(</span><span class="nx">ctx</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>SyncOnce</code> 可以單獨測規則，<code>Run</code> 只需要少數測試確認 context 取消與 ticker 排程。不要讓每個狀態測試都真的啟動 ticker。</p>
<h2 id="測試run-測試應用-context-控制退出">【測試】Run 測試應用 context 控制退出</h2>
<p>長生命週期 worker 的測試核心是讓退出條件可控。若只想測 context 取消，先取消 context 再呼叫 <code>Run</code>。</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">TestRunStopsWhenContextCanceled</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nf">cancel</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">worker</span> <span class="o">:=</span> <span class="nx">Worker</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">syncOnce</span><span class="p">:</span> <span class="kd">func</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</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;syncOnce should not be called&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="k">return</span> <span class="kc">nil</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><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">err</span> <span class="o">:=</span> <span class="nx">worker</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Hour</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">errors</span><span class="p">.</span><span class="nf">Is</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Canceled</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;Run() error = %v, want context canceled&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試不需要等待一小時。<code>time.Hour</code> 只是確保 ticker 不會在測試中自然觸發，真正的退出由 context 控制。</p>
<h2 id="判讀sleep-based-test-應該是例外">【判讀】sleep-based test 應該是例外</h2>
<p>Sleep-based test 的核心問題是慢、不穩定、難以定位。排程、CI 負載與機器速度都可能讓測試偶發失敗。</p>
<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">TestStatusWithSleep</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">start</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">3</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">6</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">got</span> <span class="o">:=</span> <span class="nf">Status</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="nx">Job</span><span class="p">{</span><span class="nx">StartedAt</span><span class="p">:</span> <span class="nx">start</span><span class="p">})</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">got</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種測試不應存在。它拖慢測試套件，仍然不能保證結果穩定。正確做法是直接建構 <code>now</code> 與 <code>StartedAt</code>。</p>
<p>若真的要等待非同步事件，應使用 deadline 與條件重試，而不是固定 sleep；下一章的 integration test 會使用這個原則。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理時間作為輸入的可測性；更完整的 fake clock 與平台 timeout 合約，會在下列章節再往外延伸：</p>
<ul>
<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>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 defer、select loop 與 timeout 邊界；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/03-stdlib/defer-cleanup/" data-link-title="3.8 defer 與資源清理" data-link-desc="用 defer 管理 close、unlock、cleanup 與 panic 邊界">Go：defer 與資源清理</a></li>
<li><a href="/blog/go/04-concurrency/select/" data-link-title="4.3 select：同時等待多種事件" data-link-desc="用 select 建立事件迴圈">Go：select：同時等待多種事件</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</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/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/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>時間控制測試的重點是把時間變成可指定輸入。純邏輯用 <code>now time.Time</code>，長生命週期元件用 <code>func() time.Time</code>，ticker 排程和單次工作分開測。避免 <code>time.Sleep</code>，測試才會快速、穩定且可重現。</p>
]]></content:encoded></item><item><title>7.1 把 handler 邏輯拆成可測單元</title><link>https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/07-refactoring/handler-boundary/</guid><description>&lt;p>handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response，usecase 應處理行為規則，domain 應保存狀態語意。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>辨識 handler 過重的訊號&lt;/li>
&lt;li>把 request DTO 與 command 分開&lt;/li>
&lt;li>把業務規則搬到 usecase&lt;/li>
&lt;li>讓 handler 只做 request/response 轉換&lt;/li>
&lt;li>分開撰寫 usecase test、handler test 與少量 integration test&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察過重-handler-會混合三種責任">【觀察】過重 handler 會混合三種責任&lt;/h2>
&lt;p>handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response，它就很難測，也很難重用。&lt;/p>
&lt;p>常見壞味道：&lt;/p>
&lt;ul>
&lt;li>handler 超過一兩個螢幕。&lt;/li>
&lt;li>測試核心規則必須透過 HTTP。&lt;/li>
&lt;li>JSON tag 出現在 domain type 上。&lt;/li>
&lt;li>handler 直接改 repository 的 map 或 slice。&lt;/li>
&lt;li>多個 handler 重複同樣的驗證與錯誤 mapping。&lt;/li>
&lt;li>想新增 CLI、worker 或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> action 時，只能複製 handler 內的邏輯。&lt;/li>
&lt;/ul>
&lt;p>以下是一個過重的建立通知 handler：&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">var&lt;/span> &lt;span class="nx">notifications&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">map&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="nx">Notification&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">handleCreateNotification&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&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="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&lt;/span>&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="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">req&lt;/span> &lt;span class="kd">struct&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="nx">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&amp;#34;`&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">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&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">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&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="k">if&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">);&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">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;invalid json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&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"> &lt;span class="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&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">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;missing required field&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusBadRequest&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">exists&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">];&lt;/span> &lt;span class="nx">exists&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;notification already exists&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusConflict&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">notification&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">Notification&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Title&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="nx">notifications&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">notification&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WriteHeader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewEncoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Encode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">notification&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這段程式可以跑，但它把太多責任放進 HTTP 邊界。只要要測「重複 ID 不可建立」，就必須走 HTTP；只要要改儲存方式，就必須改 handler。&lt;/p>
&lt;h2 id="判讀先拆-request-dto">【判讀】先拆 request DTO&lt;/h2>
&lt;p>request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag，但不應直接當成 domain model 或 repository model。&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">type&lt;/span> &lt;span class="nx">createNotificationRequest&lt;/span> &lt;span class="kd">struct&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">ID&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;id&amp;#34;`&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">Topic&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;topic&amp;#34;`&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">Title&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;title&amp;#34;`&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="nx">createNotificationRequest&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">validate&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">error&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">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&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="k">return&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Field&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Reason&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;required&amp;#34;&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="k">if&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&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="k">return&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Field&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">Reason&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;required&amp;#34;&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="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DTO 可以是 unexported，因為它只服務 HTTP handler。JSON tag 也停在 transport layer，不會污染 application command。&lt;/p>
&lt;p>錯誤可以先用簡單型別表達：&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">type&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span> &lt;span class="kd">struct&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">Field&lt;/span> &lt;span class="kt">string&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">Reason&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&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="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">e&lt;/span> &lt;span class="nx">ErrInvalidInput&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Error&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="kt">string&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">return&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Field&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s">&amp;#34;: &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">e&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Reason&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個錯誤型別讓 handler 可以把輸入錯誤轉成 &lt;code>400 Bad Request&lt;/code>，而不必靠字串比對。&lt;/p></description><content:encoded><![CDATA[<p>handler 重構的核心目標是把 transport concern 和 application concern 分開。handler 應處理 request/response，usecase 應處理行為規則，domain 應保存狀態語意。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>辨識 handler 過重的訊號</li>
<li>把 request DTO 與 command 分開</li>
<li>把業務規則搬到 usecase</li>
<li>讓 handler 只做 request/response 轉換</li>
<li>分開撰寫 usecase test、handler test 與少量 integration test</li>
</ol>
<hr>
<h2 id="觀察過重-handler-會混合三種責任">【觀察】過重 handler 會混合三種責任</h2>
<p>handler 過重的核心問題是 transport、application 與 state concern 混在同一個函式。當一個 handler 同時解析 JSON、驗證欄位、檢查重複、修改 map、組 response，它就很難測，也很難重用。</p>
<p>常見壞味道：</p>
<ul>
<li>handler 超過一兩個螢幕。</li>
<li>測試核心規則必須透過 HTTP。</li>
<li>JSON tag 出現在 domain type 上。</li>
<li>handler 直接改 repository 的 map 或 slice。</li>
<li>多個 handler 重複同樣的驗證與錯誤 mapping。</li>
<li>想新增 CLI、worker 或 <a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> action 時，只能複製 handler 內的邏輯。</li>
</ul>
<p>以下是一個過重的建立通知 handler：</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">var</span> <span class="nx">notifications</span> <span class="p">=</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">{}</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kd">func</span> <span class="nf">handleCreateNotification</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">return</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="kd">var</span> <span class="nx">req</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">ID</span>    <span class="kt">string</span> <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</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="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">req</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">15</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;invalid json&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</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">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="o">||</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;missing required field&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">exists</span> <span class="o">:=</span> <span class="nx">notifications</span><span class="p">[</span><span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">];</span> <span class="nx">exists</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;notification already exists&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusConflict</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nx">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">req</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">req</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="nx">notifications</span><span class="p">[</span><span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">]</span> <span class="p">=</span> <span class="nx">notification</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式可以跑，但它把太多責任放進 HTTP 邊界。只要要測「重複 ID 不可建立」，就必須走 HTTP；只要要改儲存方式，就必須改 handler。</p>
<h2 id="判讀先拆-request-dto">【判讀】先拆 request DTO</h2>
<p>request DTO 的核心責任是描述外部輸入格式。它可以有 JSON tag，但不應直接當成 domain model 或 repository model。</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">type</span> <span class="nx">createNotificationRequest</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>    <span class="kt">string</span> <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span> <span class="kt">string</span> <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span> <span class="kt">string</span> <span class="s">`json:&#34;title&#34;`</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">r</span> <span class="nx">createNotificationRequest</span><span class="p">)</span> <span class="nf">validate</span><span class="p">()</span> <span class="kt">error</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">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#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="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</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="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>DTO 可以是 unexported，因為它只服務 HTTP handler。JSON tag 也停在 transport layer，不會污染 application command。</p>
<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">type</span> <span class="nx">ErrInvalidInput</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">Field</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Reason</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><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="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">ErrInvalidInput</span><span class="p">)</span> <span class="nf">Error</span><span class="p">()</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">return</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Field</span> <span class="o">+</span> <span class="s">&#34;: &#34;</span> <span class="o">+</span> <span class="nx">e</span><span class="p">.</span><span class="nx">Reason</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個錯誤型別讓 handler 可以把輸入錯誤轉成 <code>400 Bad Request</code>，而不必靠字串比對。</p>
<h2 id="策略command-表達-usecase-輸入">【策略】command 表達 usecase 輸入</h2>
<p>command 的核心責任是描述 application layer 要執行的行為。它不需要 JSON tag，也不需要知道 request body 來自 HTTP、WebSocket 或 CLI。</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">type</span> <span class="nx">CreateNotificationCommand</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">Topic</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>handler 負責 DTO -&gt; command 的轉換：</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="p">(</span><span class="nx">r</span> <span class="nx">createNotificationRequest</span><span class="p">)</span> <span class="nf">toCommand</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">CreateNotificationCommand</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">ID</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Topic</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Title</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">now</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="p">}</span></span></span></code></pre></div><p><code>CreatedAt</code> 由 handler 或 usecase 決定都可以，但要一致。若時間是業務規則的一部分，通常由 usecase 注入 clock 會更穩；若只是 request 接收時間，handler 傳入也合理。重點是不要在測試中散落 <code>time.Now()</code>。</p>
<h2 id="執行usecase-保存行為規則">【執行】usecase 保存行為規則</h2>
<p>usecase 的核心責任是處理行為規則與資料能力。重複檢查、儲存、事件發布或狀態轉移應該在 usecase，而不是 handler。</p>
<p>先定義 usecase 需要的 repository：</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">type</span> <span class="nx">NotificationRepository</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>再定義 service：</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">type</span> <span class="nx">CreateNotificationUsecase</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">repository</span> <span class="nx">NotificationRepository</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><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="kd">func</span> <span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repository</span> <span class="nx">NotificationRepository</span><span class="p">)</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</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="o">&amp;</span><span class="nx">CreateNotificationUsecase</span><span class="p">{</span><span class="nx">repository</span><span class="p">:</span> <span class="nx">repository</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>執行 command：</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="p">(</span><span class="nx">u</span> <span class="o">*</span><span class="nx">CreateNotificationUsecase</span><span class="p">)</span> <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;id&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">if</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span> <span class="o">==</span> <span class="s">&#34;&#34;</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">Notification</span><span class="p">{},</span> <span class="nx">ErrInvalidInput</span><span class="p">{</span><span class="nx">Field</span><span class="p">:</span> <span class="s">&#34;topic&#34;</span><span class="p">,</span> <span class="nx">Reason</span><span class="p">:</span> <span class="s">&#34;required&#34;</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">exists</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</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="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;find notification: %w&#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 class="k">else</span> <span class="k">if</span> <span class="nx">exists</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">Notification</span><span class="p">{},</span> <span class="nx">ErrAlreadyExists</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</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">notification</span> <span class="o">:=</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">cmd</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">cmd</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">cmd</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">u</span><span class="p">.</span><span class="nx">repository</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">notification</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">23</span><span class="cl">        <span class="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;save notification: %w&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ErrAlreadyExists</code> 可以是明確錯誤型別：</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">type</span> <span class="nx">ErrAlreadyExists</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">ID</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><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="kd">func</span> <span class="p">(</span><span class="nx">e</span> <span class="nx">ErrAlreadyExists</span><span class="p">)</span> <span class="nf">Error</span><span class="p">()</span> <span class="kt">string</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="s">&#34;notification already exists: &#34;</span> <span class="o">+</span> <span class="nx">e</span><span class="p">.</span><span class="nx">ID</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這樣 handler 可以用 <code>errors.As</code> 把它對應到 <code>409 Conflict</code>。</p>
<h2 id="執行handler-只做轉換與-mapping">【執行】handler 只做轉換與 mapping</h2>
<p>重構後 handler 的核心責任是 request -&gt; command、result -&gt; response、error -&gt; HTTP status。它不直接碰 map，也不保存業務規則。</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">type</span> <span class="nx">NotificationCreator</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><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="kd">type</span> <span class="nx">NotificationHandler</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">creator</span> <span class="nx">NotificationCreator</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">now</span>     <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kd">func</span> <span class="nf">NewNotificationHandler</span><span class="p">(</span><span class="nx">creator</span> <span class="nx">NotificationCreator</span><span class="p">,</span> <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">NotificationHandler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">NotificationHandler</span><span class="p">{</span><span class="nx">creator</span><span class="p">:</span> <span class="nx">creator</span><span class="p">,</span> <span class="nx">now</span><span class="p">:</span> <span class="nx">now</span><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>handler 實作：</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="p">(</span><span class="nx">h</span> <span class="nx">NotificationHandler</span><span class="p">)</span> <span class="nf">Create</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">,</span> <span class="s">&#34;method_not_allowed&#34;</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kd">var</span> <span class="nx">req</span> <span class="nx">createNotificationRequest</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="nx">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">req</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"> 9</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_json&#34;</span><span class="p">,</span> <span class="s">&#34;request body must be valid JSON&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">req</span><span class="p">.</span><span class="nf">validate</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">14</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_input&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">.</span><span class="nf">Error</span><span class="p">())</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="k">return</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">notification</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">h</span><span class="p">.</span><span class="nx">creator</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nf">Context</span><span class="p">(),</span> <span class="nx">req</span><span class="p">.</span><span class="nf">toCommand</span><span class="p">(</span><span class="nx">h</span><span class="p">.</span><span class="nf">now</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">err</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="nf">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="k">return</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">,</span> <span class="nf">newNotificationResponse</span><span class="p">(</span><span class="nx">notification</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 handler 仍然有 HTTP 協定責任，但核心行為已經搬出去。未來 WebSocket action 或 worker 也可以建立 <code>CreateNotificationCommand</code> 呼叫同一個 usecase。</p>
<h2 id="策略response-struct-是對外-contract">【策略】response struct 是對外 contract</h2>
<p>response struct 的核心責任是描述 HTTP 回應格式。不要直接把 domain model 全部輸出，否則內部欄位會變成外部 API 承諾。</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">type</span> <span class="nx">notificationResponse</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">ID</span>        <span class="kt">string</span>    <span class="s">`json:&#34;id&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Topic</span>     <span class="kt">string</span>    <span class="s">`json:&#34;topic&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">Title</span>     <span class="kt">string</span>    <span class="s">`json:&#34;title&#34;`</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">CreatedAt</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="s">`json:&#34;createdAt&#34;`</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">func</span> <span class="nf">newNotificationResponse</span><span class="p">(</span><span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="nx">notificationResponse</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">notificationResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="nx">notification</span><span class="p">.</span><span class="nx">ID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">Topic</span><span class="p">:</span>     <span class="nx">notification</span><span class="p">.</span><span class="nx">Topic</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="nx">notification</span><span class="p">.</span><span class="nx">Title</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">notification</span><span class="p">.</span><span class="nx">CreatedAt</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 class="p">}</span></span></span></code></pre></div><p>error response 也應該穩定：</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">type</span> <span class="nx">errorResponse</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">Code</span>    <span class="kt">string</span> <span class="s">`json:&#34;code&#34;`</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">Message</span> <span class="kt">string</span> <span class="s">`json:&#34;message&#34;`</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><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="kd">func</span> <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">code</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">message</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">writeJSON</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">status</span><span class="p">,</span> <span class="nx">errorResponse</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Code</span><span class="p">:</span>    <span class="nx">code</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">Message</span><span class="p">:</span> <span class="nx">message</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="p">}</span></span></span></code></pre></div><p><code>writeJSON</code> 集中 JSON response 寫法：</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">writeJSON</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">status</span> <span class="kt">int</span><span class="p">,</span> <span class="nx">value</span> <span class="kt">any</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">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">WriteHeader</span><span class="p">(</span><span class="nx">status</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">_</span> <span class="p">=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">NewEncoder</span><span class="p">(</span><span class="nx">w</span><span class="p">).</span><span class="nf">Encode</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 helper 可以忽略 encode error，因為 response 已經開始寫出；正式服務通常會記錄 <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>。</p>
<h2 id="判讀error-mapping-是-handler-邊界">【判讀】error mapping 是 handler 邊界</h2>
<p>error mapping 的核心責任是把 application error 轉成 HTTP status 與對外 code。usecase 不應知道 HTTP status；handler 不應靠錯誤字串猜狀態。</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">writeUsecaseError</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">err</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">invalid</span> <span class="nx">ErrInvalidInput</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">invalid</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusBadRequest</span><span class="p">,</span> <span class="s">&#34;invalid_input&#34;</span><span class="p">,</span> <span class="nx">invalid</span><span class="p">.</span><span class="nf">Error</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</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">alreadyExists</span> <span class="nx">ErrAlreadyExists</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">As</span><span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="o">&amp;</span><span class="nx">alreadyExists</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusConflict</span><span class="p">,</span> <span class="s">&#34;already_exists&#34;</span><span class="p">,</span> <span class="s">&#34;notification already exists&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nf">writeError</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</span><span class="p">,</span> <span class="s">&#34;internal_error&#34;</span><span class="p">,</span> <span class="s">&#34;internal server error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>內部錯誤不要直接回給 client。對外 message 應該穩定且安全；詳細錯誤留給 log 與 error chain。</p>
<h2 id="執行usecase-測試不需要-http">【執行】usecase 測試不需要 HTTP</h2>
<p>usecase 測試的核心目標是驗證行為規則。它應該直接建立 command，使用 fake repository，不需要 <code>httptest</code>。</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">type</span> <span class="nx">fakeNotificationRepository</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">existing</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">saved</span>    <span class="p">[]</span><span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">notification</span> <span class="nx">Notification</span><span class="p">)</span> <span class="kt">error</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">saved</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">f</span><span class="p">.</span><span class="nx">saved</span><span class="p">,</span> <span class="nx">notification</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">return</span> <span class="kc">nil</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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationRepository</span><span class="p">)</span> <span class="nf">FindByID</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">id</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">bool</span><span class="p">,</span> <span class="kt">error</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">notification</span><span class="p">,</span> <span class="nx">ok</span> <span class="o">:=</span> <span class="nx">f</span><span class="p">.</span><span class="nx">existing</span><span class="p">[</span><span class="nx">id</span><span class="p">]</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="nx">notification</span><span class="p">,</span> <span class="nx">ok</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">14</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">TestCreateNotificationUsecaseExecute</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">repo</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationRepository</span><span class="p">{</span><span class="nx">existing</span><span class="p">:</span> <span class="kd">map</span><span class="p">[</span><span class="kt">string</span><span class="p">]</span><span class="nx">Notification</span><span class="p">{}}</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">usecase</span> <span class="o">:=</span> <span class="nf">NewCreateNotificationUsecase</span><span class="p">(</span><span class="nx">repo</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">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">usecase</span><span class="p">.</span><span class="nf">Execute</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">(),</span> <span class="nx">CreateNotificationCommand</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</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;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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="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">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;execute usecase: %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="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">)</span> <span class="o">!=</span> <span class="mi">1</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</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;saved notifications = %d, want 1&#34;</span><span class="p">,</span> <span class="nb">len</span><span class="p">(</span><span class="nx">repo</span><span class="p">.</span><span class="nx">saved</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試速度快、錯誤定位明確。若失敗，問題在 usecase，不在 HTTP parsing。</p>
<h2 id="執行handler-test-專注-requestresponse">【執行】handler test 專注 request/response</h2>
<p>handler test 的核心目標是驗證 HTTP 協定行為。它應該使用 fake usecase，而不是真 repository。</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">type</span> <span class="nx">fakeNotificationCreator</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">got</span> <span class="nx">CreateNotificationCommand</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">out</span> <span class="nx">Notification</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">err</span> <span class="kt">error</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="o">*</span><span class="nx">fakeNotificationCreator</span><span class="p">)</span> <span class="nf">Execute</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">cmd</span> <span class="nx">CreateNotificationCommand</span><span class="p">)</span> <span class="p">(</span><span class="nx">Notification</span><span class="p">,</span> <span class="kt">error</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">f</span><span class="p">.</span><span class="nx">got</span> <span class="p">=</span> <span class="nx">cmd</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">if</span> <span class="nx">f</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="k">return</span> <span class="nx">Notification</span><span class="p">{},</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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">f</span><span class="p">.</span><span class="nx">out</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試成功 response：</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">TestNotificationHandlerCreate</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">creator</span> <span class="o">:=</span> <span class="o">&amp;</span><span class="nx">fakeNotificationCreator</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">out</span><span class="p">:</span> <span class="nx">Notification</span><span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">            <span class="nx">ID</span><span class="p">:</span>        <span class="s">&#34;ntf_1&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">            <span class="nx">Topic</span><span class="p">:</span>     <span class="s">&#34;deployments&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">            <span class="nx">Title</span><span class="p">:</span>     <span class="s">&#34;Deploy finished&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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 class="nx">handler</span> <span class="o">:=</span> <span class="nf">NewNotificationHandler</span><span class="p">(</span><span class="nx">creator</span><span class="p">,</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/notifications&#34;</span><span class="p">,</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">`{
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">        &#34;id&#34;: &#34;ntf_1&#34;,
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">        &#34;topic&#34;: &#34;deployments&#34;,
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">        &#34;title&#34;: &#34;Deploy finished&#34;
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">    }`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nx">handler</span><span class="p">.</span><span class="nf">Create</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">24</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">    <span class="k">if</span> <span class="nx">creator</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span> <span class="o">!=</span> <span class="s">&#34;deployments&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">27</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;topic = %q, want deployments&#34;</span><span class="p">,</span> <span class="nx">creator</span><span class="p">.</span><span class="nx">got</span><span class="p">.</span><span class="nx">Topic</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試確認 handler 能解析 JSON、建立 command、呼叫 usecase、寫出狀態碼。它不測重複 ID 的儲存規則，那已經是 usecase 測試的責任。</p>
<h2 id="策略integration-test-只保留少數端到端路徑">【策略】integration test 只保留少數端到端路徑</h2>
<p>integration test 的核心用途是確認組裝正確，不是覆蓋所有規則。當 usecase 與 handler 都已有單元測試，端到端測試只需要保留代表性成功與失敗路徑。</p>
<p>例如：</p>
<ul>
<li><code>POST /notifications</code> 成功建立。</li>
<li>invalid JSON 回 <code>400</code>。</li>
<li>重複 ID 回 <code>409</code>。</li>
</ul>
<p>不要把所有欄位驗證都只放在 integration test。那會讓測試慢、失敗定位模糊，也讓重構成本升高。</p>
<h2 id="重構步驟">重構步驟</h2>
<p>從過重 handler 重構時，可以按這個順序：</p>
<ol>
<li>先補 handler 現有行為測試，鎖住 status code 與 response body。</li>
<li>抽出 request DTO，但暫時不改行為。</li>
<li>抽出 command 與 usecase，讓 handler 呼叫 usecase。</li>
<li>把 repository 或 map 寫入移到 usecase 後方。</li>
<li>抽出 response struct 與 error mapping helper。</li>
<li>補 usecase 單元測試。</li>
<li>縮減 handler 測試範圍，保留 request/response 行為。</li>
</ol>
<p>每一步都應該讓程式可編譯、測試可跑。不要一次把 handler、repository、package 結構全部搬完。</p>
<h2 id="設計檢查">設計檢查</h2>
<h3 id="檢查一抽出真正的行為邊界">檢查一：抽出真正的行為邊界</h3>
<p>如果新函式仍然接收 <code>http.ResponseWriter</code> 和 <code>*http.Request</code>，那只是移動程式碼，還沒有分離 transport concern。</p>
<h3 id="檢查二domain-model-和-response-model-分開">檢查二：domain model 和 response model 分開</h3>
<p>JSON tag 是 transport contract。domain model 若直接承擔對外格式，未來內部欄位調整就會牽動 API 相容性。</p>
<h3 id="檢查三錯誤類型對應-http-回應">檢查三：錯誤類型對應 HTTP 回應</h3>
<p>輸入錯誤、重複資料、權限問題與內部錯誤應該對應不同 status code。錯誤型別與 error mapping helper 可以避免字串判斷。</p>
<h3 id="檢查四分層測試保護不同責任">檢查四：分層測試保護不同責任</h3>
<p>端到端測試重要，但不應是唯一測試。usecase 規則越多，越需要直接測 command 與 fake repository。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理 HTTP handler 的轉換邊界；router、middleware 與 <a href="/blog/backend/knowledge-cards/transaction/" data-link-title="Transaction" data-link-desc="說明 transaction 如何讓一組資料變更一起成功或一起回復">transaction</a>，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go/07-refactoring/hexagonal-migration/" data-link-title="7.6 逐步遷移到 ports/adapters 架構" data-link-desc="用 ports 與 adapters 控制 Go 服務的依賴方向">Go 進階：逐步遷移到 ports/adapters 架構</a></li>
<li><a href="/blog/go-advanced/07-distributed-operations/database-transactions/" data-link-title="7.1 資料庫 transaction 與 schema migration" data-link-desc="把 repository 邊界延伸到資料庫交易、migration 與一致性語意">Go 進階：資料庫 transaction 與 schema migration</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 request DTO、command 與 usecase 分層；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/07-refactoring/interface-boundary/" data-link-title="7.2 用 interface 隔離外部依賴" data-link-desc="建立小而穩定的測試替身">Go：用 interface 隔離外部依賴</a></li>
<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/06-practical/repository-port/" data-link-title="6.6 如何新增 repository port" data-link-desc="先建立儲存邊界，再決定 memory、SQLite 或外部資料庫實作">Go：如何新增 repository port</a></li>
<li><a href="/blog/go/06-practical/state-fields/" data-link-title="6.3 如何擴展狀態投影欄位" data-link-desc="更新狀態模型、repository 與 API 輸出">Go：如何擴展狀態投影欄位</a></li>
</ul>
]]></content:encoded></item><item><title>Mock 遮蔽</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/</guid><description>&lt;p>Mock 遮蔽的核心概念是「mock 忠實模擬程式語言的 API 契約，但跳過了協議層和環境層的行為差異，讓這些差異在 test 中不可見」。遮蔽是 mock 的設計邊界，遮蔽的範圍形成結構性的驗證盲區。可先對照 &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/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>Mock 遮蔽發生在 API 層和協議層之間的語意斷裂處。Mock 模擬的是最上層（方法簽名、參數型別、回傳值），真實行為發生在下面兩層（協議語意、執行環境）。遮蔽有兩種模式：功能存在但行為錯誤（mock 接受了真實服務不接受的輸入）、功能根本沒實作（mock 不需要該功能就能通過）。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>Mock 遮蔽的訊號是：test 全過但實機失敗的 bug 類型集中在外部互動（連線、認證、編碼）、修復後原有 test 不需要改動、bug 修復是型別轉換或編碼調整。&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>sink.add(dynamic)&lt;/code> 不區分 text/binary frame 是典型案例。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>面對 mock 遮蔽的正確策略是分層驗證 — mock 負責 API 層，&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 test 數量無法跨越層級盲區。Mock 也不應該模擬協議行為 — 讓 mock 更「逼真」會讓 mock 本身變成需要維護和驗證的複雜元件。&lt;/p></description><content:encoded><![CDATA[<p>Mock 遮蔽的核心概念是「mock 忠實模擬程式語言的 API 契約，但跳過了協議層和環境層的行為差異，讓這些差異在 test 中不可見」。遮蔽是 mock 的設計邊界，遮蔽的範圍形成結構性的驗證盲區。可先對照 <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/nominal-integration-test/" data-link-title="名義 Integration Test" data-link-desc="名稱含 integration 但核心依賴全用 fake 的 test，驗證內部狀態機而非真實服務互動">名義 integration test</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Mock 遮蔽發生在 API 層和協議層之間的語意斷裂處。Mock 模擬的是最上層（方法簽名、參數型別、回傳值），真實行為發生在下面兩層（協議語意、執行環境）。遮蔽有兩種模式：功能存在但行為錯誤（mock 接受了真實服務不接受的輸入）、功能根本沒實作（mock 不需要該功能就能通過）。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>Mock 遮蔽的訊號是：test 全過但實機失敗的 bug 類型集中在外部互動（連線、認證、編碼）、修復後原有 test 不需要改動、bug 修復是型別轉換或編碼調整。<code>FakeWebSocketChannel</code> 的 <code>sink.add(dynamic)</code> 不區分 text/binary frame 是典型案例。</p>
<h2 id="設計責任">設計責任</h2>
<p>面對 mock 遮蔽的正確策略是分層驗證 — mock 負責 API 層，<a href="/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">protocol integration test</a> 負責協議層。增加 mock test 數量無法跨越層級盲區。Mock 也不應該模擬協議行為 — 讓 mock 更「逼真」會讓 mock 本身變成需要維護和驗證的複雜元件。</p>
]]></content:encoded></item><item><title>Mock 遮蔽機制分析</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">Mock 遮蔽&lt;/a>是 mock 的設計邊界。「遮蔽」描述的是機制 — mock 讓協議層差異變得不可見；「盲區」描述的是結果 — 被遮蔽的範圍形成結構性的驗證缺口。Mock 的職責是模擬程式語言層面的 API 契約 — 方法簽名、參數型別、回傳值結構。協議層行為（frame type、handshake 步驟、編碼格式）不在 API 契約的描述範圍內，mock 沒有模擬這些行為的義務，也不應該被期待模擬。&lt;/p>
&lt;h2 id="三層語意與斷裂點">三層語意與斷裂點&lt;/h2>
&lt;p>程式碼和外部服務之間的互動經過三層語意轉換，每一層描述不同粒度的行為。Mock 模擬的是最上層，真實行為發生在下面兩層。&lt;/p>
&lt;h3 id="api-層程式語言的方法簽名">API 層：程式語言的方法簽名&lt;/h3>
&lt;p>API 層描述的是「這個方法接受什麼參數、回傳什麼型別」。Dart 的 &lt;code>WebSocketSink.add&lt;/code> 簽名是 &lt;code>void add(dynamic event)&lt;/code> — 從 API 層看，傳 &lt;code>String&lt;/code> 和傳 &lt;code>Uint8List&lt;/code> 都合法，都不會拋出例外。&lt;/p>
&lt;p>&lt;code>FakeWebSocketChannel&lt;/code> 忠實實作了這個 API 契約。&lt;code>sink.add(&amp;quot;hello&amp;quot;)&lt;/code> 和 &lt;code>sink.add(Uint8List.fromList([104, 101, 108, 108, 111]))&lt;/code> 在 fake 的行為完全相同 — 資料進入內部 buffer，test 可以從 buffer 讀取驗證。Mock 的行為在 API 層是正確的。&lt;/p>
&lt;h3 id="協議層通訊標準的語意規則">協議層：通訊標準的語意規則&lt;/h3>
&lt;p>協議層描述的是「這個資料在網路上如何被編碼、對方如何解讀」。WebSocket 協議（RFC 6455）定義 text frame 用 opcode 0x1、binary frame 用 opcode 0x2 — 兩者語意不同，接收端可以選擇只處理其中一種。&lt;/p>
&lt;p>Dart 的 &lt;code>IOWebSocketChannel&lt;/code>（真實實作）根據 &lt;code>sink.add&lt;/code> 的參數型別決定 frame type：&lt;code>String&lt;/code> 產生 text frame，&lt;code>List&amp;lt;int&amp;gt;&lt;/code> 或 &lt;code>Uint8List&lt;/code> 產生 binary frame。這個行為是 &lt;code>IOWebSocketChannel&lt;/code> 的實作細節，不是 &lt;code>WebSocketSink&lt;/code> 介面契約的一部分 — API 簽名用 &lt;code>dynamic&lt;/code> 把型別資訊抹除了（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）。&lt;/p>
&lt;p>ttyd 只接受 text frame，收到 binary frame 靜默忽略。從 API 層看，&lt;code>sink.add(Uint8List(...))&lt;/code> 合法；從協議層看，這產生了 ttyd 不處理的 binary frame。斷裂點在 API 層和協議層之間 — mock 模擬了前者，但後者的語意差異只有真實 &lt;code>IOWebSocketChannel&lt;/code> + 真實 ttyd 才會浮現。&lt;/p>
&lt;h3 id="環境層執行環境的行為差異">環境層：執行環境的行為差異&lt;/h3>
&lt;p>環境層描述的是「同一段程式碼在不同執行環境下行為不同」。DNS 解析、TLS 憑證驗證、防火牆規則、作業系統的 socket 實作 — 這些在 test 環境可能和 production 不同。&lt;/p>
&lt;p>環境層的遮蔽比協議層更難處理，因為即使用真實服務做 protocol integration test，test 環境和 production 環境仍可能有差異。本模組不深入環境層議題。&lt;/p>
&lt;h2 id="遮蔽的兩種模式">遮蔽的兩種模式&lt;/h2>
&lt;p>Mock 遮蔽在實務上有兩種不同的表現，需要不同的偵測策略。&lt;/p>
&lt;h3 id="模式一功能存在但行為錯誤">模式一：功能存在但行為錯誤&lt;/h3>
&lt;p>程式碼有對應的實作，但實作的行為和真實服務期望的行為不一致。Mock 讓這個不一致變得不可見，因為 mock 接受了實際上外部服務不會接受的輸入。&lt;/p>
&lt;p>T.C1 就是這種模式。&lt;code>sendData()&lt;/code> 實作了「發送鍵盤輸入」的功能，但發送的是 binary frame 而非 text frame。Mock 的 &lt;code>sink.add(dynamic)&lt;/code> 接受 &lt;code>Uint8List&lt;/code> 不報錯，真實 ttyd 靜默忽略 binary frame。功能存在，行為錯誤，mock 遮蔽了錯誤。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock 遮蔽" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">Mock 遮蔽</a>是 mock 的設計邊界。「遮蔽」描述的是機制 — mock 讓協議層差異變得不可見；「盲區」描述的是結果 — 被遮蔽的範圍形成結構性的驗證缺口。Mock 的職責是模擬程式語言層面的 API 契約 — 方法簽名、參數型別、回傳值結構。協議層行為（frame type、handshake 步驟、編碼格式）不在 API 契約的描述範圍內，mock 沒有模擬這些行為的義務，也不應該被期待模擬。</p>
<h2 id="三層語意與斷裂點">三層語意與斷裂點</h2>
<p>程式碼和外部服務之間的互動經過三層語意轉換，每一層描述不同粒度的行為。Mock 模擬的是最上層，真實行為發生在下面兩層。</p>
<h3 id="api-層程式語言的方法簽名">API 層：程式語言的方法簽名</h3>
<p>API 層描述的是「這個方法接受什麼參數、回傳什麼型別」。Dart 的 <code>WebSocketSink.add</code> 簽名是 <code>void add(dynamic event)</code> — 從 API 層看，傳 <code>String</code> 和傳 <code>Uint8List</code> 都合法，都不會拋出例外。</p>
<p><code>FakeWebSocketChannel</code> 忠實實作了這個 API 契約。<code>sink.add(&quot;hello&quot;)</code> 和 <code>sink.add(Uint8List.fromList([104, 101, 108, 108, 111]))</code> 在 fake 的行為完全相同 — 資料進入內部 buffer，test 可以從 buffer 讀取驗證。Mock 的行為在 API 層是正確的。</p>
<h3 id="協議層通訊標準的語意規則">協議層：通訊標準的語意規則</h3>
<p>協議層描述的是「這個資料在網路上如何被編碼、對方如何解讀」。WebSocket 協議（RFC 6455）定義 text frame 用 opcode 0x1、binary frame 用 opcode 0x2 — 兩者語意不同，接收端可以選擇只處理其中一種。</p>
<p>Dart 的 <code>IOWebSocketChannel</code>（真實實作）根據 <code>sink.add</code> 的參數型別決定 frame type：<code>String</code> 產生 text frame，<code>List&lt;int&gt;</code> 或 <code>Uint8List</code> 產生 binary frame。這個行為是 <code>IOWebSocketChannel</code> 的實作細節，不是 <code>WebSocketSink</code> 介面契約的一部分 — API 簽名用 <code>dynamic</code> 把型別資訊抹除了（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）。</p>
<p>ttyd 只接受 text frame，收到 binary frame 靜默忽略。從 API 層看，<code>sink.add(Uint8List(...))</code> 合法；從協議層看，這產生了 ttyd 不處理的 binary frame。斷裂點在 API 層和協議層之間 — mock 模擬了前者，但後者的語意差異只有真實 <code>IOWebSocketChannel</code> + 真實 ttyd 才會浮現。</p>
<h3 id="環境層執行環境的行為差異">環境層：執行環境的行為差異</h3>
<p>環境層描述的是「同一段程式碼在不同執行環境下行為不同」。DNS 解析、TLS 憑證驗證、防火牆規則、作業系統的 socket 實作 — 這些在 test 環境可能和 production 不同。</p>
<p>環境層的遮蔽比協議層更難處理，因為即使用真實服務做 protocol integration test，test 環境和 production 環境仍可能有差異。本模組不深入環境層議題。</p>
<h2 id="遮蔽的兩種模式">遮蔽的兩種模式</h2>
<p>Mock 遮蔽在實務上有兩種不同的表現，需要不同的偵測策略。</p>
<h3 id="模式一功能存在但行為錯誤">模式一：功能存在但行為錯誤</h3>
<p>程式碼有對應的實作，但實作的行為和真實服務期望的行為不一致。Mock 讓這個不一致變得不可見，因為 mock 接受了實際上外部服務不會接受的輸入。</p>
<p>T.C1 就是這種模式。<code>sendData()</code> 實作了「發送鍵盤輸入」的功能，但發送的是 binary frame 而非 text frame。Mock 的 <code>sink.add(dynamic)</code> 接受 <code>Uint8List</code> 不報錯，真實 ttyd 靜默忽略 binary frame。功能存在，行為錯誤，mock 遮蔽了錯誤。</p>
<p>這種模式的偵測策略是 protocol integration test — 對真實服務發送相同輸入，比對回應是否符合預期。</p>
<h3 id="模式二功能根本沒實作">模式二：功能根本沒實作</h3>
<p>程式碼缺少應有的功能步驟，但 mock 不需要這個步驟就能進入成功狀態。Mock 把多步驟的協議流程簡化成單步操作，讓開發者不知道還有缺少的步驟。</p>
<p>T.C2 就是這種模式。ttyd 要求連線後發送 auth token，但 <code>ConnectionManager</code> 沒有實作這個步驟。<code>FakeWebSocketChannel.ready</code> 立即完成不需認證，<code>stream</code> 由開發者手動控制，不依賴 auth 狀態。Mock 把「TCP 握手 → WS 握手 → auth token → 驗證通過 → 推送資料」這個多步驟流程簡化成「<code>ready</code> 完成 → <code>stream</code> 有資料」（<a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a>）。</p>
<p>功能缺失比功能錯誤更難被偵測。功能錯誤至少有一段程式碼可以被 test 覆蓋（只是斷言的對象不夠深）；功能缺失意味著沒有程式碼可以寫 test。只有 protocol integration test 對真實服務跑完整流程，才能暴露「應該有但沒有」的步驟。</p>
<h2 id="mock-不應該模擬協議行為">Mock 不應該模擬協議行為</h2>
<p>面對 mock 遮蔽的第一個直覺反應通常是「讓 mock 更逼真」— 在 <code>FakeWebSocketChannel</code> 裡加入 frame type 區分、auth handshake 驗證等邏輯。這個方向有結構性問題。</p>
<p>Mock 的價值在於簡化 — 把複雜的外部依賴替換成行為可預測的替身，讓 unit test 專注在程式碼邏輯。如果 mock 開始模擬協議行為，mock 本身變成需要維護和驗證的複雜元件。Mock 的正確性由誰保證？如果外部服務更新了協議版本，誰負責更新 mock？</p>
<p>更根本的問題是：即使 mock 完美複製了當前版本的協議行為，它仍然是開發者對協議的理解的副本，不是協議本身。如果開發者對協議的理解就有偏差（例如不知道 ttyd 需要 auth token），mock 會忠實複製這個偏差。</p>
<p>正確的分工是：mock 負責 API 層，protocol integration test 負責協議層。每一層用正確的工具驗證。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>如何辨認偽裝成 integration test 的 mock test → <a href="/blog/testing/01-test-strategy-layers/nominal-integration-test/" data-link-title="「名義 integration test」的識別與修正" data-link-desc="test 名稱含 integration 但核心依賴全用 fake — 如何辨認、為什麼有害、怎麼修正命名和測試策略">名義 integration test 的識別與修正</a></li>
<li>判斷自己的服務是否存在這種斷裂 → <a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a></li>
<li>想看 SDK 自動攔截如何影響 mock 遮蔽 → <a href="/blog/monitoring/03-sdk-design/" data-link-title="模組三：SDK 設計模式" data-link-desc="跨平台 SDK 的自動攔截、手動上報、攢批送出、離線 buffer 設計">monitoring 模組三 SDK 設計</a></li>
</ul>
]]></content:encoded></item><item><title>T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（&lt;code>{&amp;quot;AuthToken&amp;quot;:&amp;quot;base64(user:pass)&amp;quot;}&lt;/code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 &lt;code>ConnectionManager&lt;/code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>10 個 ConnectionManager test 全過（&lt;code>FakeWebSocketChannel.ready&lt;/code> 立即完成）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Integration test 結果&lt;/td>
 &lt;td>11 個 connection_flow_test 全過（同樣用 &lt;code>FakeWebSocketChannel&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功，終端機空白無輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>新增 &lt;code>_sendAuthTokenIfNeeded()&lt;/code> 在 &lt;code>_establishWebSocket()&lt;/code> 內呼叫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 的 happy path 比真實服務寬鬆&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>ready&lt;/code> 是 &lt;code>Future.value()&lt;/code>（立即完成），&lt;code>stream&lt;/code> 是開發者手動控制的 &lt;code>StreamController&lt;/code>。真實 ttyd 的行為是：&lt;code>ready&lt;/code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Integration test 名為整合實為 fake&lt;/strong>。&lt;code>connection_flow_test.dart&lt;/code> 標題是「端對端整合測試」，但內部使用 &lt;code>FakeWebSocketChannel&lt;/code> + &lt;code>FakeBiometricService&lt;/code> + &lt;code>InMemoryCredentialRepository&lt;/code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>功能缺失比功能錯誤更難被 test 抓到&lt;/strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test 必須涵蓋 auth handshake&lt;/strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。&lt;/li>
&lt;li>&lt;strong>在企劃階段列出協議握手步驟&lt;/strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。&lt;/li>
&lt;li>&lt;strong>區分「名義 integration」和「真實 integration」&lt;/strong>：test 名稱含 integration 但全用 fake，應標明 &lt;code>fake-integration&lt;/code> 或改名 &lt;code>connection-state-machine-test&lt;/code>。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想區分 mock 層級 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層&lt;/a>&lt;/li>
&lt;li>想建 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想設計 auth 機制的 UX fallback → &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。</p>
<h2 id="觀察">觀察</h2>
<p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（<code>{&quot;AuthToken&quot;:&quot;base64(user:pass)&quot;}</code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 <code>ConnectionManager</code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>10 個 ConnectionManager test 全過（<code>FakeWebSocketChannel.ready</code> 立即完成）</td>
      </tr>
      <tr>
          <td>Integration test 結果</td>
          <td>11 個 connection_flow_test 全過（同樣用 <code>FakeWebSocketChannel</code>）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功，終端機空白無輸出</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>新增 <code>_sendAuthTokenIfNeeded()</code> 在 <code>_establishWebSocket()</code> 內呼叫</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 的 happy path 比真實服務寬鬆</strong>。<code>FakeWebSocketChannel</code> 的 <code>ready</code> 是 <code>Future.value()</code>（立即完成），<code>stream</code> 是開發者手動控制的 <code>StreamController</code>。真實 ttyd 的行為是：<code>ready</code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。</p>
</li>
<li>
<p><strong>Integration test 名為整合實為 fake</strong>。<code>connection_flow_test.dart</code> 標題是「端對端整合測試」，但內部使用 <code>FakeWebSocketChannel</code> + <code>FakeBiometricService</code> + <code>InMemoryCredentialRepository</code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。</p>
</li>
<li>
<p><strong>功能缺失比功能錯誤更難被 test 抓到</strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test 必須涵蓋 auth handshake</strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。</li>
<li><strong>在企劃階段列出協議握手步驟</strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。</li>
<li><strong>區分「名義 integration」和「真實 integration」</strong>：test 名稱含 integration 但全用 fake，應標明 <code>fake-integration</code> 或改名 <code>connection-state-machine-test</code>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想區分 mock 層級 → <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層</a></li>
<li>想建 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>想設計 auth 機制的 UX fallback → <a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback</a></li>
</ul>
]]></content:encoded></item><item><title>Test data 代表性</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-data-representativeness/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-data-representativeness/</guid><description>&lt;p>測試資料的代表性是指測試輸入能多大程度反映真實環境的輸入分佈。「測試資料能代表真實環境」是每個 test 的隱性假設 — 這個假設成立時 test 有效，不成立時 test 通過但問題仍在。&lt;/p>
&lt;h2 id="代表性問題的案例">代表性問題的案例&lt;/h2>
&lt;p>app_tunnel 的 ANSI parser 有 18 個 test，全部通過。測試資料是手寫的 SGR 色彩碼（&lt;code>\x1B[31mhello\x1B[0m&lt;/code>），parser 正確解析這類序列。&lt;/p>
&lt;p>真實 zsh 啟動後送出的控制序列包含 OSC 標題設定、CSI private mode、字元集指定等至少 5 種類型。Parser 只認識 SGR，其他全部透傳為亂碼（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3&lt;/a>）。&lt;/p>
&lt;p>18 個 test 覆蓋了 1 種序列類型。測試資料的代表性假設（「SGR 就是主要的序列類型」）和真實環境不符。&lt;/p>
&lt;h2 id="三種測試資料來源">三種測試資料來源&lt;/h2>
&lt;h3 id="手寫">手寫&lt;/h3>
&lt;p>開發者根據對輸入格式的理解手動建構測試字串。&lt;/p>
&lt;p>優點：精確控制、容易理解、可以針對特定邊界條件設計。&lt;/p>
&lt;p>缺點：受限於開發者對輸入分佈的認知。如果開發者不知道真實環境有哪些輸入類型，手寫的測試資料就是開發者認知的子集 — T.C3 就是這個模式。&lt;/p>
&lt;p>適合場景：格式規格明確且有限（JSON schema、固定格式的設定檔）、邊界條件測試（空值、最大長度、特殊字元）。&lt;/p>
&lt;h3 id="錄製">錄製&lt;/h3>
&lt;p>從真實環境擷取實際的輸入資料，作為 test 的輸入。&lt;/p>
&lt;p>優點：直接反映真實環境的輸入分佈，包含開發者不知道的輸入類型。&lt;/p>
&lt;p>缺點：錄製的資料可能包含敏感資訊（需要脫敏）、資料量可能大（需要挑選代表性樣本）、真實環境的輸入可能隨時間改變（錄製的資料可能過時）。&lt;/p>
&lt;p>適合場景：輸入格式複雜且規格不完整（終端機 escape 序列、網路封包、使用者產生的內容）、parser 類的功能（需要知道「真實輸入長什麼樣」）。&lt;/p>
&lt;p>T.C3 如果用錄製的真實 zsh 啟動輸出作為測試資料，OSC 和 CSI private mode 會自然出現在輸入中。即使 parser 仍然不處理這些序列，test 至少能讓開發者看到「有 5 種序列類型，我只處理了 1 種」。&lt;/p>
&lt;h3 id="生成property-based-testing">生成（Property-based testing）&lt;/h3>
&lt;p>用 generator 自動產生大量隨機或半隨機的輸入，驗證 parser 的行為是否符合通用性質（不崩潰、輸出長度 &amp;lt;= 輸入長度、冪等性）。&lt;/p>
&lt;p>優點：覆蓋人類想不到的 edge case、發現意外的崩潰或無限迴圈。&lt;/p>
&lt;p>缺點：不針對特定功能驗證（驗證的是通用性質，不是「OSC 序列是否被正確處理」）、generator 本身需要維護。&lt;/p>
&lt;p>適合場景：parser、serializer、codec 等輸入格式複雜的功能。和手寫 test 互補 — 手寫驗證特定行為正確性，生成驗證通用穩定性。&lt;/p>
&lt;h2 id="兩類-test-的分工">兩類 test 的分工&lt;/h2>
&lt;p>T.C3 的策略建議是把 test 分成兩類：&lt;/p>
&lt;p>&lt;strong>功能正確性 test&lt;/strong>：用手寫乾淨字串驗證 parser 對已知序列的處理正確性。&lt;code>\x1B[31mhello\x1B[0m&lt;/code> 應該產生紅色 token — 這是功能規格的驗證。&lt;/p>
&lt;p>&lt;strong>環境相容性 test&lt;/strong>：用錄製的真實輸出驗證 parser 在真實環境中的表現。不斷言「每個序列都被正確處理」，而是斷言「沒有崩潰」「沒有未處理序列殘留在可見輸出中」。&lt;/p>
&lt;p>兩類 test 回答不同問題。功能正確性回答「parser 的邏輯對不對」，環境相容性回答「parser 在真實環境中夠不夠用」。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Assertion 的品質判斷 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問&lt;/a>&lt;/li>
&lt;li>Mock 邊界的判斷 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/mock-boundary-decision/" data-link-title="Mock 邊界判斷決策表" data-link-desc="什麼時候 mock 夠用、什麼時候需要真實服務 — 從 API 層 / 協議層 / 環境層的斷裂點判斷 mock 的適用範圍">Mock 邊界判斷決策表&lt;/a>&lt;/li>
&lt;li>Protocol integration test 用真實服務輸出 → &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 設計">testing 模組三 WebSocket 協議測試&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>測試資料的代表性是指測試輸入能多大程度反映真實環境的輸入分佈。「測試資料能代表真實環境」是每個 test 的隱性假設 — 這個假設成立時 test 有效，不成立時 test 通過但問題仍在。</p>
<h2 id="代表性問題的案例">代表性問題的案例</h2>
<p>app_tunnel 的 ANSI parser 有 18 個 test，全部通過。測試資料是手寫的 SGR 色彩碼（<code>\x1B[31mhello\x1B[0m</code>），parser 正確解析這類序列。</p>
<p>真實 zsh 啟動後送出的控制序列包含 OSC 標題設定、CSI private mode、字元集指定等至少 5 種類型。Parser 只認識 SGR，其他全部透傳為亂碼（<a href="/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3</a>）。</p>
<p>18 個 test 覆蓋了 1 種序列類型。測試資料的代表性假設（「SGR 就是主要的序列類型」）和真實環境不符。</p>
<h2 id="三種測試資料來源">三種測試資料來源</h2>
<h3 id="手寫">手寫</h3>
<p>開發者根據對輸入格式的理解手動建構測試字串。</p>
<p>優點：精確控制、容易理解、可以針對特定邊界條件設計。</p>
<p>缺點：受限於開發者對輸入分佈的認知。如果開發者不知道真實環境有哪些輸入類型，手寫的測試資料就是開發者認知的子集 — T.C3 就是這個模式。</p>
<p>適合場景：格式規格明確且有限（JSON schema、固定格式的設定檔）、邊界條件測試（空值、最大長度、特殊字元）。</p>
<h3 id="錄製">錄製</h3>
<p>從真實環境擷取實際的輸入資料，作為 test 的輸入。</p>
<p>優點：直接反映真實環境的輸入分佈，包含開發者不知道的輸入類型。</p>
<p>缺點：錄製的資料可能包含敏感資訊（需要脫敏）、資料量可能大（需要挑選代表性樣本）、真實環境的輸入可能隨時間改變（錄製的資料可能過時）。</p>
<p>適合場景：輸入格式複雜且規格不完整（終端機 escape 序列、網路封包、使用者產生的內容）、parser 類的功能（需要知道「真實輸入長什麼樣」）。</p>
<p>T.C3 如果用錄製的真實 zsh 啟動輸出作為測試資料，OSC 和 CSI private mode 會自然出現在輸入中。即使 parser 仍然不處理這些序列，test 至少能讓開發者看到「有 5 種序列類型，我只處理了 1 種」。</p>
<h3 id="生成property-based-testing">生成（Property-based testing）</h3>
<p>用 generator 自動產生大量隨機或半隨機的輸入，驗證 parser 的行為是否符合通用性質（不崩潰、輸出長度 &lt;= 輸入長度、冪等性）。</p>
<p>優點：覆蓋人類想不到的 edge case、發現意外的崩潰或無限迴圈。</p>
<p>缺點：不針對特定功能驗證（驗證的是通用性質，不是「OSC 序列是否被正確處理」）、generator 本身需要維護。</p>
<p>適合場景：parser、serializer、codec 等輸入格式複雜的功能。和手寫 test 互補 — 手寫驗證特定行為正確性，生成驗證通用穩定性。</p>
<h2 id="兩類-test-的分工">兩類 test 的分工</h2>
<p>T.C3 的策略建議是把 test 分成兩類：</p>
<p><strong>功能正確性 test</strong>：用手寫乾淨字串驗證 parser 對已知序列的處理正確性。<code>\x1B[31mhello\x1B[0m</code> 應該產生紅色 token — 這是功能規格的驗證。</p>
<p><strong>環境相容性 test</strong>：用錄製的真實輸出驗證 parser 在真實環境中的表現。不斷言「每個序列都被正確處理」，而是斷言「沒有崩潰」「沒有未處理序列殘留在可見輸出中」。</p>
<p>兩類 test 回答不同問題。功能正確性回答「parser 的邏輯對不對」，環境相容性回答「parser 在真實環境中夠不夠用」。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Assertion 的品質判斷 → <a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問</a></li>
<li>Mock 邊界的判斷 → <a href="/blog/testing/05-test-design-judgment/mock-boundary-decision/" 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/websocket-protocol-test/" data-link-title="WebSocket 協議測試實作" data-link-desc="對真實 ttyd 驗證 frame type 和 auth handshake — 從 T.C1 和 T.C2 的教訓推導出的 protocol integration test 設計">testing 模組三 WebSocket 協議測試</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>功能規格中的 log 點定義方法</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/</guid><description>&lt;p>Log 點定義是功能規格的一部分，和 API schema 同級。功能規格描述「這個功能做什麼」，log 點規格描述「這個功能執行時留下什麼可觀察的紀錄」。把 log 點設計前移到規格階段，讓 log 成為功能的設計產物，而非事後的 debug 工具（本章合成，TF-9 Derive）。&lt;/p>
&lt;h2 id="四類-log-點">四類 log 點&lt;/h2>
&lt;p>每個功能的 log 點按執行時機分成四類。&lt;/p>
&lt;h3 id="啟動-log">啟動 log&lt;/h3>
&lt;p>功能開始執行時記錄。回答「這個功能是否被觸發了」。&lt;/p>
&lt;p>啟動 log 包含觸發來源（使用者操作、系統排程、外部事件）和初始參數（連線目標、操作類型）。如果一個功能從未被觸發，啟動 log 的缺席就是線索。&lt;/p>
&lt;h3 id="步驟-log">步驟 log&lt;/h3>
&lt;p>功能執行過程中的每個關鍵步驟完成時記錄。回答「流程走到哪裡了」。&lt;/p>
&lt;p>步驟 log 的粒度依功能複雜度而定。三步驟的功能每步記一條；十步驟的功能可以只記關鍵的三到五步。判斷標準是：如果這一步失敗，開發者是否需要知道失敗點在哪。&lt;/p>
&lt;h3 id="錯誤-log">錯誤 log&lt;/h3>
&lt;p>步驟失敗、例外捕獲、非預期狀態出現時記錄。回答「出了什麼問題」。&lt;/p>
&lt;p>錯誤 log 必須包含足夠的 context 讓開發者不需要重現問題就能判斷原因。至少包含：哪一步失敗、失敗原因（error message）、當時的關鍵狀態值。&lt;/p>
&lt;h3 id="完成-log">完成 log&lt;/h3>
&lt;p>功能正常結束時記錄。回答「功能是否成功完成、花了多久」。&lt;/p>
&lt;p>完成 log 包含執行結果和耗時。和啟動 log 配對使用 — 有啟動但沒有完成代表功能中途異常退出。&lt;/p>
&lt;h2 id="在功能規格中加可觀測性欄位">在功能規格中加可觀測性欄位&lt;/h2>
&lt;p>以 app_tunnel 的「連線到 ttyd 終端機」功能為例，傳統規格只寫：&lt;/p>
&lt;ul>
&lt;li>輸入：使用者選擇的伺服器&lt;/li>
&lt;li>處理：建立 WebSocket 連線、發送 auth token、開始接收 terminal output&lt;/li>
&lt;li>輸出：終端機畫面顯示 terminal output&lt;/li>
&lt;/ul>
&lt;p>加上可觀測性欄位後：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>類型&lt;/th>
 &lt;th>log 點&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>啟動&lt;/td>
 &lt;td>connect.start&lt;/td>
 &lt;td>目標 URL、觸發來源（使用者操作 / 自動重連）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.biometric.done&lt;/td>
 &lt;td>認證結果、耗時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.credential.loaded&lt;/td>
 &lt;td>使用者名稱（密碼 redact）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.ws.connected&lt;/td>
 &lt;td>連線 URL、耗時&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.auth.sent&lt;/td>
 &lt;td>token 長度（內容 redact）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>步驟&lt;/td>
 &lt;td>connect.stream.subscribed&lt;/td>
 &lt;td>stream 狀態&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤&lt;/td>
 &lt;td>connect.{step}.failed&lt;/td>
 &lt;td>失敗步驟、error message、retry count&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>完成&lt;/td>
 &lt;td>connect.done&lt;/td>
 &lt;td>總耗時、最終狀態&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表在功能規格階段就能寫出來，因為它只依賴功能的流程設計，不依賴實作細節。功能流程確定後，每一步在哪裡需要 log 點就確定了。&lt;/p>
&lt;h2 id="log-點命名規則">log 點命名規則&lt;/h2>
&lt;p>統一的命名規則讓 log 可以被 grep、過濾和統計。&lt;/p>
&lt;p>&lt;strong>階層式命名&lt;/strong>：&lt;code>{功能}.{步驟}.{事件}&lt;/code>。例如 &lt;code>connect.ws.connected&lt;/code>、&lt;code>connect.auth.failed&lt;/code>。&lt;/p>
&lt;p>&lt;strong>事件後綴統一&lt;/strong>：&lt;code>start&lt;/code>（啟動）、&lt;code>done&lt;/code>（步驟完成）、&lt;code>failed&lt;/code>（失敗）、&lt;code>complete&lt;/code>（功能完成）。&lt;/p>
&lt;p>&lt;strong>和程式碼結構對應&lt;/strong>：log 點名稱對應到程式碼中的函式或模組。&lt;code>connect.biometric.done&lt;/code> 對應 &lt;code>BiometricService.authenticate()&lt;/code> 的成功路徑。這讓開發者看到 log 名稱就知道去哪裡找程式碼。&lt;/p>
&lt;h2 id="log-點規格的-review-檢查">log 點規格的 review 檢查&lt;/h2>
&lt;p>功能規格 review 時，可觀測性欄位的檢查要點：&lt;/p>
&lt;p>&lt;strong>每步都有 log&lt;/strong>：流程中的每個步驟在成功和失敗時都有對應的 log 點。遺漏的步驟意味著該步驟出問題時無法從 log 判斷。&lt;/p>
&lt;p>&lt;strong>錯誤 log 有足夠 context&lt;/strong>：error log 只寫「連線失敗」不夠；需要寫「連線失敗」加上 error code、目標 URL、已完成的步驟。&lt;/p>
&lt;p>&lt;strong>敏感欄位有 redaction 標記&lt;/strong>：密碼、token、個人資料在 log 規格中標記為 redact，實作時用 redaction 機制處理。&lt;/p>
&lt;p>&lt;strong>啟動和完成配對&lt;/strong>：每個功能有啟動 log 就應該有完成 log，形成完整的生命週期。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>三層 log 的詳細設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>事後補 log 和設計產物 log 的差異 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異&lt;/a>&lt;/li>
&lt;li>Log 中的敏感資訊處理 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Log 點定義是功能規格的一部分，和 API schema 同級。功能規格描述「這個功能做什麼」，log 點規格描述「這個功能執行時留下什麼可觀察的紀錄」。把 log 點設計前移到規格階段，讓 log 成為功能的設計產物，而非事後的 debug 工具（本章合成，TF-9 Derive）。</p>
<h2 id="四類-log-點">四類 log 點</h2>
<p>每個功能的 log 點按執行時機分成四類。</p>
<h3 id="啟動-log">啟動 log</h3>
<p>功能開始執行時記錄。回答「這個功能是否被觸發了」。</p>
<p>啟動 log 包含觸發來源（使用者操作、系統排程、外部事件）和初始參數（連線目標、操作類型）。如果一個功能從未被觸發，啟動 log 的缺席就是線索。</p>
<h3 id="步驟-log">步驟 log</h3>
<p>功能執行過程中的每個關鍵步驟完成時記錄。回答「流程走到哪裡了」。</p>
<p>步驟 log 的粒度依功能複雜度而定。三步驟的功能每步記一條；十步驟的功能可以只記關鍵的三到五步。判斷標準是：如果這一步失敗，開發者是否需要知道失敗點在哪。</p>
<h3 id="錯誤-log">錯誤 log</h3>
<p>步驟失敗、例外捕獲、非預期狀態出現時記錄。回答「出了什麼問題」。</p>
<p>錯誤 log 必須包含足夠的 context 讓開發者不需要重現問題就能判斷原因。至少包含：哪一步失敗、失敗原因（error message）、當時的關鍵狀態值。</p>
<h3 id="完成-log">完成 log</h3>
<p>功能正常結束時記錄。回答「功能是否成功完成、花了多久」。</p>
<p>完成 log 包含執行結果和耗時。和啟動 log 配對使用 — 有啟動但沒有完成代表功能中途異常退出。</p>
<h2 id="在功能規格中加可觀測性欄位">在功能規格中加可觀測性欄位</h2>
<p>以 app_tunnel 的「連線到 ttyd 終端機」功能為例，傳統規格只寫：</p>
<ul>
<li>輸入：使用者選擇的伺服器</li>
<li>處理：建立 WebSocket 連線、發送 auth token、開始接收 terminal output</li>
<li>輸出：終端機畫面顯示 terminal output</li>
</ul>
<p>加上可觀測性欄位後：</p>
<table>
  <thead>
      <tr>
          <th>類型</th>
          <th>log 點</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>啟動</td>
          <td>connect.start</td>
          <td>目標 URL、觸發來源（使用者操作 / 自動重連）</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.biometric.done</td>
          <td>認證結果、耗時</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.credential.loaded</td>
          <td>使用者名稱（密碼 redact）</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.ws.connected</td>
          <td>連線 URL、耗時</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.auth.sent</td>
          <td>token 長度（內容 redact）</td>
      </tr>
      <tr>
          <td>步驟</td>
          <td>connect.stream.subscribed</td>
          <td>stream 狀態</td>
      </tr>
      <tr>
          <td>錯誤</td>
          <td>connect.{step}.failed</td>
          <td>失敗步驟、error message、retry count</td>
      </tr>
      <tr>
          <td>完成</td>
          <td>connect.done</td>
          <td>總耗時、最終狀態</td>
      </tr>
  </tbody>
</table>
<p>這張表在功能規格階段就能寫出來，因為它只依賴功能的流程設計，不依賴實作細節。功能流程確定後，每一步在哪裡需要 log 點就確定了。</p>
<h2 id="log-點命名規則">log 點命名規則</h2>
<p>統一的命名規則讓 log 可以被 grep、過濾和統計。</p>
<p><strong>階層式命名</strong>：<code>{功能}.{步驟}.{事件}</code>。例如 <code>connect.ws.connected</code>、<code>connect.auth.failed</code>。</p>
<p><strong>事件後綴統一</strong>：<code>start</code>（啟動）、<code>done</code>（步驟完成）、<code>failed</code>（失敗）、<code>complete</code>（功能完成）。</p>
<p><strong>和程式碼結構對應</strong>：log 點名稱對應到程式碼中的函式或模組。<code>connect.biometric.done</code> 對應 <code>BiometricService.authenticate()</code> 的成功路徑。這讓開發者看到 log 名稱就知道去哪裡找程式碼。</p>
<h2 id="log-點規格的-review-檢查">log 點規格的 review 檢查</h2>
<p>功能規格 review 時，可觀測性欄位的檢查要點：</p>
<p><strong>每步都有 log</strong>：流程中的每個步驟在成功和失敗時都有對應的 log 點。遺漏的步驟意味著該步驟出問題時無法從 log 判斷。</p>
<p><strong>錯誤 log 有足夠 context</strong>：error log 只寫「連線失敗」不夠；需要寫「連線失敗」加上 error code、目標 URL、已完成的步驟。</p>
<p><strong>敏感欄位有 redaction 標記</strong>：密碼、token、個人資料在 log 規格中標記為 redact，實作時用 redaction 機制處理。</p>
<p><strong>啟動和完成配對</strong>：每個功能有啟動 log 就應該有完成 log，形成完整的生命週期。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>三層 log 的詳細設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>事後補 log 和設計產物 log 的差異 → <a href="/blog/testing/02-client-observability/hotfix-log-vs-designed-log/" data-link-title="「事後補 log」vs「設計產物 log」的品質差異" data-link-desc="事後補的 log 是救火工具、設計產物的 log 是可觀測性基礎設施 — 從 app_tunnel 的 W2 hotfix log 拆解兩者在格式、覆蓋率、維護成本上的差異">「事後補 log」vs「設計產物 log」的品質差異</a></li>
<li>Log 中的敏感資訊處理 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a></li>
</ul>
]]></content:encoded></item><item><title>導航路徑 test</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/</guid><description>&lt;p>導航路徑 test 驗證的是使用者在畫面之間的移動是否符合設計 — 每個畫面的 back 按鈕是否導向正確的上層畫面、每個 router 定義的路由是否從 UI 可達、&lt;code>go&lt;/code> 和 &lt;code>push&lt;/code> 的語意是否產生正確的返回堆疊。&lt;/p>
&lt;h2 id="back-按鈕-test">Back 按鈕 test&lt;/h2>
&lt;p>每個有 back 按鈕的畫面需要一個 test 驗證「按下 back 後導航到哪裡」。Back 按鈕的目標畫面依導航方式而定：&lt;/p>
&lt;ul>
&lt;li>&lt;code>context.push('/terminal')&lt;/code> 進入 → back 回到推入前的畫面（首頁）&lt;/li>
&lt;li>&lt;code>context.go('/terminal')&lt;/code> 進入 → back 行為依 router 設定，可能沒有上一頁&lt;/li>
&lt;/ul>





&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="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back from terminal returns to home (pushed)&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&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="c1">// 從首頁 push 到 terminal
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Connect Terminal&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TerminalScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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="c1">// 按 back
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="route-可達性-test">Route 可達性 test&lt;/h2>
&lt;p>Router 定義的每個路由都應該有從 UI 可達的路徑（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性&lt;/a>）。Route 可達性 test 驗證「從首頁出發，透過 UI 操作能到達每個路由」。&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="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;enrollment route is reachable from home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&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="c1">// 找到配對入口按鈕
&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="kd">final&lt;/span> &lt;span class="n">enrollButton&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Enroll Device&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">enrollButton&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&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="c1">// 點擊後到達 enrollment 畫面
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">enrollButton&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">EnrollmentScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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;/code>&lt;/pre>&lt;/div>&lt;p>不可達的路由在 test 中表現為「找不到導航到該路由的 UI 元素」。如果 router 定義了 &lt;code>/enrollment&lt;/code> 但首頁沒有對應按鈕，&lt;code>find.text('Enroll Device')&lt;/code> 會找不到元素 — test 失敗暴露入口缺失。&lt;/p>
&lt;h2 id="go-vs-push-語意的-test">&lt;code>go&lt;/code> vs &lt;code>push&lt;/code> 語意的 test&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 和 &lt;code>push&lt;/code> 對返回堆疊的影響不同（&lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式&lt;/a>）。Test 需要驗證正確的導航方式被使用：&lt;/p>
&lt;h3 id="push-語意保留返回堆疊">Push 語意：保留返回堆疊&lt;/h3>
&lt;p>Push 後按系統 back 鍵應該回到推入前的畫面。&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="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;push preserves back stack&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&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="c1">// push to enrollment
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Enroll Device&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&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="c1">// 系統 back 鍵
&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="kd">final&lt;/span> &lt;span class="n">backButton&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byTooltip&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Back&amp;#39;&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">backButton&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="c1">// 應該回到首頁
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="go-語意替換路由堆疊">Go 語意：替換路由堆疊&lt;/h3>
&lt;p>Go 後按系統 back 鍵的行為依 router 設定。如果 go 到的路由是根層級，系統 back 鍵可能退出 app 而非回到前一個畫面。&lt;/p></description><content:encoded><![CDATA[<p>導航路徑 test 驗證的是使用者在畫面之間的移動是否符合設計 — 每個畫面的 back 按鈕是否導向正確的上層畫面、每個 router 定義的路由是否從 UI 可達、<code>go</code> 和 <code>push</code> 的語意是否產生正確的返回堆疊。</p>
<h2 id="back-按鈕-test">Back 按鈕 test</h2>
<p>每個有 back 按鈕的畫面需要一個 test 驗證「按下 back 後導航到哪裡」。Back 按鈕的目標畫面依導航方式而定：</p>
<ul>
<li><code>context.push('/terminal')</code> 進入 → back 回到推入前的畫面（首頁）</li>
<li><code>context.go('/terminal')</code> 進入 → back 行為依 router 設定，可能沒有上一頁</li>
</ul>





<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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;back from terminal returns to home (pushed)&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 從首頁 push 到 terminal
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Connect Terminal&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 按 back
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="route-可達性-test">Route 可達性 test</h2>
<p>Router 定義的每個路由都應該有從 UI 可達的路徑（<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a>）。Route 可達性 test 驗證「從首頁出發，透過 UI 操作能到達每個路由」。</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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;enrollment route is reachable from home&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 找到配對入口按鈕
</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">enrollButton</span> <span class="o">=</span> <span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Enroll Device&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">enrollButton</span><span class="p">,</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 點擊後到達 enrollment 畫面
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">enrollButton</span><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">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">EnrollmentScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><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>不可達的路由在 test 中表現為「找不到導航到該路由的 UI 元素」。如果 router 定義了 <code>/enrollment</code> 但首頁沒有對應按鈕，<code>find.text('Enroll Device')</code> 會找不到元素 — test 失敗暴露入口缺失。</p>
<h2 id="go-vs-push-語意的-test"><code>go</code> vs <code>push</code> 語意的 test</h2>
<p><code>go</code> 和 <code>push</code> 對返回堆疊的影響不同（<a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式</a>）。Test 需要驗證正確的導航方式被使用：</p>
<h3 id="push-語意保留返回堆疊">Push 語意：保留返回堆疊</h3>
<p>Push 後按系統 back 鍵應該回到推入前的畫面。</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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;push preserves back stack&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// push to enrollment
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Enroll Device&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 系統 back 鍵
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">backButton</span> <span class="o">=</span> <span class="n">find</span><span class="p">.</span><span class="n">byTooltip</span><span class="p">(</span><span class="s1">&#39;Back&#39;</span><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">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">backButton</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="c1">// 應該回到首頁
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><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><h3 id="go-語意替換路由堆疊">Go 語意：替換路由堆疊</h3>
<p>Go 後按系統 back 鍵的行為依 router 設定。如果 go 到的路由是根層級，系統 back 鍵可能退出 app 而非回到前一個畫面。</p>
<p>Test 策略：驗證 go 後的路由堆疊狀態。如果設計意圖是「切換工作區，不保留前一個畫面」，斷言系統 back 鍵不回到前一個畫面。</p>
<h2 id="深層連結-test">深層連結 test</h2>
<p>深層連結（deep link）讓使用者從 app 外部直接進入特定畫面。Deep link 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="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;deep link to /terminal shows terminal&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">(</span><span class="nl">initialRoute:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>深層連結的特殊性在於使用者跳過了正常的導航流程。從首頁到 terminal 的正常流程可能經過認證 gate，但深層連結直接到 terminal — 認證 gate 是否仍然生效需要額外的 test。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>Playwright 驗證流程 → <a href="/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程</a></li>
<li>路由可達性的設計原則 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a></li>
</ul>
]]></content:encoded></item><item><title>模組二：客戶端可觀測性</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/</guid><description>&lt;p>回答「使用者的裝置上發生了什麼事」。log 設計應在功能規格階段完成，跟 API schema 同級。&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-6&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>&lt;/td>
 &lt;td>6 元件中 4 個零 log，2 個全是 W2 hotfix&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-7&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>&lt;/td>
 &lt;td>事後補的 developer.log 格式不統一&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-9&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>&lt;/td>
 &lt;td>log 設計應在功能規格階段完成 — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 三層 log 設計（連線生命週期 / protocol 訊息 / 使用者行為）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 功能規格中的 log 點定義方法&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 自架 log endpoint vs 商業方案的取捨判斷&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 「事後補 log」vs「設計產物 log」的品質差異&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema&lt;/a>：本模組教「設計 log 點」，monitoring 教「log 收集到之後怎麼處理」&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：log 內容可能含 secret，SDK redaction 在這裡介入&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>：狀態矩陣可加「可觀測性」欄位&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「使用者的裝置上發生了什麼事」。log 設計應在功能規格階段完成，跟 API schema 同級。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TF-6</td>
          <td><a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a></td>
          <td>6 元件中 4 個零 log，2 個全是 W2 hotfix</td>
      </tr>
      <tr>
          <td>TF-7</td>
          <td><a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a></td>
          <td>事後補的 developer.log 格式不統一</td>
      </tr>
      <tr>
          <td>TF-9</td>
          <td><a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a></td>
          <td>log 設計應在功能規格階段完成 — <strong>本模組主寫</strong></td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 三層 log 設計（連線生命週期 / protocol 訊息 / 使用者行為）</li>
<li><input checked="" disabled="" type="checkbox"> 功能規格中的 log 點定義方法</li>
<li><input checked="" disabled="" type="checkbox"> 自架 log endpoint vs 商業方案的取捨判斷</li>
<li><input checked="" disabled="" type="checkbox"> 「事後補 log」vs「設計產物 log」的品質差異</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema</a>：本模組教「設計 log 點」，monitoring 教「log 收集到之後怎麼處理」</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：log 內容可能含 secret，SDK redaction 在這裡介入</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>5.2 testing 基礎</title><link>https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/</guid><description>&lt;p>Go 測試的核心規則是：測試檔以 &lt;code>_test.go&lt;/code> 結尾，測試函式以 &lt;code>Test&lt;/code> 開頭並接收 &lt;code>*testing.T&lt;/code>。本章將說明如何建立第一個單元測試、檢查結果與回報失敗。&lt;/p>
&lt;h2 id="測試是同一個-package-的行為說明">測試是同一個 package 的行為說明&lt;/h2>
&lt;p>Go 測試的核心目標是驗證可觀察行為。測試用輸入、輸出與錯誤條件說明這段程式應該如何運作。&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">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">input&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="k">return&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ToLower&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式的可觀察行為是：移除前後空白，並轉成小寫。測試應該檢查這個行為，而不是檢查函式內部是否真的先呼叫 &lt;code>TrimSpace&lt;/code> 再呼叫 &lt;code>ToLower&lt;/code>。&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">TestNormalizeName&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">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34; Alice &amp;#34;&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">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&lt;/span> &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="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;NormalizeName() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這就是最小的 Go 單元測試：準備輸入，呼叫函式，比對結果，失敗時回報清楚訊息。&lt;/p>
&lt;h2 id="測試檔命名有固定規則">測試檔命名有固定規則&lt;/h2>
&lt;p>Go 測試檔的核心規則是檔名必須以 &lt;code>_test.go&lt;/code> 結尾。測試函式必須以 &lt;code>Test&lt;/code> 開頭，接收一個 &lt;code>*testing.T&lt;/code> 參數，且沒有回傳值。&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="c1">// normalize_test.go&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">TestNormalizeName&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">3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// ...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&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>go test&lt;/code> 會自動找到這些檔案與函式。測試檔可以和被測程式放在同一個 package，也可以使用 &lt;code>package xxx_test&lt;/code> 建立外部測試 package。&lt;/p>
&lt;p>同 package 測試可以存取未匯出的函式與型別，外部測試只能使用匯出的 API。入門階段可以先用同 package 測試，等到需要從使用者視角驗證 public API 時，再使用外部測試。&lt;/p>
&lt;h2 id="失敗訊息要說明-got-與-want">失敗訊息要說明 got 與 want&lt;/h2>
&lt;p>測試失敗訊息的核心責任是幫助讀者快速定位差異。Go 社群常用 &lt;code>got&lt;/code> 與 &lt;code>want&lt;/code> 表示實際結果與預期結果。&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&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">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;NormalizeName() = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個訊息包含函式名稱、實際結果與預期結果。當測試失敗時，讀者不需要再打開測試檔猜哪個值錯了。&lt;/p>
&lt;p>&lt;code>t.Fatal&lt;/code> 與 &lt;code>t.Fatalf&lt;/code> 會立刻中止目前測試；&lt;code>t.Error&lt;/code> 與 &lt;code>t.Errorf&lt;/code> 會記錄失敗但繼續執行。若後續檢查依賴目前結果，使用 &lt;code>Fatalf&lt;/code> 比較安全。&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="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;8080&amp;#34;&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="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">3&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;ParsePort() error = %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">4&lt;/span>&lt;span class="cl">&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="mi">8080&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="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;ParsePort() = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">8080&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>如果解析已經失敗，後面再檢查數值沒有意義，所以先用 &lt;code>Fatalf&lt;/code> 結束測試。&lt;/p>
&lt;h2 id="測試錯誤要明確檢查錯誤存在">測試錯誤要明確檢查錯誤存在&lt;/h2>
&lt;p>錯誤情境測試的核心原則是同時檢查「是否有錯」與「錯誤是否符合預期」。只檢查回傳值常常不足以描述失敗行為。&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">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kt">int&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kt">error&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">port&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">strconv&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Atoi&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;parse port %q: %w&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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"> 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>&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="nx">port&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&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">return&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Errorf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;port must be positive&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">port&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kc">nil&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;/code>&lt;/pre>&lt;/div>&lt;p>測試成功情境時，應確認沒有錯誤；測試失敗情境時，應確認錯誤確實發生。&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">TestParsePortInvalid&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">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;abc&amp;#34;&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="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">4&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;ParsePort() error = nil, want error&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>若程式使用 sentinel error 或可辨識的錯誤型別，可以再用 &lt;code>errors.Is&lt;/code> 或 &lt;code>errors.As&lt;/code> 檢查錯誤種類。不要只比對完整錯誤字串，除非錯誤訊息本身就是公開合約。&lt;/p>
&lt;h2 id="helper-函式可以降低重複">helper 函式可以降低重複&lt;/h2>
&lt;p>測試 helper 的核心用途是隱藏準備資料的細節，而不是隱藏真正的驗證邏輯。helper 應該讓測試主體更接近「這個行為應該成立」。&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">mustParsePort&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="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">int&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Helper&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">port&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="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"> 6&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;ParsePort(%q) error = %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&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"> 7&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">port&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;code>t.Helper()&lt;/code> 會讓失敗行號指向呼叫 helper 的測試，而不是 helper 內部。這能讓測試失敗時更快找到真正的案例位置。&lt;/p>
&lt;p>helper 不應該把測試意圖藏起來。若 helper 名稱太抽象，或讀者必須跳進 helper 才知道測試在驗證什麼，這個 helper 可能反而降低可讀性。&lt;/p>
&lt;h2 id="測試要避免依賴不穩定環境">測試要避免依賴不穩定環境&lt;/h2>
&lt;p>可靠測試的核心規則是讓輸入可控制、輸出可觀察。時間、隨機數、檔案系統、網路與全域狀態都可能讓測試不穩定。&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">IsExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">bool&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="k">return&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式把 &lt;code>now&lt;/code> 當成參數，因此測試可以傳入固定時間。&lt;/p></description><content:encoded><![CDATA[<p>Go 測試的核心規則是：測試檔以 <code>_test.go</code> 結尾，測試函式以 <code>Test</code> 開頭並接收 <code>*testing.T</code>。本章將說明如何建立第一個單元測試、檢查結果與回報失敗。</p>
<h2 id="測試是同一個-package-的行為說明">測試是同一個 package 的行為說明</h2>
<p>Go 測試的核心目標是驗證可觀察行為。測試用輸入、輸出與錯誤條件說明這段程式應該如何運作。</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">NormalizeName</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">input</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">ToLower</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式的可觀察行為是：移除前後空白，並轉成小寫。測試應該檢查這個行為，而不是檢查函式內部是否真的先呼叫 <code>TrimSpace</code> 再呼叫 <code>ToLower</code>。</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">TestNormalizeName</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">got</span> <span class="o">:=</span> <span class="nf">NormalizeName</span><span class="p">(</span><span class="s">&#34;  Alice  &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="s">&#34;alice&#34;</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</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;NormalizeName() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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="p">}</span></span></span></code></pre></div><p>這就是最小的 Go 單元測試：準備輸入，呼叫函式，比對結果，失敗時回報清楚訊息。</p>
<h2 id="測試檔命名有固定規則">測試檔命名有固定規則</h2>
<p>Go 測試檔的核心規則是檔名必須以 <code>_test.go</code> 結尾。測試函式必須以 <code>Test</code> 開頭，接收一個 <code>*testing.T</code> 參數，且沒有回傳值。</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="c1">// normalize_test.go</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">func</span> <span class="nf">TestNormalizeName</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">3</span><span class="cl">    <span class="c1">// ...</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>go test</code> 會自動找到這些檔案與函式。測試檔可以和被測程式放在同一個 package，也可以使用 <code>package xxx_test</code> 建立外部測試 package。</p>
<p>同 package 測試可以存取未匯出的函式與型別，外部測試只能使用匯出的 API。入門階段可以先用同 package 測試，等到需要從使用者視角驗證 public API 時，再使用外部測試。</p>
<h2 id="失敗訊息要說明-got-與-want">失敗訊息要說明 got 與 want</h2>
<p>測試失敗訊息的核心責任是幫助讀者快速定位差異。Go 社群常用 <code>got</code> 與 <code>want</code> 表示實際結果與預期結果。</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</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">Fatalf</span><span class="p">(</span><span class="s">&#34;NormalizeName() = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個訊息包含函式名稱、實際結果與預期結果。當測試失敗時，讀者不需要再打開測試檔猜哪個值錯了。</p>
<p><code>t.Fatal</code> 與 <code>t.Fatalf</code> 會立刻中止目前測試；<code>t.Error</code> 與 <code>t.Errorf</code> 會記錄失敗但繼續執行。若後續檢查依賴目前結果，使用 <code>Fatalf</code> 比較安全。</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="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="s">&#34;8080&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</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">3</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;ParsePort() error = %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="mi">8080</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">7</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;ParsePort() = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="mi">8080</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果解析已經失敗，後面再檢查數值沒有意義，所以先用 <code>Fatalf</code> 結束測試。</p>
<h2 id="測試錯誤要明確檢查錯誤存在">測試錯誤要明確檢查錯誤存在</h2>
<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">ParsePort</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">int</span><span class="p">,</span> <span class="kt">error</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">port</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">strconv</span><span class="p">.</span><span class="nf">Atoi</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</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"> 4</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;parse port %q: %w&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">if</span> <span class="nx">port</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Errorf</span><span class="p">(</span><span class="s">&#34;port must be positive&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">port</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">12</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">TestParsePortInvalid</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">_</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="s">&#34;abc&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</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">4</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;ParsePort() error = nil, want error&#34;</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="p">}</span></span></span></code></pre></div><p>若程式使用 sentinel error 或可辨識的錯誤型別，可以再用 <code>errors.Is</code> 或 <code>errors.As</code> 檢查錯誤種類。不要只比對完整錯誤字串，除非錯誤訊息本身就是公開合約。</p>
<h2 id="helper-函式可以降低重複">helper 函式可以降低重複</h2>
<p>測試 helper 的核心用途是隱藏準備資料的細節，而不是隱藏真正的驗證邏輯。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">mustParsePort</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">input</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">int</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">port</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 5</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"> 6</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;ParsePort(%q) error = %v&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">,</span> <span class="nx">err</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="k">return</span> <span class="nx">port</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>t.Helper()</code> 會讓失敗行號指向呼叫 helper 的測試，而不是 helper 內部。這能讓測試失敗時更快找到真正的案例位置。</p>
<p>helper 不應該把測試意圖藏起來。若 helper 名稱太抽象，或讀者必須跳進 helper 才知道測試在驗證什麼，這個 helper 可能反而降低可讀性。</p>
<h2 id="測試要避免依賴不穩定環境">測試要避免依賴不穩定環境</h2>
<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">IsExpired</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">now</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式把 <code>now</code> 當成參數，因此測試可以傳入固定時間。</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">TestIsExpired</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">now</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</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">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">9</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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="k">if</span> <span class="p">!</span><span class="nf">IsExpired</span><span class="p">(</span><span class="nx">now</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="nx">t</span><span class="p">.</span><span class="nf">Fatalf</span><span class="p">(</span><span class="s">&#34;IsExpired() = false, want true&#34;</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="p">}</span></span></span></code></pre></div><p>測試要在合適層級控制變因。單元測試優先控制依賴，整合測試才使用真實檔案、網路或服務。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會介紹 table-driven test，說明如何用同一個測試流程整理多組案例。</p>
]]></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>Assertion 品質三問</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/</guid><description>&lt;p>Assertion 是 test 的結論 — 「我認為程式碼的行為應該是 X」。Assertion 的品質決定了 test 的有效性：無效的 assertion 讓 test 通過但問題仍在，或讓 test 隨機失敗但問題不在程式碼。&lt;/p>
&lt;h2 id="三個判斷問題">三個判斷問題&lt;/h2>
&lt;h3 id="斷言的是行為嗎">斷言的是行為嗎&lt;/h3>
&lt;p>Assertion 應該斷言程式碼的外部可觀察行為（回傳值、狀態變化、副作用），而非內部實作細節（私有變數的值、呼叫次數、執行順序）。&lt;/p>
&lt;p>斷言行為的 test 在重構時不需要改 — 只要行為不變，test 就通過。斷言實作的 test 在任何內部調整時都會壞掉，即使行為完全正確。&lt;/p>
&lt;p>例：驗證「parser 正確解析紅色文字」時，斷言 token 的顏色屬性（行為）比斷言 parser 內部的 state machine 走了哪些步驟（實作）更穩定。&lt;/p>
&lt;h3 id="能區分正確和錯誤嗎">能區分正確和錯誤嗎&lt;/h3>
&lt;p>Assertion 應該在程式碼正確時通過、錯誤時失敗。如果 assertion 無論程式碼正確或錯誤都通過，這個 assertion 沒有提供保護。&lt;/p>
&lt;p>常見的無效 assertion：&lt;/p>
&lt;p>&lt;strong>斷言不為 null&lt;/strong>：&lt;code>expect(result, isNotNull)&lt;/code> 只驗證「有回傳值」，不驗證「回傳值正確」。回傳錯誤的值也會通過。&lt;/p>
&lt;p>&lt;strong>斷言型別&lt;/strong>：&lt;code>expect(result, isA&amp;lt;List&amp;gt;())&lt;/code> 只驗證「回傳 List」，不驗證 List 的內容。空 List 和錯誤內容的 List 都會通過。&lt;/p>
&lt;p>&lt;strong>斷言包含&lt;/strong>：&lt;code>expect(result, contains('error'))&lt;/code> 驗證字串包含 &amp;rsquo;error&amp;rsquo;，但如果回傳 &amp;rsquo;no error occurred&amp;rsquo;（正確情境）也包含 &amp;rsquo;error&amp;rsquo; — assertion 無法區分正確和錯誤。&lt;/p>
&lt;p>T.C3 的 parser test 斷言 &lt;code>expect(tokens.first, isA&amp;lt;TextToken&amp;gt;())&lt;/code> — 驗證 token 型別是 TextToken。但正確解析和透傳亂碼都可能產生 TextToken，assertion 無法區分（本章合成，TF-5 Derive — 透傳的靜默副作用和 assertion 的區分力有 tension）。&lt;/p>
&lt;h3 id="會-flaky-嗎">會 flaky 嗎&lt;/h3>
&lt;p>Assertion 是否依賴非確定性因素 — 時間、隨機數、外部服務狀態、執行順序。如果是，test 可能在程式碼正確時失敗（false negative），降低團隊對 test 的信任。&lt;/p>
&lt;p>常見的 flaky assertion 來源：&lt;/p>
&lt;ul>
&lt;li>依賴 &lt;code>DateTime.now()&lt;/code> 或 &lt;code>stopwatch.elapsed&lt;/code> — 時間精度和系統負載影響結果&lt;/li>
&lt;li>依賴特定的執行順序 — &lt;code>Set&lt;/code> 或 &lt;code>Map&lt;/code> 的迭代順序不保證&lt;/li>
&lt;li>依賴外部服務的回應時間 — 網路延遲導致 timeout&lt;/li>
&lt;/ul>
&lt;h2 id="assertion-改善的操作步驟">Assertion 改善的操作步驟&lt;/h2>
&lt;p>對既有的 test assertion 逐一問三個問題，標記需要改善的：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>行為 check&lt;/strong>：assertion 斷言的是 public API 的回傳值或狀態嗎？如果斷言私有變數或呼叫次數，考慮改成行為斷言。&lt;/li>
&lt;li>&lt;strong>區分 check&lt;/strong>：把 assertion 改成反向（&lt;code>expect(result, 'wrong_value')&lt;/code>），test 會失敗嗎？如果 assertion 太寬鬆（isNotNull、isA），test 可能在錯誤的情況下也通過。&lt;/li>
&lt;li>&lt;strong>穩定 check&lt;/strong>：連續跑 10 次，每次都通過嗎？如果有 flaky，找到依賴的非確定性因素。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Flaky test 的系統性根因分類 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/flaky-test-root-cause/" data-link-title="Flaky test 根因分類" data-link-desc="計時依賴 / 環境差異 / 資源競爭 / 非確定性輸出 — 四類 flaky test 根因的辨識和處理策略">Flaky test 根因分類&lt;/a>&lt;/li>
&lt;li>測試資料的代表性 → &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 能發現什麼問題">Test data 代表性&lt;/a>&lt;/li>
&lt;li>Mock 邊界判斷 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/mock-boundary-decision/" data-link-title="Mock 邊界判斷決策表" data-link-desc="什麼時候 mock 夠用、什麼時候需要真實服務 — 從 API 層 / 協議層 / 環境層的斷裂點判斷 mock 的適用範圍">Mock 邊界判斷決策表&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Assertion 是 test 的結論 — 「我認為程式碼的行為應該是 X」。Assertion 的品質決定了 test 的有效性：無效的 assertion 讓 test 通過但問題仍在，或讓 test 隨機失敗但問題不在程式碼。</p>
<h2 id="三個判斷問題">三個判斷問題</h2>
<h3 id="斷言的是行為嗎">斷言的是行為嗎</h3>
<p>Assertion 應該斷言程式碼的外部可觀察行為（回傳值、狀態變化、副作用），而非內部實作細節（私有變數的值、呼叫次數、執行順序）。</p>
<p>斷言行為的 test 在重構時不需要改 — 只要行為不變，test 就通過。斷言實作的 test 在任何內部調整時都會壞掉，即使行為完全正確。</p>
<p>例：驗證「parser 正確解析紅色文字」時，斷言 token 的顏色屬性（行為）比斷言 parser 內部的 state machine 走了哪些步驟（實作）更穩定。</p>
<h3 id="能區分正確和錯誤嗎">能區分正確和錯誤嗎</h3>
<p>Assertion 應該在程式碼正確時通過、錯誤時失敗。如果 assertion 無論程式碼正確或錯誤都通過，這個 assertion 沒有提供保護。</p>
<p>常見的無效 assertion：</p>
<p><strong>斷言不為 null</strong>：<code>expect(result, isNotNull)</code> 只驗證「有回傳值」，不驗證「回傳值正確」。回傳錯誤的值也會通過。</p>
<p><strong>斷言型別</strong>：<code>expect(result, isA&lt;List&gt;())</code> 只驗證「回傳 List」，不驗證 List 的內容。空 List 和錯誤內容的 List 都會通過。</p>
<p><strong>斷言包含</strong>：<code>expect(result, contains('error'))</code> 驗證字串包含 &rsquo;error&rsquo;，但如果回傳 &rsquo;no error occurred&rsquo;（正確情境）也包含 &rsquo;error&rsquo; — assertion 無法區分正確和錯誤。</p>
<p>T.C3 的 parser test 斷言 <code>expect(tokens.first, isA&lt;TextToken&gt;())</code> — 驗證 token 型別是 TextToken。但正確解析和透傳亂碼都可能產生 TextToken，assertion 無法區分（本章合成，TF-5 Derive — 透傳的靜默副作用和 assertion 的區分力有 tension）。</p>
<h3 id="會-flaky-嗎">會 flaky 嗎</h3>
<p>Assertion 是否依賴非確定性因素 — 時間、隨機數、外部服務狀態、執行順序。如果是，test 可能在程式碼正確時失敗（false negative），降低團隊對 test 的信任。</p>
<p>常見的 flaky assertion 來源：</p>
<ul>
<li>依賴 <code>DateTime.now()</code> 或 <code>stopwatch.elapsed</code> — 時間精度和系統負載影響結果</li>
<li>依賴特定的執行順序 — <code>Set</code> 或 <code>Map</code> 的迭代順序不保證</li>
<li>依賴外部服務的回應時間 — 網路延遲導致 timeout</li>
</ul>
<h2 id="assertion-改善的操作步驟">Assertion 改善的操作步驟</h2>
<p>對既有的 test assertion 逐一問三個問題，標記需要改善的：</p>
<ol>
<li><strong>行為 check</strong>：assertion 斷言的是 public API 的回傳值或狀態嗎？如果斷言私有變數或呼叫次數，考慮改成行為斷言。</li>
<li><strong>區分 check</strong>：把 assertion 改成反向（<code>expect(result, 'wrong_value')</code>），test 會失敗嗎？如果 assertion 太寬鬆（isNotNull、isA），test 可能在錯誤的情況下也通過。</li>
<li><strong>穩定 check</strong>：連續跑 10 次，每次都通過嗎？如果有 flaky，找到依賴的非確定性因素。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Flaky test 的系統性根因分類 → <a href="/blog/testing/05-test-design-judgment/flaky-test-root-cause/" data-link-title="Flaky test 根因分類" data-link-desc="計時依賴 / 環境差異 / 資源競爭 / 非確定性輸出 — 四類 flaky test 根因的辨識和處理策略">Flaky test 根因分類</a></li>
<li>測試資料的代表性 → <a href="/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">Test data 代表性</a></li>
<li>Mock 邊界判斷 → <a href="/blog/testing/05-test-design-judgment/mock-boundary-decision/" data-link-title="Mock 邊界判斷決策表" data-link-desc="什麼時候 mock 夠用、什麼時候需要真實服務 — 從 API 層 / 協議層 / 環境層的斷裂點判斷 mock 的適用範圍">Mock 邊界判斷決策表</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>Playwright 瀏覽器驗證流程</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/playwright-verification/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/playwright-verification/</guid><description>&lt;p>Playwright 是瀏覽器自動化工具，在真實瀏覽器中執行 UI 操作並驗證結果。和 Flutter 的 widget test 不同，Playwright 操作的是瀏覽器中的 DOM 元素，驗證的是使用者在瀏覽器中實際看到的畫面。&lt;/p>
&lt;h2 id="playwright-和-widget-test-的互補">Playwright 和 widget test 的互補&lt;/h2>
&lt;p>Widget test 在 Flutter test framework 中執行，不需要瀏覽器，驗證的是 widget tree 的結構和狀態。Playwright 在真實瀏覽器中執行，驗證的是渲染後的 DOM 和視覺呈現。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Widget test&lt;/th>
 &lt;th>Playwright&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>執行環境&lt;/td>
 &lt;td>Flutter test framework&lt;/td>
 &lt;td>真實瀏覽器（Chromium 等）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證對象&lt;/td>
 &lt;td>Widget tree 結構&lt;/td>
 &lt;td>DOM 元素和視覺呈現&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>視覺驗證、跨瀏覽器相容&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CSS 驗證&lt;/td>
 &lt;td>無法驗證 CSS 渲染&lt;/td>
 &lt;td>可以驗證 CSS 效果&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者的分工：widget test 驗證「邏輯上正確」（該有的元素存在、該觸發的事件發生），Playwright 驗證「視覺上正確」（元素在正確的位置、顏色和大小符合設計）。&lt;/p>
&lt;h2 id="playwright-test-的基本結構">Playwright test 的基本結構&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">test&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">expect&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@playwright/test&amp;#39;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal screen shows connection status&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&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="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;http://localhost:8080&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 點擊連線按鈕
&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="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;text=Connect Terminal&amp;#39;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 等待畫面轉換
&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="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForSelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;terminal-screen&amp;#34;]&amp;#39;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 驗證連線狀態顯示
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">status&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;connection-status&amp;#34;]&amp;#39;&lt;/span>&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="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">status&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeVisible&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="三個位置的斷言">三個位置的斷言&lt;/h3>
&lt;p>Playwright test 中的斷言放在三個位置，各自驗證不同的東西：&lt;/p>
&lt;p>&lt;strong>假設斷言（test 開頭）&lt;/strong>：驗證 test 的前置條件。頁面載入成功、初始狀態正確。如果假設斷言失敗，test 的後續結果不可信。&lt;/p>
&lt;p>&lt;strong>行為斷言（操作之後）&lt;/strong>：驗證 UI 操作的即時效果。點擊按鈕後 dialog 出現、表單提交後顯示成功訊息。&lt;/p>
&lt;p>&lt;strong>互動斷言（流程結束）&lt;/strong>：驗證完整操作流程的最終狀態。多步驟操作完成後畫面回到預期狀態。&lt;/p>
&lt;h2 id="selector-策略">Selector 策略&lt;/h2>
&lt;p>Playwright 用 selector 定位 DOM 元素。Selector 的穩定性決定了 test 的維護成本。&lt;/p>
&lt;h3 id="推薦data-testid">推薦：data-testid&lt;/h3>
&lt;p>在 HTML 元素上加 &lt;code>data-testid&lt;/code> 屬性，Playwright 用 &lt;code>[data-testid=&amp;quot;xxx&amp;quot;]&lt;/code> 定位。&lt;code>data-testid&lt;/code> 不受 CSS class 改名、文字內容變更、DOM 結構調整影響。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">button&lt;/span> &lt;span class="na">data-testid&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;connect-button&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>Connect Terminal&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="可接受文字內容">可接受：文字內容&lt;/h3>
&lt;p>用 &lt;code>text=Connect Terminal&lt;/code> 定位。在按鈕文字穩定的場景下可用，但多語系支援或文案調整時會斷。&lt;/p>
&lt;h3 id="避免css-selector">避免：CSS selector&lt;/h3>
&lt;p>用 &lt;code>.btn-primary&lt;/code> 或 &lt;code>#main-content &amp;gt; div:nth-child(2)&lt;/code> 定位。CSS class 和 DOM 結構的改動頻率高，test 頻繁因無關變更而失敗。&lt;/p>
&lt;h2 id="和開發伺服器的整合">和開發伺服器的整合&lt;/h2>
&lt;p>Playwright test 需要一個正在運行的 web 應用。整合方式：&lt;/p>
&lt;p>&lt;strong>手動啟動&lt;/strong>：開發者先啟動 dev server，再跑 Playwright test。適合本地開發。&lt;/p>
&lt;p>&lt;strong>自動啟動&lt;/strong>：Playwright 設定檔中指定 &lt;code>webServer&lt;/code> 配置，Playwright 自動啟動 dev server，test 結束後自動停止。適合 CI。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// playwright.config.ts
&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="kr">export&lt;/span> &lt;span class="k">default&lt;/span> &lt;span class="nx">defineConfig&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">webServer&lt;/span>&lt;span class="o">:&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="nx">command&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;npm run dev&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="nx">port&lt;/span>: &lt;span class="kt">8080&lt;/span>&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="nx">reuseExistingServer&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CI&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="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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>視覺比對 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對&lt;/a>&lt;/li>
&lt;li>狀態覆蓋策略 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略&lt;/a>&lt;/li>
&lt;li>導航路徑 test → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Playwright 是瀏覽器自動化工具，在真實瀏覽器中執行 UI 操作並驗證結果。和 Flutter 的 widget test 不同，Playwright 操作的是瀏覽器中的 DOM 元素，驗證的是使用者在瀏覽器中實際看到的畫面。</p>
<h2 id="playwright-和-widget-test-的互補">Playwright 和 widget test 的互補</h2>
<p>Widget test 在 Flutter test framework 中執行，不需要瀏覽器，驗證的是 widget tree 的結構和狀態。Playwright 在真實瀏覽器中執行，驗證的是渲染後的 DOM 和視覺呈現。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Widget test</th>
          <th>Playwright</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>執行環境</td>
          <td>Flutter test framework</td>
          <td>真實瀏覽器（Chromium 等）</td>
      </tr>
      <tr>
          <td>驗證對象</td>
          <td>Widget tree 結構</td>
          <td>DOM 元素和視覺呈現</td>
      </tr>
      <tr>
          <td>速度</td>
          <td>毫秒級</td>
          <td>秒級</td>
      </tr>
      <tr>
          <td>穩定性</td>
          <td>高（無瀏覽器差異）</td>
          <td>中（瀏覽器行為差異）</td>
      </tr>
      <tr>
          <td>適用場景</td>
          <td>邏輯驗證、狀態覆蓋</td>
          <td>視覺驗證、跨瀏覽器相容</td>
      </tr>
      <tr>
          <td>CSS 驗證</td>
          <td>無法驗證 CSS 渲染</td>
          <td>可以驗證 CSS 效果</td>
      </tr>
  </tbody>
</table>
<p>兩者的分工：widget test 驗證「邏輯上正確」（該有的元素存在、該觸發的事件發生），Playwright 驗證「視覺上正確」（元素在正確的位置、顏色和大小符合設計）。</p>
<h2 id="playwright-test-的基本結構">Playwright test 的基本結構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@playwright/test&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;terminal screen shows connection status&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;http://localhost:8080&#39;</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">// 點擊連線按鈕
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;text=Connect Terminal&#39;</span><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="c1">// 等待畫面轉換
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;terminal-screen&#34;]&#39;</span><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="c1">// 驗證連線狀態顯示
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;connection-status&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">status</span><span class="p">).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="三個位置的斷言">三個位置的斷言</h3>
<p>Playwright test 中的斷言放在三個位置，各自驗證不同的東西：</p>
<p><strong>假設斷言（test 開頭）</strong>：驗證 test 的前置條件。頁面載入成功、初始狀態正確。如果假設斷言失敗，test 的後續結果不可信。</p>
<p><strong>行為斷言（操作之後）</strong>：驗證 UI 操作的即時效果。點擊按鈕後 dialog 出現、表單提交後顯示成功訊息。</p>
<p><strong>互動斷言（流程結束）</strong>：驗證完整操作流程的最終狀態。多步驟操作完成後畫面回到預期狀態。</p>
<h2 id="selector-策略">Selector 策略</h2>
<p>Playwright 用 selector 定位 DOM 元素。Selector 的穩定性決定了 test 的維護成本。</p>
<h3 id="推薦data-testid">推薦：data-testid</h3>
<p>在 HTML 元素上加 <code>data-testid</code> 屬性，Playwright 用 <code>[data-testid=&quot;xxx&quot;]</code> 定位。<code>data-testid</code> 不受 CSS class 改名、文字內容變更、DOM 結構調整影響。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">button</span> <span class="na">data-testid</span><span class="o">=</span><span class="s">&#34;connect-button&#34;</span><span class="p">&gt;</span>Connect Terminal<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span></span></span></code></pre></div><h3 id="可接受文字內容">可接受：文字內容</h3>
<p>用 <code>text=Connect Terminal</code> 定位。在按鈕文字穩定的場景下可用，但多語系支援或文案調整時會斷。</p>
<h3 id="避免css-selector">避免：CSS selector</h3>
<p>用 <code>.btn-primary</code> 或 <code>#main-content &gt; div:nth-child(2)</code> 定位。CSS class 和 DOM 結構的改動頻率高，test 頻繁因無關變更而失敗。</p>
<h2 id="和開發伺服器的整合">和開發伺服器的整合</h2>
<p>Playwright test 需要一個正在運行的 web 應用。整合方式：</p>
<p><strong>手動啟動</strong>：開發者先啟動 dev server，再跑 Playwright test。適合本地開發。</p>
<p><strong>自動啟動</strong>：Playwright 設定檔中指定 <code>webServer</code> 配置，Playwright 自動啟動 dev server，test 結束後自動停止。適合 CI。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// playwright.config.ts
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="k">default</span> <span class="nx">defineConfig</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">webServer</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">command</span><span class="o">:</span> <span class="s1">&#39;npm run dev&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">port</span>: <span class="kt">8080</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">reuseExistingServer</span><span class="o">:</span> <span class="o">!</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CI</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="p">});</span></span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>視覺比對 → <a href="/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對</a></li>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>導航路徑 test → <a href="/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test</a></li>
</ul>
]]></content:encoded></item><item><title>T.C3 ANSI parser 測試資料不覆蓋真實 shell output</title><link>https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 unit test 的輸入資料品質如何決定測試的有效性。Parser 邏輯正確、斷言正確、覆蓋率高 — 但測試資料是人工挑選的乾淨子集，跟真實環境的輸入分佈不同。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的 &lt;code>AnsiParser&lt;/code> 負責解析終端機輸出的 ANSI escape 序列，轉換為帶色彩的文字 token。unit test 用手寫字串驗證：&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">// 測試資料範例 — 乾淨的 SGR 色彩碼
&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;紅色文字&amp;#39;&lt;/span>&lt;span class="p">,&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">tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="se">\x1B&lt;/span>&lt;span class="s1">[31mhello&lt;/span>&lt;span class="se">\x1B&lt;/span>&lt;span class="s1">[0m&amp;#39;&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">first&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">isA&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">TextToken&lt;/span>&lt;span class="o">&amp;gt;&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;/code>&lt;/pre>&lt;/div>&lt;p>真實 zsh prompt 啟動後送出的控制序列（擷取自實機 log）：&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">\x1B]0;user@host: ~\x07 ← OSC：設定終端機視窗標題
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">\x1B[?2004h ← CSI private mode：啟用括號貼上模式
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">\x1B[?1h ← CSI private mode：啟用應用程式游標鍵
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">\x1B(B ← 字元集指定：選擇 ASCII
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">\x1B[?25l ← CSI private mode：隱藏游標&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Parser 只認識 &lt;code>\x1B[{數字;數字}{字母}&lt;/code> 格式的標準 CSI，其他全部殘留在輸出中。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>測試案例數&lt;/td>
 &lt;td>18 個 AnsiParser test，全過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試覆蓋的序列類型&lt;/td>
 &lt;td>SGR 色彩碼（&lt;code>\x1B[31m&lt;/code> 等）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>真實環境的序列類型&lt;/td>
 &lt;td>SGR + OSC + CSI private mode + 字元集指定 + 其他&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>終端機畫面散佈 &lt;code>]0;user@host&lt;/code> 等亂碼片段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>新增 3 個 RegExp 過濾 OSC / CSI private / 其他 escape&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>測試資料的代表性是隱性假設&lt;/strong>。18 個 test 的斷言都正確 — &lt;code>\x1B[31m&lt;/code> 確實應該產生紅色 token。但「測試輸入能代表真實輸入」是一個未經驗證的假設。真實 zsh 的輸出包含 5+ 種 escape 序列類型，測試只覆蓋了 1 種。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Parser 的行為對未知序列是「透傳」而非「報錯」&lt;/strong>。這是合理的設計 — 不認識的序列不應該讓 parser 崩潰。但透傳的後果是亂碼靜默出現在畫面上，不觸發任何錯誤或 log，開發者在 unit test 環境完全不會察覺。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>手寫測試資料 vs 錄製真實資料&lt;/strong>。如果測試資料是從真實 shell session 錄製的（capture 一次真實 zsh 啟動輸出），OSC 和 CSI private mode 會自然出現在測試輸入中，parser 的透傳行為會在 test 階段就被看到。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>從真實環境錄製測試資料&lt;/strong>：用 &lt;code>script&lt;/code> 命令或 WebSocket log 錄一次真實 shell session 的完整輸出，作為 integration test 的輸入。即使不改 parser 邏輯，至少能看到「哪些序列被透傳了」。&lt;/li>
&lt;li>&lt;strong>Parser 對未知序列記 warning log&lt;/strong>：透傳是合理的 fallback，但加一行 &lt;code>developer.log('Unknown escape: ${escape.codeUnits}')&lt;/code> 讓開發者知道有未處理的序列。&lt;/li>
&lt;li>&lt;strong>測試分兩類&lt;/strong>：「功能正確性」用手寫乾淨字串；「環境相容性」用錄製的真實輸出。兩類測試回答不同問題。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想理解測試資料代表性 → &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 能發現什麼問題">Test data 代表性&lt;/a>&lt;/li>
&lt;li>想建 protocol integration test 用真實 ttyd 輸出 → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>類似案例（mock 遮蔽） → &lt;a href="https://tarrragon.github.io/blog/testing/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 WS frame type mock 遮蔽&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 unit test 的輸入資料品質如何決定測試的有效性。Parser 邏輯正確、斷言正確、覆蓋率高 — 但測試資料是人工挑選的乾淨子集，跟真實環境的輸入分佈不同。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的 <code>AnsiParser</code> 負責解析終端機輸出的 ANSI escape 序列，轉換為帶色彩的文字 token。unit 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">// 測試資料範例 — 乾淨的 SGR 色彩碼
</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;紅色文字&#39;</span><span class="p">,</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">tokens</span> <span class="o">=</span> <span class="n">parser</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span><span class="s1">&#39;</span><span class="se">\x1B</span><span class="s1">[31mhello</span><span class="se">\x1B</span><span class="s1">[0m&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">tokens</span><span class="p">.</span><span class="n">first</span><span class="p">,</span> <span class="n">isA</span><span class="o">&lt;</span><span class="n">TextToken</span><span class="o">&gt;</span><span class="p">());</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>真實 zsh prompt 啟動後送出的控制序列（擷取自實機 log）：</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">\x1B]0;user@host: ~\x07          ← OSC：設定終端機視窗標題
</span></span><span class="line"><span class="ln">2</span><span class="cl">\x1B[?2004h                      ← CSI private mode：啟用括號貼上模式
</span></span><span class="line"><span class="ln">3</span><span class="cl">\x1B[?1h                         ← CSI private mode：啟用應用程式游標鍵
</span></span><span class="line"><span class="ln">4</span><span class="cl">\x1B(B                           ← 字元集指定：選擇 ASCII
</span></span><span class="line"><span class="ln">5</span><span class="cl">\x1B[?25l                        ← CSI private mode：隱藏游標</span></span></code></pre></div><p>Parser 只認識 <code>\x1B[{數字;數字}{字母}</code> 格式的標準 CSI，其他全部殘留在輸出中。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>測試案例數</td>
          <td>18 個 AnsiParser test，全過</td>
      </tr>
      <tr>
          <td>測試覆蓋的序列類型</td>
          <td>SGR 色彩碼（<code>\x1B[31m</code> 等）</td>
      </tr>
      <tr>
          <td>真實環境的序列類型</td>
          <td>SGR + OSC + CSI private mode + 字元集指定 + 其他</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>終端機畫面散佈 <code>]0;user@host</code> 等亂碼片段</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>新增 3 個 RegExp 過濾 OSC / CSI private / 其他 escape</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>測試資料的代表性是隱性假設</strong>。18 個 test 的斷言都正確 — <code>\x1B[31m</code> 確實應該產生紅色 token。但「測試輸入能代表真實輸入」是一個未經驗證的假設。真實 zsh 的輸出包含 5+ 種 escape 序列類型，測試只覆蓋了 1 種。</p>
</li>
<li>
<p><strong>Parser 的行為對未知序列是「透傳」而非「報錯」</strong>。這是合理的設計 — 不認識的序列不應該讓 parser 崩潰。但透傳的後果是亂碼靜默出現在畫面上，不觸發任何錯誤或 log，開發者在 unit test 環境完全不會察覺。</p>
</li>
<li>
<p><strong>手寫測試資料 vs 錄製真實資料</strong>。如果測試資料是從真實 shell session 錄製的（capture 一次真實 zsh 啟動輸出），OSC 和 CSI private mode 會自然出現在測試輸入中，parser 的透傳行為會在 test 階段就被看到。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>從真實環境錄製測試資料</strong>：用 <code>script</code> 命令或 WebSocket log 錄一次真實 shell session 的完整輸出，作為 integration test 的輸入。即使不改 parser 邏輯，至少能看到「哪些序列被透傳了」。</li>
<li><strong>Parser 對未知序列記 warning log</strong>：透傳是合理的 fallback，但加一行 <code>developer.log('Unknown escape: ${escape.codeUnits}')</code> 讓開發者知道有未處理的序列。</li>
<li><strong>測試分兩類</strong>：「功能正確性」用手寫乾淨字串；「環境相容性」用錄製的真實輸出。兩類測試回答不同問題。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想理解測試資料代表性 → <a href="/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">Test data 代表性</a></li>
<li>想建 protocol integration test 用真實 ttyd 輸出 → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>類似案例（mock 遮蔽） → <a href="/blog/testing/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 WS frame type mock 遮蔽</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>自架 log endpoint vs 商業方案的取捨判斷</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/</guid><description>&lt;p>Log 收集方案的選擇取決於兩個因素：使用者在哪裡（同機 / 同網段 / 外部網路），以及 log 的消費者是誰（開發者自己 / 維運團隊 / 客服團隊）。自用工具和商業產品對這兩個因素的答案不同，適合不同的方案。&lt;/p>
&lt;h2 id="自架-log-endpoint-的適用場景">自架 log endpoint 的適用場景&lt;/h2>
&lt;p>自架 log endpoint 適合的前提是：client 和 server 在同一個網路內（同機、同 LAN、同 VPN/tailnet），log 的唯一消費者是開發者本人。&lt;/p>
&lt;p>app_tunnel 就是這個場景。Server（ttyd）和 client（Flutter app）在同一台機器或同一個 Tailscale tailnet 內。開發者同時是使用者和維運者。Log 的消費方式是 grep — 不需要 dashboard、不需要告警、不需要多人共享。&lt;/p>
&lt;p>在這個場景下，自架 log endpoint 的成本遠低於商業方案。一個 Go 程式開 HTTP endpoint 接收 JSON log 寫入檔案，20 行程式碼就能完成。Client 端的 &lt;code>AppLogger&lt;/code> 在 debug mode 同時寫 console 和 POST 到 endpoint。Debug 時用 &lt;code>grep&lt;/code> + &lt;code>jq&lt;/code> 查詢，不需要額外工具。&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">Client (Flutter) → HTTP POST /log → Go receiver → JSON file → grep/jq&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個方案沒有外部依賴、沒有帳號管理、沒有費用、沒有資料隱私顧慮（log 不離開本機網路）。&lt;/p>
&lt;h2 id="商業方案的適用場景">商業方案的適用場景&lt;/h2>
&lt;p>商業方案（Sentry、Crashlytics、Datadog）適合的前提是：使用者分佈在外部網路，log 的消費者包含非開發者（維運、客服、產品），且需要告警和趨勢分析。&lt;/p>
&lt;p>商業方案提供的能力包括：跨網路收集（SDK 自動處理網路不穩定和批次傳輸）、多人查看 dashboard、告警規則設定、crash 報告自動分群、用戶 session 重播。這些能力在自用工具場景下不需要，在商業產品場景下是基礎需求。&lt;/p>
&lt;p>商業方案的成本包括：SDK 整合和設定、帳號和權限管理、月費（依事件量計費）、資料隱私合規（log 傳到第三方伺服器）。&lt;/p>
&lt;h2 id="判斷流程">判斷流程&lt;/h2>
&lt;h3 id="使用者在哪裡">使用者在哪裡&lt;/h3>
&lt;p>使用者和 server 在同一個網路內（自用工具、內部工具、開發期測試）→ 自架 log endpoint 是成本最低的選擇。&lt;/p>
&lt;p>使用者在外部網路（上架 app store、SaaS 產品、B2B 部署）→ 商業方案的跨網路收集能力是必要的，自架需要處理的 edge case（離線緩衝、重試、批次傳輸）太多。&lt;/p>
&lt;h3 id="log-消費者是誰">Log 消費者是誰&lt;/h3>
&lt;p>只有開發者自己 → grep/jq 足夠，不需要 dashboard。&lt;/p>
&lt;p>包含非技術人員（客服、產品經理）→ 需要視覺化 dashboard 和搜尋介面，商業方案的 UI 是這個需求的標準答案。&lt;/p>
&lt;h3 id="是否需要告警">是否需要告警&lt;/h3>
&lt;p>開發者自己用、即時看 log → 不需要告警。&lt;/p>
&lt;p>有維運值班、需要被動發現問題 → 需要告警規則，商業方案內建。&lt;/p>
&lt;h2 id="混合方案">混合方案&lt;/h2>
&lt;p>開發期用自架 log endpoint（零成本、即時可用），production 切換到商業方案 — 這個策略可行的前提是 log 層的 API 設計足夠抽象。&lt;/p>
&lt;p>&lt;code>AppLogger&lt;/code> 提供統一的 log 介面（&lt;code>log(level, name, data)&lt;/code>），底層實作在 debug mode 寫 console + POST 到本機 endpoint，在 release mode 寫 console + 呼叫 Sentry/Crashlytics SDK。切換只改 &lt;code>AppLogger&lt;/code> 的底層實作，不改呼叫端。&lt;/p>
&lt;p>這個抽象的投資在自用工具階段就值得做 — 即使目前不需要商業方案，統一的 log 介面也讓 log 點的管理更一致。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>三層 log 的詳細設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>在功能規格中定義 log 點 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法&lt;/a>&lt;/li>
&lt;li>Log 收集後的 schema 設計 → &lt;a href="https://tarrragon.github.io/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Log 收集方案的選擇取決於兩個因素：使用者在哪裡（同機 / 同網段 / 外部網路），以及 log 的消費者是誰（開發者自己 / 維運團隊 / 客服團隊）。自用工具和商業產品對這兩個因素的答案不同，適合不同的方案。</p>
<h2 id="自架-log-endpoint-的適用場景">自架 log endpoint 的適用場景</h2>
<p>自架 log endpoint 適合的前提是：client 和 server 在同一個網路內（同機、同 LAN、同 VPN/tailnet），log 的唯一消費者是開發者本人。</p>
<p>app_tunnel 就是這個場景。Server（ttyd）和 client（Flutter app）在同一台機器或同一個 Tailscale tailnet 內。開發者同時是使用者和維運者。Log 的消費方式是 grep — 不需要 dashboard、不需要告警、不需要多人共享。</p>
<p>在這個場景下，自架 log endpoint 的成本遠低於商業方案。一個 Go 程式開 HTTP endpoint 接收 JSON log 寫入檔案，20 行程式碼就能完成。Client 端的 <code>AppLogger</code> 在 debug mode 同時寫 console 和 POST 到 endpoint。Debug 時用 <code>grep</code> + <code>jq</code> 查詢，不需要額外工具。</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">Client (Flutter) → HTTP POST /log → Go receiver → JSON file → grep/jq</span></span></code></pre></div><p>這個方案沒有外部依賴、沒有帳號管理、沒有費用、沒有資料隱私顧慮（log 不離開本機網路）。</p>
<h2 id="商業方案的適用場景">商業方案的適用場景</h2>
<p>商業方案（Sentry、Crashlytics、Datadog）適合的前提是：使用者分佈在外部網路，log 的消費者包含非開發者（維運、客服、產品），且需要告警和趨勢分析。</p>
<p>商業方案提供的能力包括：跨網路收集（SDK 自動處理網路不穩定和批次傳輸）、多人查看 dashboard、告警規則設定、crash 報告自動分群、用戶 session 重播。這些能力在自用工具場景下不需要，在商業產品場景下是基礎需求。</p>
<p>商業方案的成本包括：SDK 整合和設定、帳號和權限管理、月費（依事件量計費）、資料隱私合規（log 傳到第三方伺服器）。</p>
<h2 id="判斷流程">判斷流程</h2>
<h3 id="使用者在哪裡">使用者在哪裡</h3>
<p>使用者和 server 在同一個網路內（自用工具、內部工具、開發期測試）→ 自架 log endpoint 是成本最低的選擇。</p>
<p>使用者在外部網路（上架 app store、SaaS 產品、B2B 部署）→ 商業方案的跨網路收集能力是必要的，自架需要處理的 edge case（離線緩衝、重試、批次傳輸）太多。</p>
<h3 id="log-消費者是誰">Log 消費者是誰</h3>
<p>只有開發者自己 → grep/jq 足夠，不需要 dashboard。</p>
<p>包含非技術人員（客服、產品經理）→ 需要視覺化 dashboard 和搜尋介面，商業方案的 UI 是這個需求的標準答案。</p>
<h3 id="是否需要告警">是否需要告警</h3>
<p>開發者自己用、即時看 log → 不需要告警。</p>
<p>有維運值班、需要被動發現問題 → 需要告警規則，商業方案內建。</p>
<h2 id="混合方案">混合方案</h2>
<p>開發期用自架 log endpoint（零成本、即時可用），production 切換到商業方案 — 這個策略可行的前提是 log 層的 API 設計足夠抽象。</p>
<p><code>AppLogger</code> 提供統一的 log 介面（<code>log(level, name, data)</code>），底層實作在 debug mode 寫 console + POST 到本機 endpoint，在 release mode 寫 console + 呼叫 Sentry/Crashlytics SDK。切換只改 <code>AppLogger</code> 的底層實作，不改呼叫端。</p>
<p>這個抽象的投資在自用工具階段就值得做 — 即使目前不需要商業方案，統一的 log 介面也讓 log 點的管理更一致。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>三層 log 的詳細設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>在功能規格中定義 log 點 → <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a></li>
<li>Log 收集後的 schema 設計 → <a href="/blog/monitoring/02-log-schema/" data-link-title="模組二：Log Schema 設計" data-link-desc="跨平台統一事件格式、欄位設計、版本演進策略">monitoring 模組二 Log Schema</a></li>
</ul>
]]></content:encoded></item><item><title>模組三：協議整合測試</title><link>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/03-protocol-integration-test/</guid><description>&lt;p>回答「我的 client 跟真實服務的互動是否正確」。這一層的關鍵是不用 mock，直接連真實服務。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>TF-8&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a> + &lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2&lt;/a>&lt;/td>
 &lt;td>自用工具 server+client 同機 → protocol integration test 成本極低&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Protocol integration test 定義（跟 unit test / E2E 的邊界）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> WebSocket 協議測試實作（對真實 ttyd 驗證 frame type + auth handshake）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> HTTP contract test 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> CI 中的服務 fixture 管理（啟動/停止真實服務的 test harness）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 成本判斷表：什麼時候值得、什麼時候用 contract test 替代&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/03-sdk-design/" data-link-title="模組三：SDK 設計模式" data-link-desc="跨平台 SDK 的自動攔截、手動上報、攢批送出、離線 buffer 設計">monitoring 模組三 SDK 設計&lt;/a>：SDK 的 transport 行為也需要 protocol test&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">ux-design 模組三 輸入機制&lt;/a>：輸入設計（整行 vs 逐字元）影響 protocol test 的斷言&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「我的 client 跟真實服務的互動是否正確」。這一層的關鍵是不用 mock，直接連真實服務。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TF-8</td>
          <td><a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a> + <a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a></td>
          <td>自用工具 server+client 同機 → protocol integration test 成本極低</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Protocol integration test 定義（跟 unit test / E2E 的邊界）</li>
<li><input checked="" disabled="" type="checkbox"> WebSocket 協議測試實作（對真實 ttyd 驗證 frame type + auth handshake）</li>
<li><input checked="" disabled="" type="checkbox"> HTTP contract test 設計</li>
<li><input checked="" disabled="" type="checkbox"> CI 中的服務 fixture 管理（啟動/停止真實服務的 test harness）</li>
<li><input checked="" disabled="" type="checkbox"> 成本判斷表：什麼時候值得、什麼時候用 contract test 替代</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/03-sdk-design/" data-link-title="模組三：SDK 設計模式" data-link-desc="跨平台 SDK 的自動攔截、手動上報、攢批送出、離線 buffer 設計">monitoring 模組三 SDK 設計</a>：SDK 的 transport 行為也需要 protocol test</li>
<li>← <a href="/blog/ux-design/03-input-mechanism/" data-link-title="模組三：輸入機制設計" data-link-desc="Keyboard type / submit model / IME policy / special keys — 輸入機制是設計產物，影響 UI layout 和 protocol">ux-design 模組三 輸入機制</a>：輸入設計（整行 vs 逐字元）影響 protocol test 的斷言</li>
</ul>
]]></content:encoded></item><item><title>5.3 race condition 檢查</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/race-check/</guid><description>&lt;p>Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置，但不能取代 ownership、mutex、channel 與狀態邊界設計。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨 data race 與一般邏輯競爭&lt;/li>
&lt;li>用 &lt;code>go test -race ./...&lt;/code> 檢查並發路徑&lt;/li>
&lt;li>寫出能觸發共享狀態讀寫的測試&lt;/li>
&lt;li>依 race report 找到讀寫來源&lt;/li>
&lt;li>選擇 mutex、channel owner 或 atomic 修正同步邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察並發-bug-常常不會穩定重現">【觀察】並發 bug 常常不會穩定重現&lt;/h2>
&lt;p>Data race 的核心問題是測試可能偶爾通過、偶爾失敗，也可能完全不失敗但資料已經不安全。單次執行結果正確，不代表沒有未同步讀寫。&lt;/p>
&lt;p>反模式：&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">var&lt;/span> &lt;span class="nx">count&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="nf">increment&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">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">count&lt;/span>&lt;span class="o">++&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>count++&lt;/code> 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時，可能互相覆蓋結果，也可能被 race detector 偵測到未同步讀寫。&lt;/p>
&lt;h2 id="判讀data-race-是未同步的並發讀寫">【判讀】data race 是未同步的並發讀寫&lt;/h2>
&lt;p>Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體，其中至少一個是寫入，而且沒有同步保護。&lt;/p>
&lt;p>觸發測試：&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">TestIncrementRace&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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&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"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&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="k">go&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"> 7&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nf">increment&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;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"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&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;/code>&lt;/pre>&lt;/div>&lt;p>一般 &lt;code>go test&lt;/code> 不一定會失敗。&lt;code>go test -race&lt;/code> 會在 runtime 偵測這類未同步讀寫，並輸出讀取與寫入發生的位置。&lt;/p>
&lt;h2 id="執行用-go-test--race-跑到相關路徑">【執行】用 go test -race 跑到相關路徑&lt;/h2>
&lt;p>Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path，不會被它發現。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> -race ./...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢，但對含有 goroutine、共享 map、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> hub、background worker 的服務非常重要。&lt;/p>
&lt;p>若專案很大，可以先針對相關 package：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">go &lt;span class="nb">test&lt;/span> -race ./internal/websocket ./internal/storage ./internal/worker&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範圍縮小能讓日常執行更快，但合併前仍應跑完整路徑。&lt;/p>
&lt;h2 id="策略併發測試要讓共享狀態真的被同時讀寫">【策略】併發測試要讓共享狀態真的被同時讀寫&lt;/h2>
&lt;p>Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫，race detector 沒有機會回報。&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">TestRepositoryConcurrentAccess&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">repo&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewUserRepository&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">ctx&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&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="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">i&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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="k">go&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">10&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user_%d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&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">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Save&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">User&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">id&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="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">_&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">repo&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Find&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span>&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;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock，&lt;code>-race&lt;/code> 會指出問題。&lt;/p>
&lt;h2 id="執行websocket-hub-也需要-race-path">【執行】WebSocket hub 也需要 race path&lt;/h2>
&lt;p>WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。&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">TestHubConcurrentBroadcastAndUnregister&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">hub&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewHub&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">clients&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="nx">Client&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">100&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="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &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="nx">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTestClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sprintf&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;client_%d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="mi">8&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="nx">client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Subscribe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;alerts&amp;#34;&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="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">clients&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">client&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kd">struct&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">clients&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">clients&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">go&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">16&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="p">&amp;lt;&lt;/span> &lt;span class="mi">100&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="nx">i&lt;/span>&lt;span class="o">++&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Broadcast&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;alerts&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">ServerMessage&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;notification&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="k">go&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">23&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">client&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">clients&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">unregisterClient&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">client&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="p">}()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試是否需要 lock，取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行，測試就應該透過 channel 操作，而不是直接呼叫未同步方法。測試要符合 ownership 設計，不應製造不被 API 允許的並發。&lt;/p></description><content:encoded><![CDATA[<p>Race detector 的核心作用是找出測試執行期間發生的 data race。它能指出未同步讀寫同一份記憶體的位置，但不能取代 ownership、mutex、channel 與狀態邊界設計。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨 data race 與一般邏輯競爭</li>
<li>用 <code>go test -race ./...</code> 檢查並發路徑</li>
<li>寫出能觸發共享狀態讀寫的測試</li>
<li>依 race report 找到讀寫來源</li>
<li>選擇 mutex、channel owner 或 atomic 修正同步邊界</li>
</ol>
<hr>
<h2 id="觀察並發-bug-常常不會穩定重現">【觀察】並發 bug 常常不會穩定重現</h2>
<p>Data race 的核心問題是測試可能偶爾通過、偶爾失敗，也可能完全不失敗但資料已經不安全。單次執行結果正確，不代表沒有未同步讀寫。</p>
<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">var</span> <span class="nx">count</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kd">func</span> <span class="nf">increment</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">count</span><span class="o">++</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>count++</code> 不是原子操作。它包含讀取、加一、寫回。多個 goroutine 同時執行時，可能互相覆蓋結果，也可能被 race detector 偵測到未同步讀寫。</p>
<h2 id="判讀data-race-是未同步的並發讀寫">【判讀】data race 是未同步的並發讀寫</h2>
<p>Data race 的核心定義是至少兩個 goroutine 同時存取同一份記憶體，其中至少一個是寫入，而且沒有同步保護。</p>
<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">TestIncrementRace</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">go</span> <span class="kd">func</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">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nf">increment</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><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">wg</span><span class="p">.</span><span class="nf">Wait</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>一般 <code>go test</code> 不一定會失敗。<code>go test -race</code> 會在 runtime 偵測這類未同步讀寫，並輸出讀取與寫入發生的位置。</p>
<h2 id="執行用-go-test--race-跑到相關路徑">【執行】用 go test -race 跑到相關路徑</h2>
<p>Race detector 的核心限制是只能檢查實際執行到的程式路徑。沒有被測試覆蓋的 goroutine、handler、repository 或 broadcast path，不會被它發現。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>這個指令會用 race detector 跑所有 package 的測試。它會比一般測試慢，但對含有 goroutine、共享 map、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> hub、background worker 的服務非常重要。</p>
<p>若專案很大，可以先針對相關 package：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./internal/websocket ./internal/storage ./internal/worker</span></span></code></pre></div><p>範圍縮小能讓日常執行更快，但合併前仍應跑完整路徑。</p>
<h2 id="策略併發測試要讓共享狀態真的被同時讀寫">【策略】併發測試要讓共享狀態真的被同時讀寫</h2>
<p>Race detector 的核心前提是測試要製造相關路徑。只建立 repository 卻不並發讀寫，race detector 沒有機會回報。</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">TestRepositoryConcurrentAccess</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">repo</span> <span class="o">:=</span> <span class="nf">NewUserRepository</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">ctx</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">Background</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">i</span> <span class="o">:=</span> <span class="nx">i</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">id</span> <span class="o">:=</span> <span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;user_%d&#34;</span><span class="p">,</span> <span class="nx">i</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">repo</span><span class="p">.</span><span class="nf">Save</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">User</span><span class="p">{</span><span class="nx">ID</span><span class="p">:</span> <span class="nx">id</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">_</span><span class="p">,</span> <span class="nx">_</span> <span class="p">=</span> <span class="nx">repo</span><span class="p">.</span><span class="nf">Find</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">id</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 class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試的主要斷言在「讓 race detector 執行共享 map 的讀寫路徑」。若 repository 忘記加 lock，<code>-race</code> 會指出問題。</p>
<h2 id="執行websocket-hub-也需要-race-path">【執行】WebSocket hub 也需要 race path</h2>
<p>WebSocket hub 的核心並發風險是 client 註冊、取消註冊、訂閱變更與 broadcast 可能同時發生。測試應讓這些路徑交錯執行。</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">TestHubConcurrentBroadcastAndUnregister</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">clients</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">([]</span><span class="o">*</span><span class="nx">Client</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">100</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="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">client</span> <span class="o">:=</span> <span class="nf">NewTestClient</span><span class="p">(</span><span class="nx">fmt</span><span class="p">.</span><span class="nf">Sprintf</span><span class="p">(</span><span class="s">&#34;client_%d&#34;</span><span class="p">,</span> <span class="nx">i</span><span class="p">),</span> <span class="mi">8</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">client</span><span class="p">.</span><span class="nf">Subscribe</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="nx">hub</span><span class="p">.</span><span class="nx">clients</span><span class="p">[</span><span class="nx">client</span><span class="p">]</span> <span class="p">=</span> <span class="kd">struct</span><span class="p">{}{}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="nx">clients</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">clients</span><span class="p">,</span> <span class="nx">client</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="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">2</span><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="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="p">&lt;</span> <span class="mi">100</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">18</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 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">19</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">        <span class="k">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">client</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">clients</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">hub</span><span class="p">.</span><span class="nf">unregisterClient</span><span class="p">(</span><span class="nx">client</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nx">wg</span><span class="p">.</span><span class="nf">Wait</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個測試是否需要 lock，取決於 hub 的設計。如果 hub 保證所有操作都在單一 event loop 中執行，測試就應該透過 channel 操作，而不是直接呼叫未同步方法。測試要符合 ownership 設計，不應製造不被 API 允許的並發。</p>
<h2 id="判讀race-report-要看讀寫兩端">【判讀】race report 要看讀寫兩端</h2>
<p>Race report 的核心資訊是兩個位置：一端讀或寫，另一端寫。修正時不要只看最後一行，要找出是哪個共享資料缺少同步。</p>
<p>典型報告會包含：</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">WARNING: DATA RACE
</span></span><span class="line"><span class="ln">2</span><span class="cl">Read at 0x...
</span></span><span class="line"><span class="ln">3</span><span class="cl">  example.com/app.(*UserRepository).Find()
</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">Previous write at 0x...
</span></span><span class="line"><span class="ln">6</span><span class="cl">  example.com/app.(*UserRepository).Save()</span></span></code></pre></div><p>這表示 <code>Find</code> 和 <code>Save</code> 同時碰到同一份資料，且缺少同步。修正方向是在 repository owner 補上 mutex、channel ownership 或其他同步邊界。</p>
<h2 id="策略修正方式要對應狀態形狀">【策略】修正方式要對應狀態形狀</h2>
<p>修正 data race 的核心選擇是建立正確同步邊界。常見方法有 mutex、channel owner、atomic。</p>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>適用情境</th>
          <th>注意事項</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>mutex</td>
          <td>多方法讀寫同一份 map/slice/state</td>
          <td>lock 要保護完整不變式</td>
      </tr>
      <tr>
          <td>channel owner</td>
          <td>狀態修改可集中成事件 loop</td>
          <td>要設計 reply、shutdown、<a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a></td>
      </tr>
      <tr>
          <td>atomic</td>
          <td>單一數值 counter 或 flag</td>
          <td>不適合複雜狀態</td>
      </tr>
  </tbody>
</table>
<p>Mutex 範例：</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">type</span> <span class="nx">Counter</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">mu</span>    <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">value</span> <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><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="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Counter</span><span class="p">)</span> <span class="nf">Inc</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">value</span><span class="o">++</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="kd">func</span> <span class="p">(</span><span class="nx">c</span> <span class="o">*</span><span class="nx">Counter</span><span class="p">)</span> <span class="nf">Value</span><span class="p">()</span> <span class="kt">int</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="k">defer</span> <span class="nx">c</span><span class="p">.</span><span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="k">return</span> <span class="nx">c</span><span class="p">.</span><span class="nx">value</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>鎖應該屬於擁有狀態的型別，並保護一個清楚的不變條件。只為了讓 race detector 安靜而到處加鎖，會讓 ownership 分散，後續仍然難以判斷資料一致性。</p>
<h2 id="判讀race-free-不代表行為正確">【判讀】race-free 不代表行為正確</h2>
<p>Race detector 的核心邊界是它只找 data race，不保證並發邏輯正確。沒有 data race 的程式仍可能 deadlock、漏訊息、順序錯誤、重複 close 或違反資料語意。</p>
<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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">case</span> <span class="nx">client</span><span class="p">.</span><span class="nx">send</span> <span class="o">&lt;-</span> <span class="nx">message</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">default</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="c1">// drop</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這段程式可能沒有 data race，但「<a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full 時丟訊息」是否正確是服務語意問題。Race detector 不會告訴你該丟、該斷線、還是該寫可靠 queue。</p>
<p>因此並發測試要分成兩層：</p>
<ul>
<li>用 <code>go test -race</code> 找未同步記憶體存取。</li>
<li>用行為測試檢查 channel close、queue full、context cancel、cleanup、<a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>。</li>
</ul>
<h2 id="測試把-race-check-納入固定流程">【測試】把 race check 納入固定流程</h2>
<p>Race check 的核心價值來自重複執行。只在出事後手動跑，效果有限。</p>
<p>建議流程：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> ./...
</span></span><span class="line"><span class="ln">2</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p>日常開發可以先跑相關 package，提交前或 CI 跑完整 race suite。若 race suite 太慢，至少讓含有 hub、repository、worker、client state 的 package 固定跑 <code>-race</code>。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理共享 state、channel ownership 與 goroutine lifecycle 的 race 風險；lock-free 與完整 memory model，會在下列章節再往外延伸：</p>
<ul>
<li><a href="/blog/go-advanced/01-concurrency-patterns/shared-state/" data-link-title="1.4 共享狀態與複製邊界" data-link-desc="用 lock 與 copy 保護長期服務的狀態資料">Go 進階：共享狀態與複製邊界</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/channel-ownership/" data-link-title="1.1 channel ownership 與關閉責任" data-link-desc="判斷誰能送出、接收與關閉 channel">Go 進階：channel ownership 與關閉責任</a></li>
<li><a href="/blog/go-advanced/01-concurrency-patterns/select-loop/" data-link-title="1.2 select loop 的生命週期設計" data-link-desc="理解長時間運行 goroutine 如何同時處理事件、ticker 與取消">Go 進階：select loop 的生命週期設計</a></li>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是共享狀態、channel ownership 與 lifecycle；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/04-concurrency/goroutine/" data-link-title="4.1 goroutine：輕量並發工作" data-link-desc="用 goroutine 啟動並發工作，並設計清楚的退出條件">Go：goroutine：輕量並發工作</a></li>
<li><a href="/blog/go/04-concurrency/channel/" data-link-title="4.2 channel：資料傳遞與 backpressure " data-link-desc="理解 channel 如何在 goroutine 之間傳遞資料並形成 backpressure ">Go：channel：資料傳遞與 backpressure </a></li>
<li><a href="/blog/go/07-refactoring/state-boundary/" data-link-title="7.4 狀態管理的安全邊界" data-link-desc="用 lock、copy 與 API 限制保護共享狀態">Go：狀態管理的安全邊界</a></li>
<li><a href="/blog/go/06-practical/new-background-worker/" data-link-title="6.4 如何新增背景工作流程" data-link-desc="接入 context、channel 與 shutdown">Go：如何新增背景工作流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p><code>go test -race</code> 是 Go 並發服務的基本安全網，但它只檢查測試執行到的 data race。你仍然需要設計清楚的 state owner、lock boundary、channel ownership 與行為測試。Race-free 不是正確性的全部；它只是可靠性的第一層檢查。</p>
]]></content:encoded></item><item><title>5.3 table-driven test</title><link>https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/</guid><description>&lt;p>table-driven test 的核心規則是：同一個行為的多組案例放進表格，測試流程只寫一次。本章將說明如何設計案例欄位、命名子測試，並避免把太多不同行為塞進同一張表。&lt;/p>
&lt;h2 id="table-driven-test-解決重複案例">table-driven test 解決重複案例&lt;/h2>
&lt;p>table-driven test 的核心目標是把「案例資料」和「測試流程」分開。當同一個函式需要測多組輸入與預期結果時，表格能讓案例集中呈現，測試流程只保留一次。&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">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">string&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">input&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="k">return&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ToLower&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式有多個值得驗證的案例：一般字串、前後空白、已經是小寫、空字串。若每個案例都寫一個完整測試，程式會很快重複。&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">TestNormalizeName&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">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;lowercase&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; Alice &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&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">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeName&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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;NormalizeName(%q) = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&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;/p>
&lt;h2 id="案例欄位要對應行為">案例欄位要對應行為&lt;/h2>
&lt;p>測試表格欄位的核心原則是只放描述案例所需的資料。常見欄位包括 &lt;code>name&lt;/code>、輸入值、預期輸出與是否預期錯誤。&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="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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">name&lt;/span> &lt;span class="kt">string&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">input&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">int&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;valid&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;8080&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">8080&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;not number&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;abc&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;zero&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&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;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>wantErr&lt;/code> 表示這個案例預期出錯。它比把錯誤訊息塞進 &lt;code>want&lt;/code> 更清楚，因為成功結果與錯誤結果是兩種不同觀察。&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&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">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ParsePort&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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="k">if&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&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="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"> 6&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;ParsePort(%q) error = nil, want error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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="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">return&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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">12&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;ParsePort(%q) error = %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&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">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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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;ParsePort(%q) = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&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>return&lt;/code>，成功案例再繼續檢查輸出。這讓測試流程和函式行為一樣清楚：失敗時不應再比較正常結果。&lt;/p>
&lt;h2 id="trun-讓案例有名字">&lt;code>t.Run&lt;/code> 讓案例有名字&lt;/h2>
&lt;p>&lt;code>t.Run&lt;/code> 的核心作用是建立子測試，讓每個案例在測試輸出中有獨立名稱。當某個案例失敗時，工程師可以直接看到是哪一列資料出問題。&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&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="c1">// case assertion&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&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>&amp;quot;empty input&amp;quot;&lt;/code>、&lt;code>&amp;quot;negative port&amp;quot;&lt;/code>、&lt;code>&amp;quot;trim spaces&amp;quot;&lt;/code> 比 &lt;code>&amp;quot;case 1&amp;quot;&lt;/code> 更有定位價值。&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="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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">name&lt;/span> &lt;span class="kt">string&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">input&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; Alice &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alice&amp;#34;&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;preserve hyphen&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;Mary-Jane&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;mary-jane&amp;#34;&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>當測試失敗時，名稱會出現在 &lt;code>go test&lt;/code> 輸出中。好的案例名稱能讓讀者先理解失敗情境，再去看 got/want 差異。&lt;/p>
&lt;h2 id="表格應集中在單一行為">表格應集中在單一行為&lt;/h2>
&lt;p>table-driven test 的邊界是「同一個測試流程是否能自然描述所有案例」。如果某些案例需要完全不同的準備、執行或驗證方式，通常應該拆成不同測試。&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">TestParsePort&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="c1">// 測 ParsePort 的輸入輸出規則&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&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="kd">func&lt;/span> &lt;span class="nf">TestLoadConfig&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">6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 測 LoadConfig 的檔案讀取與解析流程&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&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;/p>
&lt;p>好的表格應該短而集中。若你需要在測試迴圈裡寫很多 &lt;code>if tt.someMode&lt;/code>，這通常是拆分測試的訊號。&lt;/p>
&lt;h2 id="比較複雜資料時使用合適工具">比較複雜資料時使用合適工具&lt;/h2>
&lt;p>比較結果的核心原則是選擇能清楚表達差異的方式。基本型別可以直接用 &lt;code>!=&lt;/code>，slice、map、struct 則常用 &lt;code>reflect.DeepEqual&lt;/code> 或專門的比較工具。&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">SplitCSV&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&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="k">if&lt;/span> &lt;span class="nx">input&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&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="k">return&lt;/span> &lt;span class="kc">nil&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &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="nx">parts&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">Split&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;,&amp;#34;&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">for&lt;/span> &lt;span class="nx">i&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">parts&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="nx">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nx">strings&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">TrimSpace&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">i&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">parts&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;/code>&lt;/pre>&lt;/div>&lt;p>測試 slice 時，不能直接用 &lt;code>got != want&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<p>table-driven test 的核心規則是：同一個行為的多組案例放進表格，測試流程只寫一次。本章將說明如何設計案例欄位、命名子測試，並避免把太多不同行為塞進同一張表。</p>
<h2 id="table-driven-test-解決重複案例">table-driven test 解決重複案例</h2>
<p>table-driven test 的核心目標是把「案例資料」和「測試流程」分開。當同一個函式需要測多組輸入與預期結果時，表格能讓案例集中呈現，測試流程只保留一次。</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">NormalizeName</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">input</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">ToLower</span><span class="p">(</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</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">TestNormalizeName</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">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="kt">string</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 class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;lowercase&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;  Alice  &#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;&#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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">14</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NormalizeName</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</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;NormalizeName(%q) = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <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="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>表格中的每一列是一個案例，迴圈中的程式是共同驗證流程。新增案例時，只要新增一列，不必複製整個測試函式。</p>
<h2 id="案例欄位要對應行為">案例欄位要對應行為</h2>
<p>測試表格欄位的核心原則是只放描述案例所需的資料。常見欄位包括 <code>name</code>、輸入值、預期輸出與是否預期錯誤。</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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">input</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">want</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">wantErr</span> <span class="kt">bool</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 class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;valid&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;8080&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="mi">8080</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;not number&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;abc&#34;</span><span class="p">,</span> <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;zero&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;0&#34;</span><span class="p">,</span> <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><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><code>wantErr</code> 表示這個案例預期出錯。它比把錯誤訊息塞進 <code>want</code> 更清楚，因為成功結果與錯誤結果是兩種不同觀察。</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="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</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">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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"> 3</span><span class="cl">        <span class="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ParsePort</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">if</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</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"> 6</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;ParsePort(%q) error = nil, want error&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</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="k">return</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="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;ParsePort(%q) error = %v&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</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;ParsePort(%q) = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <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="p">}</span></span></span></code></pre></div><p>錯誤案例先處理並 <code>return</code>，成功案例再繼續檢查輸出。這讓測試流程和函式行為一樣清楚：失敗時不應再比較正常結果。</p>
<h2 id="trun-讓案例有名字"><code>t.Run</code> 讓案例有名字</h2>
<p><code>t.Run</code> 的核心作用是建立子測試，讓每個案例在測試輸出中有獨立名稱。當某個案例失敗時，工程師可以直接看到是哪一列資料出問題。</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="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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="c1">// case assertion</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>案例名稱應該描述情境，而不是描述編號。<code>&quot;empty input&quot;</code>、<code>&quot;negative port&quot;</code>、<code>&quot;trim spaces&quot;</code> 比 <code>&quot;case 1&quot;</code> 更有定位價值。</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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">want</span>  <span class="kt">string</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="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;  Alice  &#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alice&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;preserve hyphen&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;Mary-Jane&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;mary-jane&#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></code></pre></div><p>當測試失敗時，名稱會出現在 <code>go test</code> 輸出中。好的案例名稱能讓讀者先理解失敗情境，再去看 got/want 差異。</p>
<h2 id="表格應集中在單一行為">表格應集中在單一行為</h2>
<p>table-driven test 的邊界是「同一個測試流程是否能自然描述所有案例」。如果某些案例需要完全不同的準備、執行或驗證方式，通常應該拆成不同測試。</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">TestParsePort</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="c1">// 測 ParsePort 的輸入輸出規則</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><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="kd">func</span> <span class="nf">TestLoadConfig</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">6</span><span class="cl">    <span class="c1">// 測 LoadConfig 的檔案讀取與解析流程</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>把不同行為硬塞進同一張表，會讓欄位越來越多，最後出現大量只在少數案例使用的欄位。這種表格看起來少了重複，實際上讓讀者更難理解每個案例。</p>
<p>好的表格應該短而集中。若你需要在測試迴圈裡寫很多 <code>if tt.someMode</code>，這通常是拆分測試的訊號。</p>
<h2 id="比較複雜資料時使用合適工具">比較複雜資料時使用合適工具</h2>
<p>比較結果的核心原則是選擇能清楚表達差異的方式。基本型別可以直接用 <code>!=</code>，slice、map、struct 則常用 <code>reflect.DeepEqual</code> 或專門的比較工具。</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">SplitCSV</span><span class="p">(</span><span class="nx">input</span> <span class="kt">string</span><span class="p">)</span> <span class="p">[]</span><span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">if</span> <span class="nx">input</span> <span class="o">==</span> <span class="s">&#34;&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="k">return</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <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="nx">parts</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">Split</span><span class="p">(</span><span class="nx">input</span><span class="p">,</span> <span class="s">&#34;,&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">for</span> <span class="nx">i</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">parts</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">parts</span><span class="p">[</span><span class="nx">i</span><span class="p">]</span> <span class="p">=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">TrimSpace</span><span class="p">(</span><span class="nx">parts</span><span class="p">[</span><span class="nx">i</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">return</span> <span class="nx">parts</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>測試 slice 時，不能直接用 <code>got != want</code>。</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">TestSplitCSV</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">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="p">[]</span><span class="kt">string</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 class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</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="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;two values&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;a, b&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</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">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">13</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">SplitCSV</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="k">if</span> <span class="p">!</span><span class="nx">reflect</span><span class="p">.</span><span class="nf">DeepEqual</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</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;SplitCSV(%q) = %#v, want %#v&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</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 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="p">}</span></span></span></code></pre></div><p><code>reflect.DeepEqual</code> 適合入門與標準庫範例。大型專案可能使用第三方比較工具產生更好的 diff，但核心原則不變：失敗訊息要讓差異容易看懂。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會把測試方法套到 HTTP handler，說明如何不用啟動真實 server 也能驗證請求與回應。</p>
]]></content:encoded></item><item><title>5.3 unittest 基礎</title><link>https://tarrragon.github.io/blog/python/05-error-testing/unittest/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/05-error-testing/unittest/</guid><description>&lt;p>&lt;code>unittest&lt;/code> 是 Python 內建的測試框架，提供了測試組織、斷言和測試執行等功能。Hook 系統的測試都使用 &lt;code>unittest&lt;/code> 撰寫。&lt;/p>
&lt;h2 id="基本結構">基本結構&lt;/h2>
&lt;h3 id="最簡單的測試">最簡單的測試&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestCalculator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="k">def&lt;/span> &lt;span class="nf">test_add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_subtract&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">5&lt;/span> &lt;span class="o">-&lt;/span> &lt;span class="mi">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="vm">__name__&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;__main__&amp;#34;&lt;/span>&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="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">main&lt;/span>&lt;span class="p">()&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>執行測試：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">$ python -m unittest test_calculator.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">..
&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">Ran &lt;span class="m">2&lt;/span> tests in 0.001s
&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">OK&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="測試類別結構">測試類別結構&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestMyModule&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="nd">@classmethod&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">setUpClass&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&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="s2">&amp;#34;&amp;#34;&amp;#34;在所有測試前執行一次&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shared_resource&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_expensive_resource&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>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nd">@classmethod&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">tearDownClass&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&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="s2">&amp;#34;&amp;#34;&amp;#34;在所有測試後執行一次&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shared_resource&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cleanup&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">setUp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;在每個測試前執行&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;value&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&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"> &lt;span class="k">def&lt;/span> &lt;span class="nf">tearDown&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;在每個測試後執行&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_something&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試方法必須以 test_ 開頭&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertTrue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="常用斷言方法">常用斷言方法&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>檢查&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>assertEqual(a, b)&lt;/code>&lt;/td>
 &lt;td>a == b&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertNotEqual(a, b)&lt;/code>&lt;/td>
 &lt;td>a != b&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertTrue(x)&lt;/code>&lt;/td>
 &lt;td>x is True&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertFalse(x)&lt;/code>&lt;/td>
 &lt;td>x is False&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertIs(a, b)&lt;/code>&lt;/td>
 &lt;td>a is b&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertIsNone(x)&lt;/code>&lt;/td>
 &lt;td>x is None&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertIn(a, b)&lt;/code>&lt;/td>
 &lt;td>a in b&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertIsInstance(a, b)&lt;/code>&lt;/td>
 &lt;td>isinstance(a, b)&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>assertRaises(Error)&lt;/code>&lt;/td>
 &lt;td>拋出指定異常&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="實際範例測試-hook-io">實際範例：測試 Hook IO&lt;/h2>
&lt;p>來自 &lt;code>.claude/lib/tests/test_hook_io.py&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">sys&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">io&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StringIO&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">patch&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="c1"># 導入被測試的模組&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">sys&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">insert&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Path&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__file__&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parent&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parent&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="kn">from&lt;/span> &lt;span class="nn">hook_io&lt;/span> &lt;span class="kn">import&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="n">read_hook_input&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">write_hook_output&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="n">create_pretooluse_output&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="n">create_posttooluse_output&lt;/span>&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;span class="line">&lt;span class="ln"> 15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 17&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestReadHookInput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 18&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試 read_hook_input 函式&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 20&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_valid_json_input&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 21&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試有效的 JSON 輸入&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 22&lt;/span>&lt;span class="cl"> &lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;tool_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Write&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;file_path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/test.txt&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 23&lt;/span>&lt;span class="cl"> &lt;span class="n">json_input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">test_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 24&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 25&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">StringIO&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json_input&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 26&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_hook_input&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 27&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 28&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">test_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 29&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 30&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_invalid_json_returns_empty_dict&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 31&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試無效的 JSON 應返回空字典&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 32&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">StringIO&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;not valid json&amp;#34;&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 33&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_hook_input&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 34&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 35&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&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"> 36&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 37&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_empty_input_returns_empty_dict&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 38&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試空輸入應返回空字典&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 39&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">StringIO&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 40&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_hook_input&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 41&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 42&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&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"> 43&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 44&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 45&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestWriteHookOutput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 46&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試 write_hook_output 函式&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 47&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 48&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_output_json_format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 49&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試輸出為有效的 JSON 格式&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 50&lt;/span>&lt;span class="cl"> &lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decision&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;allow&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;reason&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;OK&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 51&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 52&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">new_callable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">StringIO&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 53&lt;/span>&lt;span class="cl"> &lt;span class="n">write_hook_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">test_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 54&lt;/span>&lt;span class="cl"> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getvalue&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 55&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 56&lt;/span>&lt;span class="cl"> &lt;span class="n">parsed&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 57&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parsed&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;decision&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;allow&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 58&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 59&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_chinese_characters_preserved&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 60&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試中文字元被保留&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 61&lt;/span>&lt;span class="cl"> &lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;你好&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 62&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 63&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">new_callable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">StringIO&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 64&lt;/span>&lt;span class="cl"> &lt;span class="n">write_hook_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">test_data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ensure_ascii&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 65&lt;/span>&lt;span class="cl"> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getvalue&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 66&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 67&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertIn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;你好&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 68&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 69&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 70&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestCreatePretoolueOutput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 71&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試 create_pretooluse_output 函式&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 72&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 73&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_basic_output_structure&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 74&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試基本輸出結構&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 75&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_pretooluse_output&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 76&lt;/span>&lt;span class="cl"> &lt;span class="n">decision&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;allow&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 77&lt;/span>&lt;span class="cl"> &lt;span class="n">reason&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Test reason&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 78&lt;/span>&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 79&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 80&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertIn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;hookSpecificOutput&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 81&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 82&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;hookSpecificOutput&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;permissionDecision&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 83&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 84&lt;/span>&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 85&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 86&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_with_user_prompt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 87&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試包含 userPrompt 的輸出&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 88&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">create_pretooluse_output&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 89&lt;/span>&lt;span class="cl"> &lt;span class="n">decision&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ask&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 90&lt;/span>&lt;span class="cl"> &lt;span class="n">reason&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Need confirmation&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 91&lt;/span>&lt;span class="cl"> &lt;span class="n">user_prompt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Continue?&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 92&lt;/span>&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 93&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 94&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 95&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;hookSpecificOutput&amp;#34;&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="s2">&amp;#34;userPrompt&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 96&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;Continue?&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 97&lt;/span>&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 98&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 99&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">100&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="vm">__name__&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;__main__&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">101&lt;/span>&lt;span class="cl"> &lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">main&lt;/span>&lt;span class="p">()&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="測試異常">測試異常&lt;/h2>
&lt;h3 id="assertraises">assertRaises&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_raises_value_error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="s2">&amp;#34;&amp;#34;&amp;#34;測試函式是否拋出 ValueError&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertRaises&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="ne">ValueError&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="nb">int&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;not a number&amp;#34;&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="k">def&lt;/span> &lt;span class="nf">test_raises_with_message&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="s2">&amp;#34;&amp;#34;&amp;#34;測試異常訊息&amp;#34;&amp;#34;&amp;#34;&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">with&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertRaises&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="ne">ValueError&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">context&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="k">raise&lt;/span> &lt;span class="ne">ValueError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;invalid input&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertIn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;invalid&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">exception&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="測試檔案操作">測試檔案操作&lt;/h2>
&lt;h3 id="使用臨時檔案">使用臨時檔案&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">tempfile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&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">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestFileOperations&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="k">def&lt;/span> &lt;span class="nf">test_read_file&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="s2">&amp;#34;&amp;#34;&amp;#34;測試檔案讀取&amp;#34;&amp;#34;&amp;#34;&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">with&lt;/span> &lt;span class="n">tempfile&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">NamedTemporaryFile&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">mode&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;w&amp;#34;&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="n">suffix&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;.txt&amp;#34;&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">delete&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&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 class="k">as&lt;/span> &lt;span class="n">f&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="n">f&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">write&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;test content&amp;#34;&lt;/span>&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="n">temp_path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">f&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_file&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">temp_path&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;test content&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="k">finally&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">unlink&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">temp_path&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="使用臨時目錄">使用臨時目錄&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">tempfile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&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">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestDirectoryOperations&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="k">def&lt;/span> &lt;span class="nf">setUp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">temp_dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">tempfile&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mkdtemp&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">tearDown&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="kn">import&lt;/span> &lt;span class="nn">shutil&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">shutil&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">rmtree&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">temp_dir&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>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_create_file&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&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="n">file_path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">temp_dir&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;test.txt&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="n">create_file&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">file_path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertTrue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">os&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">exists&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">file_path&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="執行測試">執行測試&lt;/h2>
&lt;h3 id="執行單一測試檔案">執行單一測試檔案&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">python -m unittest tests/test_hook_io.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="執行單一測試類別">執行單一測試類別&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">python -m unittest tests.test_hook_io.TestReadHookInput&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="執行單一測試方法">執行單一測試方法&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">python -m unittest tests.test_hook_io.TestReadHookInput.test_valid_json_input&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="執行所有測試">執行所有測試&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">python -m unittest discover -s tests -p &lt;span class="s2">&amp;#34;test_*.py&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="詳細輸出">詳細輸出&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">python -m unittest -v tests/test_hook_io.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="測試組織">測試組織&lt;/h2>
&lt;h3 id="目錄結構">目錄結構&lt;/h3>





&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">.claude/lib/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">├── __init__.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">├── git_utils.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">├── hook_io.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">├── hook_logging.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">└── tests/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> ├── __init__.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> ├── test_git_utils.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> ├── test_hook_io.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> └── test_hook_logging.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="命名慣例">命名慣例&lt;/h3>
&lt;ul>
&lt;li>測試檔案：&lt;code>test_&amp;lt;module&amp;gt;.py&lt;/code>&lt;/li>
&lt;li>測試類別：&lt;code>Test&amp;lt;ClassName&amp;gt;&lt;/code>&lt;/li>
&lt;li>測試方法：&lt;code>test_&amp;lt;behavior&amp;gt;&lt;/code>&lt;/li>
&lt;/ul>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># test_git_utils.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestRunGitCommand&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="k">def&lt;/span> &lt;span class="nf">test_successful_command_returns_true&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="o">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_failed_command_returns_false&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&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="o">...&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">def&lt;/span> &lt;span class="nf">test_timeout_returns_error_message&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="o">...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="最佳實踐">最佳實踐&lt;/h2>
&lt;h3 id="1-一個測試驗證一件事">1. 一個測試驗證一件事&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 好：每個測試只驗證一個行為&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_valid_input_returns_true&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;valid&amp;#34;&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertTrue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&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="k">def&lt;/span> &lt;span class="nf">test_invalid_input_returns_false&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;invalid&amp;#34;&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertFalse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&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>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertTrue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;valid&amp;#34;&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertFalse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;invalid&amp;#34;&lt;/span>&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-使用描述性的測試名稱">2. 使用描述性的測試名稱&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1"># 好：清楚說明測試內容&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_empty_input_returns_empty_dict&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="o">...&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="c1"># 不好：模糊的名稱&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_input&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="o">...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-使用-setup-避免重複">3. 使用 setUp 避免重複&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestMarkdownChecker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">setUp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">checker&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">MarkdownLinkChecker&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">test_content&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;# Test&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">[link](/python/05-error-testing/unittest/file.md)&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="k">def&lt;/span> &lt;span class="nf">test_check_valid_link&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="c1"># 使用 setUp 中建立的物件&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">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">checker&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">check&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">test_content&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="o">...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="思考題">思考題&lt;/h2>
&lt;ol>
&lt;li>&lt;code>setUp&lt;/code> 和 &lt;code>setUpClass&lt;/code> 有什麼區別？什麼時候用哪個？&lt;/li>
&lt;li>為什麼測試方法必須以 &lt;code>test_&lt;/code> 開頭？&lt;/li>
&lt;li>如何測試一個需要讀取 stdin 的函式？&lt;/li>
&lt;/ol>
&lt;h2 id="實作練習">實作練習&lt;/h2>
&lt;ol>
&lt;li>為 &lt;code>get_current_branch()&lt;/code> 函式撰寫測試&lt;/li>
&lt;li>測試一個會拋出異常的函式&lt;/li>
&lt;li>使用 &lt;code>unittest.skip&lt;/code> 暫時跳過某個測試&lt;/li>
&lt;/ol>
&lt;hr>
&lt;p>&lt;em>上一章：&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/exception/" data-link-title="5.1 異常處理策略" data-link-desc="何時捕獲、何時拋出">異常處理策略&lt;/a>&lt;/em>
&lt;em>下一章：&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/mock/" data-link-title="5.4 Mock 與測試隔離" data-link-desc="隔離外部依賴">Mock 與測試隔離&lt;/a>&lt;/em>&lt;/p></description><content:encoded><![CDATA[<p><code>unittest</code> 是 Python 內建的測試框架，提供了測試組織、斷言和測試執行等功能。Hook 系統的測試都使用 <code>unittest</code> 撰寫。</p>
<h2 id="基本結構">基本結構</h2>
<h3 id="最簡單的測試">最簡單的測試</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">class</span> <span class="nc">TestCalculator</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</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="k">def</span> <span class="nf">test_add</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="mi">1</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="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="mi">2</span><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="k">def</span> <span class="nf">test_subtract</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="mi">5</span> <span class="o">-</span> <span class="mi">3</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="mi">2</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="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="n">unittest</span><span class="o">.</span><span class="n">main</span><span class="p">()</span></span></span></code></pre></div><p>執行測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">$ python -m unittest test_calculator.py
</span></span><span class="line"><span class="ln">2</span><span class="cl">..
</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">Ran <span class="m">2</span> tests in 0.001s
</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">OK</span></span></code></pre></div><h2 id="測試類別結構">測試類別結構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="k">class</span> <span class="nc">TestMyModule</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</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="nd">@classmethod</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">def</span> <span class="nf">setUpClass</span><span class="p">(</span><span class="bp">cls</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s2">&#34;&#34;&#34;在所有測試前執行一次&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="bp">cls</span><span class="o">.</span><span class="n">shared_resource</span> <span class="o">=</span> <span class="n">create_expensive_resource</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="nd">@classmethod</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">def</span> <span class="nf">tearDownClass</span><span class="p">(</span><span class="bp">cls</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="s2">&#34;&#34;&#34;在所有測試後執行一次&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="bp">cls</span><span class="o">.</span><span class="n">shared_resource</span><span class="o">.</span><span class="n">cleanup</span><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="k">def</span> <span class="nf">setUp</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="s2">&#34;&#34;&#34;在每個測試前執行&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;value&#34;</span><span class="p">}</span>
</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">    <span class="k">def</span> <span class="nf">tearDown</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <span class="s2">&#34;&#34;&#34;在每個測試後執行&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">test_data</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">def</span> <span class="nf">test_something</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試方法必須以 test_ 開頭&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertTrue</span><span class="p">(</span><span class="kc">True</span><span class="p">)</span></span></span></code></pre></div><h2 id="常用斷言方法">常用斷言方法</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>檢查</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>assertEqual(a, b)</code></td>
          <td>a == b</td>
      </tr>
      <tr>
          <td><code>assertNotEqual(a, b)</code></td>
          <td>a != b</td>
      </tr>
      <tr>
          <td><code>assertTrue(x)</code></td>
          <td>x is True</td>
      </tr>
      <tr>
          <td><code>assertFalse(x)</code></td>
          <td>x is False</td>
      </tr>
      <tr>
          <td><code>assertIs(a, b)</code></td>
          <td>a is b</td>
      </tr>
      <tr>
          <td><code>assertIsNone(x)</code></td>
          <td>x is None</td>
      </tr>
      <tr>
          <td><code>assertIn(a, b)</code></td>
          <td>a in b</td>
      </tr>
      <tr>
          <td><code>assertIsInstance(a, b)</code></td>
          <td>isinstance(a, b)</td>
      </tr>
      <tr>
          <td><code>assertRaises(Error)</code></td>
          <td>拋出指定異常</td>
      </tr>
  </tbody>
</table>
<h2 id="實際範例測試-hook-io">實際範例：測試 Hook IO</h2>
<p>來自 <code>.claude/lib/tests/test_hook_io.py</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">  1</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln">  2</span><span class="cl"><span class="kn">import</span> <span class="nn">sys</span>
</span></span><span class="line"><span class="ln">  3</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</span>
</span></span><span class="line"><span class="ln">  4</span><span class="cl"><span class="kn">from</span> <span class="nn">io</span> <span class="kn">import</span> <span class="n">StringIO</span>
</span></span><span class="line"><span class="ln">  5</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</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="c1"># 導入被測試的模組</span>
</span></span><span class="line"><span class="ln">  8</span><span class="cl"><span class="n">sys</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">insert</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="n">Path</span><span class="p">(</span><span class="vm">__file__</span><span class="p">)</span><span class="o">.</span><span class="n">parent</span><span class="o">.</span><span class="n">parent</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">  9</span><span class="cl"><span class="kn">from</span> <span class="nn">hook_io</span> <span class="kn">import</span> <span class="p">(</span>
</span></span><span class="line"><span class="ln"> 10</span><span class="cl">    <span class="n">read_hook_input</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 11</span><span class="cl">    <span class="n">write_hook_output</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 12</span><span class="cl">    <span class="n">create_pretooluse_output</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 13</span><span class="cl">    <span class="n">create_posttooluse_output</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></span><span class="line"><span class="ln"> 17</span><span class="cl"><span class="k">class</span> <span class="nc">TestReadHookInput</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 18</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試 read_hook_input 函式&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 19</span><span class="cl">
</span></span><span class="line"><span class="ln"> 20</span><span class="cl">    <span class="k">def</span> <span class="nf">test_valid_json_input</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 21</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試有效的 JSON 輸入&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 22</span><span class="cl">        <span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;tool_name&#34;</span><span class="p">:</span> <span class="s2">&#34;Write&#34;</span><span class="p">,</span> <span class="s2">&#34;file_path&#34;</span><span class="p">:</span> <span class="s2">&#34;/test.txt&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 23</span><span class="cl">        <span class="n">json_input</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">test_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 24</span><span class="cl">
</span></span><span class="line"><span class="ln"> 25</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdin&#34;</span><span class="p">,</span> <span class="n">StringIO</span><span class="p">(</span><span class="n">json_input</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 26</span><span class="cl">            <span class="n">result</span> <span class="o">=</span> <span class="n">read_hook_input</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 27</span><span class="cl">
</span></span><span class="line"><span class="ln"> 28</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">test_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 29</span><span class="cl">
</span></span><span class="line"><span class="ln"> 30</span><span class="cl">    <span class="k">def</span> <span class="nf">test_invalid_json_returns_empty_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 31</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試無效的 JSON 應返回空字典&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 32</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdin&#34;</span><span class="p">,</span> <span class="n">StringIO</span><span class="p">(</span><span class="s2">&#34;not valid json&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 33</span><span class="cl">            <span class="n">result</span> <span class="o">=</span> <span class="n">read_hook_input</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 34</span><span class="cl">
</span></span><span class="line"><span class="ln"> 35</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 36</span><span class="cl">
</span></span><span class="line"><span class="ln"> 37</span><span class="cl">    <span class="k">def</span> <span class="nf">test_empty_input_returns_empty_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 38</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試空輸入應返回空字典&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 39</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdin&#34;</span><span class="p">,</span> <span class="n">StringIO</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 40</span><span class="cl">            <span class="n">result</span> <span class="o">=</span> <span class="n">read_hook_input</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 41</span><span class="cl">
</span></span><span class="line"><span class="ln"> 42</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="p">{})</span>
</span></span><span class="line"><span class="ln"> 43</span><span class="cl">
</span></span><span class="line"><span class="ln"> 44</span><span class="cl">
</span></span><span class="line"><span class="ln"> 45</span><span class="cl"><span class="k">class</span> <span class="nc">TestWriteHookOutput</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 46</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試 write_hook_output 函式&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 47</span><span class="cl">
</span></span><span class="line"><span class="ln"> 48</span><span class="cl">    <span class="k">def</span> <span class="nf">test_output_json_format</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 49</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試輸出為有效的 JSON 格式&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 50</span><span class="cl">        <span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;decision&#34;</span><span class="p">:</span> <span class="s2">&#34;allow&#34;</span><span class="p">,</span> <span class="s2">&#34;reason&#34;</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 51</span><span class="cl">
</span></span><span class="line"><span class="ln"> 52</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdout&#34;</span><span class="p">,</span> <span class="n">new_callable</span><span class="o">=</span><span class="n">StringIO</span><span class="p">)</span> <span class="k">as</span> <span class="n">mock_stdout</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 53</span><span class="cl">            <span class="n">write_hook_output</span><span class="p">(</span><span class="n">test_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 54</span><span class="cl">            <span class="n">output</span> <span class="o">=</span> <span class="n">mock_stdout</span><span class="o">.</span><span class="n">getvalue</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 55</span><span class="cl">
</span></span><span class="line"><span class="ln"> 56</span><span class="cl">        <span class="n">parsed</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">output</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 57</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">parsed</span><span class="p">[</span><span class="s2">&#34;decision&#34;</span><span class="p">],</span> <span class="s2">&#34;allow&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 58</span><span class="cl">
</span></span><span class="line"><span class="ln"> 59</span><span class="cl">    <span class="k">def</span> <span class="nf">test_chinese_characters_preserved</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 60</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試中文字元被保留&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 61</span><span class="cl">        <span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;你好&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 62</span><span class="cl">
</span></span><span class="line"><span class="ln"> 63</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdout&#34;</span><span class="p">,</span> <span class="n">new_callable</span><span class="o">=</span><span class="n">StringIO</span><span class="p">)</span> <span class="k">as</span> <span class="n">mock_stdout</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 64</span><span class="cl">            <span class="n">write_hook_output</span><span class="p">(</span><span class="n">test_data</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 65</span><span class="cl">            <span class="n">output</span> <span class="o">=</span> <span class="n">mock_stdout</span><span class="o">.</span><span class="n">getvalue</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 66</span><span class="cl">
</span></span><span class="line"><span class="ln"> 67</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertIn</span><span class="p">(</span><span class="s2">&#34;你好&#34;</span><span class="p">,</span> <span class="n">output</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 68</span><span class="cl">
</span></span><span class="line"><span class="ln"> 69</span><span class="cl">
</span></span><span class="line"><span class="ln"> 70</span><span class="cl"><span class="k">class</span> <span class="nc">TestCreatePretoolueOutput</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 71</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試 create_pretooluse_output 函式&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 72</span><span class="cl">
</span></span><span class="line"><span class="ln"> 73</span><span class="cl">    <span class="k">def</span> <span class="nf">test_basic_output_structure</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 74</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試基本輸出結構&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 75</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">create_pretooluse_output</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 76</span><span class="cl">            <span class="n">decision</span><span class="o">=</span><span class="s2">&#34;allow&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 77</span><span class="cl">            <span class="n">reason</span><span class="o">=</span><span class="s2">&#34;Test reason&#34;</span>
</span></span><span class="line"><span class="ln"> 78</span><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 79</span><span class="cl">
</span></span><span class="line"><span class="ln"> 80</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertIn</span><span class="p">(</span><span class="s2">&#34;hookSpecificOutput&#34;</span><span class="p">,</span> <span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 81</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 82</span><span class="cl">            <span class="n">result</span><span class="p">[</span><span class="s2">&#34;hookSpecificOutput&#34;</span><span class="p">][</span><span class="s2">&#34;permissionDecision&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 83</span><span class="cl">            <span class="s2">&#34;allow&#34;</span>
</span></span><span class="line"><span class="ln"> 84</span><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 85</span><span class="cl">
</span></span><span class="line"><span class="ln"> 86</span><span class="cl">    <span class="k">def</span> <span class="nf">test_with_user_prompt</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 87</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試包含 userPrompt 的輸出&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 88</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">create_pretooluse_output</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 89</span><span class="cl">            <span class="n">decision</span><span class="o">=</span><span class="s2">&#34;ask&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 90</span><span class="cl">            <span class="n">reason</span><span class="o">=</span><span class="s2">&#34;Need confirmation&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 91</span><span class="cl">            <span class="n">user_prompt</span><span class="o">=</span><span class="s2">&#34;Continue?&#34;</span>
</span></span><span class="line"><span class="ln"> 92</span><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 93</span><span class="cl">
</span></span><span class="line"><span class="ln"> 94</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 95</span><span class="cl">            <span class="n">result</span><span class="p">[</span><span class="s2">&#34;hookSpecificOutput&#34;</span><span class="p">][</span><span class="s2">&#34;userPrompt&#34;</span><span class="p">],</span>
</span></span><span class="line"><span class="ln"> 96</span><span class="cl">            <span class="s2">&#34;Continue?&#34;</span>
</span></span><span class="line"><span class="ln"> 97</span><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="ln"> 98</span><span class="cl">
</span></span><span class="line"><span class="ln"> 99</span><span class="cl">
</span></span><span class="line"><span class="ln">100</span><span class="cl"><span class="k">if</span> <span class="vm">__name__</span> <span class="o">==</span> <span class="s2">&#34;__main__&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">101</span><span class="cl">    <span class="n">unittest</span><span class="o">.</span><span class="n">main</span><span class="p">()</span></span></span></code></pre></div><h2 id="測試異常">測試異常</h2>
<h3 id="assertraises">assertRaises</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">def</span> <span class="nf">test_raises_value_error</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試函式是否拋出 ValueError&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">assertRaises</span><span class="p">(</span><span class="ne">ValueError</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nb">int</span><span class="p">(</span><span class="s2">&#34;not a number&#34;</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="k">def</span> <span class="nf">test_raises_with_message</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試異常訊息&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">with</span> <span class="bp">self</span><span class="o">.</span><span class="n">assertRaises</span><span class="p">(</span><span class="ne">ValueError</span><span class="p">)</span> <span class="k">as</span> <span class="n">context</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">raise</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;invalid input&#34;</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="bp">self</span><span class="o">.</span><span class="n">assertIn</span><span class="p">(</span><span class="s2">&#34;invalid&#34;</span><span class="p">,</span> <span class="nb">str</span><span class="p">(</span><span class="n">context</span><span class="o">.</span><span class="n">exception</span><span class="p">))</span></span></span></code></pre></div><h2 id="測試檔案操作">測試檔案操作</h2>
<h3 id="使用臨時檔案">使用臨時檔案</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">tempfile</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</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">class</span> <span class="nc">TestFileOperations</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</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="k">def</span> <span class="nf">test_read_file</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試檔案讀取&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="k">with</span> <span class="n">tempfile</span><span class="o">.</span><span class="n">NamedTemporaryFile</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="n">mode</span><span class="o">=</span><span class="s2">&#34;w&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">            <span class="n">suffix</span><span class="o">=</span><span class="s2">&#34;.txt&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="n">delete</span><span class="o">=</span><span class="kc">False</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="p">)</span> <span class="k">as</span> <span class="n">f</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="n">f</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="s2">&#34;test content&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="n">temp_path</span> <span class="o">=</span> <span class="n">f</span><span class="o">.</span><span class="n">name</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">try</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="n">result</span> <span class="o">=</span> <span class="n">read_file</span><span class="p">(</span><span class="n">temp_path</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="s2">&#34;test content&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">        <span class="k">finally</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="n">os</span><span class="o">.</span><span class="n">unlink</span><span class="p">(</span><span class="n">temp_path</span><span class="p">)</span></span></span></code></pre></div><h3 id="使用臨時目錄">使用臨時目錄</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">tempfile</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</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">class</span> <span class="nc">TestDirectoryOperations</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</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="k">def</span> <span class="nf">setUp</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">temp_dir</span> <span class="o">=</span> <span class="n">tempfile</span><span class="o">.</span><span class="n">mkdtemp</span><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="k">def</span> <span class="nf">tearDown</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="kn">import</span> <span class="nn">shutil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="n">shutil</span><span class="o">.</span><span class="n">rmtree</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">temp_dir</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="k">def</span> <span class="nf">test_create_file</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">file_path</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">temp_dir</span><span class="p">,</span> <span class="s2">&#34;test.txt&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">create_file</span><span class="p">(</span><span class="n">file_path</span><span class="p">,</span> <span class="s2">&#34;content&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertTrue</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="n">exists</span><span class="p">(</span><span class="n">file_path</span><span class="p">))</span></span></span></code></pre></div><h2 id="執行測試">執行測試</h2>
<h3 id="執行單一測試檔案">執行單一測試檔案</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python -m unittest tests/test_hook_io.py</span></span></code></pre></div><h3 id="執行單一測試類別">執行單一測試類別</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python -m unittest tests.test_hook_io.TestReadHookInput</span></span></code></pre></div><h3 id="執行單一測試方法">執行單一測試方法</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python -m unittest tests.test_hook_io.TestReadHookInput.test_valid_json_input</span></span></code></pre></div><h3 id="執行所有測試">執行所有測試</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python -m unittest discover -s tests -p <span class="s2">&#34;test_*.py&#34;</span></span></span></code></pre></div><h3 id="詳細輸出">詳細輸出</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">python -m unittest -v tests/test_hook_io.py</span></span></code></pre></div><h2 id="測試組織">測試組織</h2>
<h3 id="目錄結構">目錄結構</h3>





<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">.claude/lib/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── __init__.py
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">├── git_utils.py
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">├── hook_io.py
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">├── hook_logging.py
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">└── tests/
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    ├── __init__.py
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    ├── test_git_utils.py
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── test_hook_io.py
</span></span><span class="line"><span class="ln">10</span><span class="cl">    └── test_hook_logging.py</span></span></code></pre></div><h3 id="命名慣例">命名慣例</h3>
<ul>
<li>測試檔案：<code>test_&lt;module&gt;.py</code></li>
<li>測試類別：<code>Test&lt;ClassName&gt;</code></li>
<li>測試方法：<code>test_&lt;behavior&gt;</code></li>
</ul>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># test_git_utils.py</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">class</span> <span class="nc">TestRunGitCommand</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">def</span> <span class="nf">test_successful_command_returns_true</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="o">...</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">def</span> <span class="nf">test_failed_command_returns_false</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">        <span class="o">...</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="k">def</span> <span class="nf">test_timeout_returns_error_message</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">        <span class="o">...</span></span></span></code></pre></div><h2 id="最佳實踐">最佳實踐</h2>
<h3 id="1-一個測試驗證一件事">1. 一個測試驗證一件事</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># 好：每個測試只驗證一個行為</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">test_valid_input_returns_true</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">validate</span><span class="p">(</span><span class="s2">&#34;valid&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertTrue</span><span class="p">(</span><span class="n">result</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="k">def</span> <span class="nf">test_invalid_input_returns_false</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="n">result</span> <span class="o">=</span> <span class="n">validate</span><span class="p">(</span><span class="s2">&#34;invalid&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertFalse</span><span class="p">(</span><span class="n">result</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"># 不好：一個測試驗證多件事</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="k">def</span> <span class="nf">test_validate</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertTrue</span><span class="p">(</span><span class="n">validate</span><span class="p">(</span><span class="s2">&#34;valid&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertFalse</span><span class="p">(</span><span class="n">validate</span><span class="p">(</span><span class="s2">&#34;invalid&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">validate</span><span class="p">(</span><span class="s2">&#34;&#34;</span><span class="p">),</span> <span class="kc">None</span><span class="p">)</span></span></span></code></pre></div><h3 id="2-使用描述性的測試名稱">2. 使用描述性的測試名稱</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 好：清楚說明測試內容</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">def</span> <span class="nf">test_empty_input_returns_empty_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="o">...</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="c1"># 不好：模糊的名稱</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="k">def</span> <span class="nf">test_input</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="o">...</span></span></span></code></pre></div><h3 id="3-使用-setup-避免重複">3. 使用 setUp 避免重複</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="k">class</span> <span class="nc">TestMarkdownChecker</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">def</span> <span class="nf">setUp</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">checker</span> <span class="o">=</span> <span class="n">MarkdownLinkChecker</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">test_content</span> <span class="o">=</span> <span class="s2">&#34;# Test</span><span class="se">\n</span><span class="s2">[link](/python/05-error-testing/unittest/file.md)&#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="k">def</span> <span class="nf">test_check_valid_link</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="c1"># 使用 setUp 中建立的物件</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">checker</span><span class="o">.</span><span class="n">check</span><span class="p">(</span><span class="bp">self</span><span class="o">.</span><span class="n">test_content</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">        <span class="o">...</span></span></span></code></pre></div><h2 id="思考題">思考題</h2>
<ol>
<li><code>setUp</code> 和 <code>setUpClass</code> 有什麼區別？什麼時候用哪個？</li>
<li>為什麼測試方法必須以 <code>test_</code> 開頭？</li>
<li>如何測試一個需要讀取 stdin 的函式？</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<ol>
<li>為 <code>get_current_branch()</code> 函式撰寫測試</li>
<li>測試一個會拋出異常的函式</li>
<li>使用 <code>unittest.skip</code> 暫時跳過某個測試</li>
</ol>
<hr>
<p><em>上一章：<a href="/blog/python/05-error-testing/exception/" data-link-title="5.1 異常處理策略" data-link-desc="何時捕獲、何時拋出">異常處理策略</a></em>
<em>下一章：<a href="/blog/python/05-error-testing/mock/" data-link-title="5.4 Mock 與測試隔離" data-link-desc="隔離外部依賴">Mock 與測試隔離</a></em></p>
]]></content:encoded></item><item><title>Hyprland VM 環境設定與測試矩陣</title><link>https://tarrragon.github.io/blog/linux/dotfile/05-hyprland-config/hyprland-vm-setup/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/linux/dotfile/05-hyprland-config/hyprland-vm-setup/</guid><description>&lt;p>VM 是 Hyprland 配置的演練場——用來驗證「配置邏輯對不對」，不是用來體驗「跑起來順不順」。GPU 加速在 VM 中受限，動畫和模糊效果會嚴重降級或無法使用，但配置檔語法、keybind 設計、window rules、workspace 邏輯都能在 VM 中完整測試。&lt;/p>
&lt;h2 id="utm-on-apple-silicon-設定">UTM on Apple Silicon 設定&lt;/h2>
&lt;p>Apple Silicon Mac 用 UTM（基於 QEMU）跑 ARM64 Linux VM：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>CPU&lt;/strong>：UTM 使用 Apple Hypervisor.framework，ARM64 guest 接近原生速度&lt;/li>
&lt;li>&lt;strong>GPU&lt;/strong>：使用 &lt;code>virtio-gpu-gl-pci&lt;/code>（UTM v4.1+ 新建 Linux VM 的預設）。UTM v5.0.0+ 的 GitHub release 版支援 Venus driver（guest Mesa → host MoltenVK → Apple Metal 的 Vulkan 轉送路徑），這是目前最好的 GPU 加速方案&lt;/li>
&lt;li>&lt;strong>Linux ISO&lt;/strong>：Arch Linux ARM（archlinuxarm.org）或 Fedora aarch64&lt;/li>
&lt;li>&lt;strong>建議配置&lt;/strong>：4 CPU cores、4GB+ RAM、40GB+ disk&lt;/li>
&lt;/ul>
&lt;h3 id="utm-建-vm-的注意事項">UTM 建 VM 的注意事項&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>第一頁選 Virtualize，不是 Emulate&lt;/strong>——同架構（Apple Silicon 跑 ARM64 guest）兩條都是 QEMU，差在 Virtualize 走 hvf 硬體虛擬化（CPU 接近原生）、Emulate 走 TCG 純軟體模擬（CPU 慢一個數量級；實測 C++ 大型編譯一小時只跑 1/3）。Emulate 只在跨架構（ARM Mac 跑 x86_64 guest）才需要。判別現有 VM：guest 裡 &lt;code>lscpu&lt;/code> 的 Model name 是 &lt;code>-&lt;/code> 為直通、顯示具體型號（如 Cortex-A72）為模擬&lt;/li>
&lt;li>選 「Linux」 虛擬機類型，不是 「Other」&lt;/li>
&lt;li>Display Card 選 &lt;code>virtio-gpu-gl-pci&lt;/code>（有 3D 加速），不是 &lt;code>virtio-gpu-pci&lt;/code>（無加速）；Emulate 精靈預設給無加速的 &lt;code>virtio-gpu-pci&lt;/code>、Virtualize 精靈通常直接給對&lt;/li>
&lt;li>&lt;strong>VM 視窗可能停在序列視圖、圖形顯示是另一個視圖&lt;/strong>——Virtualize 精靈預設多附一個序列裝置，UTM 視窗開機後可能顯示的是序列 console（文字登入、guest 裡 &lt;code>who&lt;/code> 顯示登入在 &lt;code>pts/0&lt;/code>），跟 virtio-gpu 的圖形輸出是兩個獨立視圖。切換：VM 視窗工具列的顯示器下拉選單 → 選 &lt;code>Display 1 (virtio-gpu)&lt;/code>。判讀圖形裝置本身有沒有掛上看 guest 的 &lt;code>ls /dev/dri/&lt;/code>（有 &lt;code>card0&lt;/code> = 裝置在、只是視窗看錯視圖）。不想每次切就到 VM 設定移除序列裝置。compositor 要在圖形視圖那側的 VT 上啟動、序列 console 起不了 Hyprland&lt;/li>
&lt;li>如果用 App Store 版的 UTM（不含 Venus），只有基本的 virtio-gpu-gl 加速&lt;/li>
&lt;li>GitHub release 版的 UTM 支援 Venus/MoltenVK，效能更好但仍不及裸金屬&lt;/li>
&lt;li>&lt;strong>修飾鍵：Mac 的 ⌘ 對應 guest 的 &lt;code>SUPER&lt;/code>，但 macOS 會先攔截部分 ⌘ 組合&lt;/strong>——Hyprland 的 keybind 幾乎都以 &lt;code>SUPER&lt;/code>（Meta）當主修飾鍵（見 &lt;a href="../hyprland-core-config/">核心配置的修飾鍵段&lt;/a>），而 UTM 裡 Mac 的 Command ⌘ 通常就是那顆 &lt;code>SUPER&lt;/code>。問題是 macOS 自己會先吃掉某些 ⌘ 組合（⌘+Q 結束 app、⌘+Space Spotlight…），VM 收不到。判讀：&lt;code>SUPER&lt;/code> 綁定沒反應、但焦點視窗打字正常，多半是宿主層攔截、不是 Hyprland 配置錯。解法：先確認 VM 視窗有 focus；在 UTM 的鍵盤/輸入設定開「把系統快捷鍵送進 VM（capture input）」讓 ⌘ 組合進 guest。臨時繞過：需要重載配置這類動作，直接在已開的終端機下指令（如 &lt;code>source&lt;/code> / &lt;code>hyprctl reload&lt;/code>），不必卡在 ⌘ 鍵上&lt;/li>
&lt;/ul>
&lt;h2 id="vm-必要環境變數">VM 必要環境變數&lt;/h2>
&lt;p>在 Hyprland 配置裡加入以下環境變數（只在 VM 中使用，實機要移除）：&lt;/p></description><content:encoded><![CDATA[<p>VM 是 Hyprland 配置的演練場——用來驗證「配置邏輯對不對」，不是用來體驗「跑起來順不順」。GPU 加速在 VM 中受限，動畫和模糊效果會嚴重降級或無法使用，但配置檔語法、keybind 設計、window rules、workspace 邏輯都能在 VM 中完整測試。</p>
<h2 id="utm-on-apple-silicon-設定">UTM on Apple Silicon 設定</h2>
<p>Apple Silicon Mac 用 UTM（基於 QEMU）跑 ARM64 Linux VM：</p>
<ul>
<li><strong>CPU</strong>：UTM 使用 Apple Hypervisor.framework，ARM64 guest 接近原生速度</li>
<li><strong>GPU</strong>：使用 <code>virtio-gpu-gl-pci</code>（UTM v4.1+ 新建 Linux VM 的預設）。UTM v5.0.0+ 的 GitHub release 版支援 Venus driver（guest Mesa → host MoltenVK → Apple Metal 的 Vulkan 轉送路徑），這是目前最好的 GPU 加速方案</li>
<li><strong>Linux ISO</strong>：Arch Linux ARM（archlinuxarm.org）或 Fedora aarch64</li>
<li><strong>建議配置</strong>：4 CPU cores、4GB+ RAM、40GB+ disk</li>
</ul>
<h3 id="utm-建-vm-的注意事項">UTM 建 VM 的注意事項</h3>
<ul>
<li><strong>第一頁選 Virtualize，不是 Emulate</strong>——同架構（Apple Silicon 跑 ARM64 guest）兩條都是 QEMU，差在 Virtualize 走 hvf 硬體虛擬化（CPU 接近原生）、Emulate 走 TCG 純軟體模擬（CPU 慢一個數量級；實測 C++ 大型編譯一小時只跑 1/3）。Emulate 只在跨架構（ARM Mac 跑 x86_64 guest）才需要。判別現有 VM：guest 裡 <code>lscpu</code> 的 Model name 是 <code>-</code> 為直通、顯示具體型號（如 Cortex-A72）為模擬</li>
<li>選 「Linux」 虛擬機類型，不是 「Other」</li>
<li>Display Card 選 <code>virtio-gpu-gl-pci</code>（有 3D 加速），不是 <code>virtio-gpu-pci</code>（無加速）；Emulate 精靈預設給無加速的 <code>virtio-gpu-pci</code>、Virtualize 精靈通常直接給對</li>
<li><strong>VM 視窗可能停在序列視圖、圖形顯示是另一個視圖</strong>——Virtualize 精靈預設多附一個序列裝置，UTM 視窗開機後可能顯示的是序列 console（文字登入、guest 裡 <code>who</code> 顯示登入在 <code>pts/0</code>），跟 virtio-gpu 的圖形輸出是兩個獨立視圖。切換：VM 視窗工具列的顯示器下拉選單 → 選 <code>Display 1 (virtio-gpu)</code>。判讀圖形裝置本身有沒有掛上看 guest 的 <code>ls /dev/dri/</code>（有 <code>card0</code> = 裝置在、只是視窗看錯視圖）。不想每次切就到 VM 設定移除序列裝置。compositor 要在圖形視圖那側的 VT 上啟動、序列 console 起不了 Hyprland</li>
<li>如果用 App Store 版的 UTM（不含 Venus），只有基本的 virtio-gpu-gl 加速</li>
<li>GitHub release 版的 UTM 支援 Venus/MoltenVK，效能更好但仍不及裸金屬</li>
<li><strong>修飾鍵：Mac 的 ⌘ 對應 guest 的 <code>SUPER</code>，但 macOS 會先攔截部分 ⌘ 組合</strong>——Hyprland 的 keybind 幾乎都以 <code>SUPER</code>（Meta）當主修飾鍵（見 <a href="../hyprland-core-config/">核心配置的修飾鍵段</a>），而 UTM 裡 Mac 的 Command ⌘ 通常就是那顆 <code>SUPER</code>。問題是 macOS 自己會先吃掉某些 ⌘ 組合（⌘+Q 結束 app、⌘+Space Spotlight…），VM 收不到。判讀：<code>SUPER</code> 綁定沒反應、但焦點視窗打字正常，多半是宿主層攔截、不是 Hyprland 配置錯。解法：先確認 VM 視窗有 focus；在 UTM 的鍵盤/輸入設定開「把系統快捷鍵送進 VM（capture input）」讓 ⌘ 組合進 guest。臨時繞過：需要重載配置這類動作，直接在已開的終端機下指令（如 <code>source</code> / <code>hyprctl reload</code>），不必卡在 ⌘ 鍵上</li>
</ul>
<h2 id="vm-必要環境變數">VM 必要環境變數</h2>
<p>在 Hyprland 配置裡加入以下環境變數（只在 VM 中使用，實機要移除）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-lua" data-lang="lua"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">-- VM-only environment variables</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">env</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s2">&#34;WLR_RENDERER_ALLOW_SOFTWARE, 1&#34;</span><span class="p">,</span>  <span class="c1">-- 允許軟體渲染 fallback</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="s2">&#34;WLR_NO_HARDWARE_CURSORS, 1&#34;</span><span class="p">,</span>      <span class="c1">-- 停用硬體 cursor（VM 常見問題）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="s2">&#34;LIBGL_ALWAYS_SOFTWARE, 1&#34;</span><span class="p">,</span>        <span class="c1">-- 強制 Mesa 軟體渲染</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>如果上述仍無法啟動（virtio-gpu vGPU passthrough 的情況）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-lua" data-lang="lua"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">env</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s2">&#34;AQ_NO_KMS_REQUIREMENT, 1&#34;</span><span class="p">,</span>       <span class="c1">-- 繞過 KMS 需求</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="s2">&#34;WLR_RENDERER, pixman&#34;</span><span class="p">,</span>            <span class="c1">-- 強制 pixman 軟體 renderer</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><blockquote>
<p><strong>[已驗證]</strong> Hyprland 0.55（Aquamarine）+ UTM virtio-gpu-gl-pci 實測：GPU 加速模式下 Hyprland 直接走 VirGL/Venus，不需要 <code>WLR_RENDERER</code> 或 <code>WLR_RENDERER_ALLOW_SOFTWARE</code>。<code>AQ_NO_KMS_REQUIREMENT</code> 仍有效。軟體渲染 fallback 路徑（<code>WLR_RENDERER=pixman</code>）未測試——有 GPU 加速時不需要走這條。</p></blockquote>
<h2 id="vm-中應該關閉的效果">VM 中應該關閉的效果</h2>
<p>軟體渲染下，視覺效果是最大的效能殺手。建議在 VM 配置中停用：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-lua" data-lang="lua"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">hl.config</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="n">decoration</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="n">blur</span> <span class="o">=</span> <span class="p">{</span> <span class="n">enabled</span> <span class="o">=</span> <span class="kc">false</span> <span class="p">},</span>     <span class="c1">-- 模糊是 GPU 最重的效果</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="n">shadow</span> <span class="o">=</span> <span class="p">{</span> <span class="n">enabled</span> <span class="o">=</span> <span class="kc">false</span> <span class="p">},</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="n">rounding</span> <span class="o">=</span> <span class="mi">0</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 class="n">animations</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="n">enabled</span> <span class="o">=</span> <span class="kc">false</span><span class="p">,</span>                <span class="c1">-- 或設定極簡動畫</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>這些效果關閉後，基本的平鋪操作（切換視窗、移動 workspace、開關 app）在 VM 中應該足夠流暢。</p>
<h2 id="效能預期">效能預期</h2>
<table>
  <thead>
      <tr>
          <th>功能</th>
          <th>軟體渲染</th>
          <th>virtio-gpu-gl</th>
          <th>裸金屬（實機）</th>
      </tr>
  </thead>
  <tbody>
      <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>卡頓</td>
          <td>流暢</td>
      </tr>
      <tr>
          <td>Waybar + Wofi</td>
          <td>正常</td>
          <td>正常</td>
          <td>正常</td>
      </tr>
      <tr>
          <td>多 Workspace 切換</td>
          <td>正常</td>
          <td>正常</td>
          <td>正常</td>
      </tr>
      <tr>
          <td>Firefox 瀏覽</td>
          <td>明顯變慢</td>
          <td>可用</td>
          <td>正常</td>
      </tr>
  </tbody>
</table>
<p>VM 的價值在於驗證配置邏輯，不在於評估視覺體驗。如果在 VM 裡覺得「卡」，不代表 Hyprland 本身慢——多數情況是 VM 圖形加速的限制。</p>
<h2 id="sway-作為-vm-初步驗證工具">Sway 作為 VM 初步驗證工具</h2>
<p>如果 VM 中 Hyprland 跑得太吃力，可以先用 Sway 驗證 VM 的 Wayland 圖形棧是否正常：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">sudo pacman -S sway foot
</span></span><span class="line"><span class="ln">2</span><span class="cl">sway</span></span></code></pre></div><p>Sway 比 Hyprland 輕量（基於 wlroots、沒有華麗動畫），如果 Sway 能跑，代表 VM 的 Wayland 環境是正常的，Hyprland 的問題只是效能不夠。如果連 Sway 都跑不動，要回去檢查 VM 的 GPU 設定。</p>
<h2 id="vm-vs-實機測試矩陣">VM vs 實機測試矩陣</h2>
<h3 id="vm-中可完整驗證">VM 中可完整驗證</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>說明</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>配置檔語法</td>
          <td>Lua config 是否解析正確、require 拆分是否正常</td>
      </tr>
      <tr>
          <td>Keybind 設計</td>
          <td>快捷鍵邏輯、submap、modifier 組合</td>
      </tr>
      <tr>
          <td>Window rules</td>
          <td>float / workspace assignment / opacity 規則是否生效</td>
      </tr>
      <tr>
          <td>Workspace 切換</td>
          <td>workspace 編號、切換邏輯</td>
      </tr>
      <tr>
          <td>Layout 選擇</td>
          <td>dwindle vs master 的行為差異</td>
      </tr>
      <tr>
          <td>Waybar 模組配置</td>
          <td>JSON config + CSS styling 是否正確顯示</td>
      </tr>
      <tr>
          <td>Wofi/Rofi 主題</td>
          <td>啟動器的功能和外觀設定</td>
      </tr>
      <tr>
          <td>Mako 通知樣式</td>
          <td>通知的位置、配色、timeout</td>
      </tr>
      <tr>
          <td>Hyprlock 佈局</td>
          <td>鎖屏的輸入框位置和文字配置</td>
      </tr>
      <tr>
          <td>Autostart 順序</td>
          <td>exec-once 的程式是否正確啟動</td>
      </tr>
      <tr>
          <td>環境變數</td>
          <td>XDG、Qt、GTK 等環境變數是否正確設定</td>
      </tr>
      <tr>
          <td>Stow 部署</td>
          <td>dotfile repo 的 stow 是否正確建立 symlink</td>
      </tr>
      <tr>
          <td>Bootstrap script</td>
          <td>install.sh 的完整流程（安裝套件 + deploy 配置）</td>
      </tr>
      <tr>
          <td>Caelestia CLI 指令</td>
          <td><code>caelestia shell</code>、<code>caelestia scheme</code> 等指令是否可執行</td>
      </tr>
  </tbody>
</table>
<h3 id="需要實機測試">需要實機測試</h3>
<table>
  <thead>
      <tr>
          <th>項目</th>
          <th>為什麼 VM 不行</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>多螢幕配置</td>
          <td>VM 通常只有一個虛擬顯示器</td>
      </tr>
      <tr>
          <td>HiDPI / fractional scaling</td>
          <td>虛擬顯示器不模擬真實解析度行為</td>
      </tr>
      <tr>
          <td>VRR / Adaptive Sync</td>
          <td>需要支援 VRR 的真實螢幕</td>
      </tr>
      <tr>
          <td>動畫流暢度</td>
          <td>VM 的 GPU 加速不足以評估真實效能</td>
      </tr>
      <tr>
          <td>模糊效果品質</td>
          <td>軟體渲染下模糊不可用或品質差</td>
      </tr>
      <tr>
          <td>觸控板 / 手勢</td>
          <td>VM 沒有觸控板裝置</td>
      </tr>
      <tr>
          <td>媒體鍵 / 亮度鍵</td>
          <td>需要實體鍵盤上的 XF86 keycodes</td>
      </tr>
      <tr>
          <td>NVIDIA 驅動設定</td>
          <td>VM 不走 NVIDIA 驅動，所有 NVIDIA 配置無法測試</td>
      </tr>
      <tr>
          <td>Screen sharing</td>
          <td>PipeWire + portal 的完整鏈路在 VM 中測試無意義</td>
      </tr>
      <tr>
          <td>Suspend / Resume</td>
          <td>虛擬機的 suspend 行為跟實機不同</td>
      </tr>
      <tr>
          <td>硬體 cursor 渲染</td>
          <td>VM 用軟體 cursor，無法測試硬體 cursor 問題</td>
      </tr>
      <tr>
          <td>藍牙 / WiFi 整合</td>
          <td>需要實際硬體</td>
      </tr>
      <tr>
          <td>電池 / 電源管理</td>
          <td>筆電專屬功能</td>
      </tr>
      <tr>
          <td>日常使用效能</td>
          <td>只有在實機跑一段時間才能評估「能不能當主力」</td>
      </tr>
  </tbody>
</table>
<h2 id="務實的-vm-使用策略">務實的 VM 使用策略</h2>
<p>VM 階段的目標是「把配置寫好、驗證邏輯、確認 bootstrap script 能跑」，不是「體驗 Hyprland 好不好用」。具體做法：</p>
<ol>
<li>在 VM 中完成 Arch Linux 安裝 + Hyprland 套件安裝（怎麼把那台 Linux 從 ISO 裝起來、安裝程式選項怎麼判讀、裝完怎麼驗工具與連入，見 <a href="/blog/linux/install/" data-link-title="Linux 安裝與機器初始化" data-link-desc="在 VM 或新機器從零裝好 Linux、判讀安裝程式選項、驗證最小系統、或要從外部連入跑 bootstrap 時回來讀">Linux 安裝與機器初始化</a>）</li>
<li>關閉所有視覺效果（blur / animation / shadow）</li>
<li>寫好完整的 Hyprland 配置（keybind / rules / workspace / autostart）</li>
<li>寫好 waybar / wofi / mako 配置</li>
<li>測試 stow 部署流程（從 dotfile repo clone → stow → 配置生效）</li>
<li>測試 bootstrap script（install.sh 從零到完整桌面）</li>
<li>把驗證過的配置 commit 進 dotfile repo</li>
</ol>
<p>到實機時，clone dotfile repo → 跑 install.sh → 補上硬體相關設定（monitor、GPU 驅動、觸控板）→ 打開視覺效果。VM 階段的工作在實機上幾乎不用重做。</p>
]]></content:encoded></item><item><title>「事後補 log」vs「設計產物 log」的品質差異</title><link>https://tarrragon.github.io/blog/testing/02-client-observability/hotfix-log-vs-designed-log/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/02-client-observability/hotfix-log-vs-designed-log/</guid><description>&lt;p>事後補 log 和設計產物 log 的差別在於產出時機和品質標準。事後補的 log 在 debug 壓力下產出，目的是「讓這次的問題能被定位」；設計產物的 log 在功能規格階段產出，目的是「讓未來任何問題都能被定位」。兩者的品質差異在格式統一性、覆蓋完整性和長期維護成本三個面向上表現明顯。&lt;/p>
&lt;h2 id="格式統一性">格式統一性&lt;/h2>
&lt;p>app_tunnel 在 W2 修復時補的 &lt;code>developer.log&lt;/code> 格式不統一（&lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a>）。不同元件由不同時間點、不同 debug 需求補上的 log，各自有各自的風格：&lt;/p>
&lt;p>有的帶 &lt;code>name:&lt;/code> 參數讓 log 可以按元件過濾：&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="n">developer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;WS connected&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">name:&lt;/span> &lt;span class="s1">&amp;#39;ConnectionManager&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>有的不帶，混在全域 log 裡無法過濾：&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="n">developer&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;auth token sent&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>有的帶 &lt;code>// i18n-exempt&lt;/code> 標記（因為 linter 會對 hardcoded string 報警），有的忘了加。有的把錯誤訊息放在 &lt;code>error:&lt;/code> 參數，有的用字串串接。&lt;/p>
&lt;p>這些不一致來自事後補 log 的結構性原因：每條 log 是在解決當下問題時加的，沒有統一規範，也沒有 review。加完能定位問題就提交，下次遇到新問題再加新的 log — 格式隨機。&lt;/p>
&lt;p>設計產物 log 在產出前就有命名規則和格式規範（見 &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法&lt;/a>）。所有 log 點走同一個 &lt;code>AppLogger&lt;/code> 介面，name、level、結構化欄位在規格階段就定義好，實作時照規格寫。&lt;/p>
&lt;h2 id="覆蓋完整性">覆蓋完整性&lt;/h2>
&lt;p>事後補 log 的覆蓋範圍由「哪些問題已經發生過」決定。W2-002 auth token 問題觸發了 &lt;code>ConnectionManager&lt;/code> 和 &lt;code>TerminalScreen&lt;/code> 的 log 補充，但 &lt;code>TtydProtocol&lt;/code>、&lt;code>BiometricService&lt;/code>、&lt;code>CredentialRepository&lt;/code>、&lt;code>EnrollmentScreen&lt;/code> 四個元件仍然零 log — 因為這四個元件在 W2 的 debug 過程中不是瓶頸。&lt;/p>
&lt;p>六個核心元件中四個零 log 的狀態意味著：下次如果問題出在 &lt;code>BiometricService&lt;/code>（例如特定 iOS 版本的 biometric API 行為改變），debug 又會回到「手動加 log → 重新編譯 → 插拔裝置」的循環。事後補 log 只覆蓋已知問題的路徑，對未知問題沒有防護。&lt;/p>
&lt;p>設計產物 log 的覆蓋範圍由功能流程的步驟數決定。每個功能規格列出所有步驟的 log 點，不管這些步驟是否曾經出過問題。&lt;code>BiometricService.authenticate()&lt;/code> 在規格中就有 start/done/failed 三個 log 點，無論是否遇過 biometric 問題。&lt;/p>
&lt;h2 id="維護成本">維護成本&lt;/h2>
&lt;p>事後補 log 隨 debug 過程累積，沒有統一管理。隨時間推移：&lt;/p>
&lt;ul>
&lt;li>某些 log 的觸發條件已經不存在了（被修復的 bug 對應的 log），但沒人清理&lt;/li>
&lt;li>某些 log 的格式和新加的 log 不一致，但沒人統一&lt;/li>
&lt;li>某些 log 的 context 資訊不足（當時能定位問題是因為開發者記得 context，半年後換人接手就不夠了）&lt;/li>
&lt;li>某些 log 在 release build 中不該出現但忘了加條件&lt;/li>
&lt;/ul>
&lt;p>設計產物 log 有規格文件作為 source of truth。功能變更時更新規格中的 log 點列表，刪除的步驟對應的 log 點一起刪除，新增的步驟對應的 log 點一起新增。Log 的生命週期和功能的生命週期綁定。&lt;/p>
&lt;h2 id="從事後補過渡到設計產物">從事後補過渡到設計產物&lt;/h2>
&lt;p>已有的事後補 log 不需要全部重寫。過渡策略是：&lt;/p>
&lt;p>&lt;strong>統一入口&lt;/strong>：建立 &lt;code>AppLogger&lt;/code> 封裝，把現有的 &lt;code>developer.log&lt;/code> 呼叫改為走 &lt;code>AppLogger&lt;/code>。這一步不改 log 內容，只改呼叫方式，讓後續的格式統一和功能切換有統一入口。&lt;/p></description><content:encoded><![CDATA[<p>事後補 log 和設計產物 log 的差別在於產出時機和品質標準。事後補的 log 在 debug 壓力下產出，目的是「讓這次的問題能被定位」；設計產物的 log 在功能規格階段產出，目的是「讓未來任何問題都能被定位」。兩者的品質差異在格式統一性、覆蓋完整性和長期維護成本三個面向上表現明顯。</p>
<h2 id="格式統一性">格式統一性</h2>
<p>app_tunnel 在 W2 修復時補的 <code>developer.log</code> 格式不統一（<a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a>）。不同元件由不同時間點、不同 debug 需求補上的 log，各自有各自的風格：</p>
<p>有的帶 <code>name:</code> 參數讓 log 可以按元件過濾：</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="n">developer</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="s1">&#39;WS connected&#39;</span><span class="p">,</span> <span class="nl">name:</span> <span class="s1">&#39;ConnectionManager&#39;</span><span class="p">);</span></span></span></code></pre></div><p>有的不帶，混在全域 log 裡無法過濾：</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="n">developer</span><span class="p">.</span><span class="n">log</span><span class="p">(</span><span class="s1">&#39;auth token sent&#39;</span><span class="p">);</span></span></span></code></pre></div><p>有的帶 <code>// i18n-exempt</code> 標記（因為 linter 會對 hardcoded string 報警），有的忘了加。有的把錯誤訊息放在 <code>error:</code> 參數，有的用字串串接。</p>
<p>這些不一致來自事後補 log 的結構性原因：每條 log 是在解決當下問題時加的，沒有統一規範，也沒有 review。加完能定位問題就提交，下次遇到新問題再加新的 log — 格式隨機。</p>
<p>設計產物 log 在產出前就有命名規則和格式規範（見 <a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a>）。所有 log 點走同一個 <code>AppLogger</code> 介面，name、level、結構化欄位在規格階段就定義好，實作時照規格寫。</p>
<h2 id="覆蓋完整性">覆蓋完整性</h2>
<p>事後補 log 的覆蓋範圍由「哪些問題已經發生過」決定。W2-002 auth token 問題觸發了 <code>ConnectionManager</code> 和 <code>TerminalScreen</code> 的 log 補充，但 <code>TtydProtocol</code>、<code>BiometricService</code>、<code>CredentialRepository</code>、<code>EnrollmentScreen</code> 四個元件仍然零 log — 因為這四個元件在 W2 的 debug 過程中不是瓶頸。</p>
<p>六個核心元件中四個零 log 的狀態意味著：下次如果問題出在 <code>BiometricService</code>（例如特定 iOS 版本的 biometric API 行為改變），debug 又會回到「手動加 log → 重新編譯 → 插拔裝置」的循環。事後補 log 只覆蓋已知問題的路徑，對未知問題沒有防護。</p>
<p>設計產物 log 的覆蓋範圍由功能流程的步驟數決定。每個功能規格列出所有步驟的 log 點，不管這些步驟是否曾經出過問題。<code>BiometricService.authenticate()</code> 在規格中就有 start/done/failed 三個 log 點，無論是否遇過 biometric 問題。</p>
<h2 id="維護成本">維護成本</h2>
<p>事後補 log 隨 debug 過程累積，沒有統一管理。隨時間推移：</p>
<ul>
<li>某些 log 的觸發條件已經不存在了（被修復的 bug 對應的 log），但沒人清理</li>
<li>某些 log 的格式和新加的 log 不一致，但沒人統一</li>
<li>某些 log 的 context 資訊不足（當時能定位問題是因為開發者記得 context，半年後換人接手就不夠了）</li>
<li>某些 log 在 release build 中不該出現但忘了加條件</li>
</ul>
<p>設計產物 log 有規格文件作為 source of truth。功能變更時更新規格中的 log 點列表，刪除的步驟對應的 log 點一起刪除，新增的步驟對應的 log 點一起新增。Log 的生命週期和功能的生命週期綁定。</p>
<h2 id="從事後補過渡到設計產物">從事後補過渡到設計產物</h2>
<p>已有的事後補 log 不需要全部重寫。過渡策略是：</p>
<p><strong>統一入口</strong>：建立 <code>AppLogger</code> 封裝，把現有的 <code>developer.log</code> 呼叫改為走 <code>AppLogger</code>。這一步不改 log 內容，只改呼叫方式，讓後續的格式統一和功能切換有統一入口。</p>
<p><strong>補規格</strong>：對每個功能寫出 log 點規格表（四類 log 點），比對現有 log 和規格的差距。規格中有但程式碼中沒有的 log 點 = 覆蓋缺口，補上。程式碼中有但規格中沒有的 log 點 = 可能是過時的 debug log，評估是否刪除。</p>
<p><strong>新功能走設計產物流程</strong>：從下一個新功能開始，功能規格中包含可觀測性欄位。新功能的 log 從一開始就是設計產物品質。</p>
<p>過渡的第一步是建立統一入口，具體的 log 點規格格式見<a href="/blog/testing/02-client-observability/log-point-in-spec/" data-link-title="功能規格中的 log 點定義方法" data-link-desc="把 log 點設計從 debug 階段前移到功能規格階段 — 每個功能的規格文件新增可觀測性欄位，列出啟動 / 步驟 / 錯誤 / 完成四類 log 點">功能規格中的 log 點定義方法</a>。規格中的每個 log 點屬於哪一層（連線生命週期 / protocol / 使用者行為），在<a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a>中定義。收集到 log 之後用自架還是商業方案處理，見<a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a>的判斷流程。</p>
]]></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>Flaky test 根因分類</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/flaky-test-root-cause/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/flaky-test-root-cause/</guid><description>&lt;p>Flaky test 是指在程式碼沒有改變的情況下，test 的結果在通過和失敗之間隨機切換。Flaky test 侵蝕團隊對 test suite 的信任 — 如果 test 經常「隨便」失敗，開發者會習慣性地 re-run 而非調查失敗原因，真正的 bug 可能在 re-run 中被忽略。&lt;/p>
&lt;h2 id="四類根因">四類根因&lt;/h2>
&lt;h3 id="計時依賴">計時依賴&lt;/h3>
&lt;p>Test 依賴特定的時間條件 — timeout、delay、animation duration。系統負載不同時，時間條件可能滿足也可能不滿足。&lt;/p>
&lt;p>常見模式：&lt;/p>
&lt;ul>
&lt;li>&lt;code>await Future.delayed(Duration(seconds: 2))&lt;/code> + assertion — 如果操作在 2 秒內完成，test 通過；如果 CI 機器負載高導致操作超過 2 秒，test 失敗&lt;/li>
&lt;li>&lt;code>expect(stopwatch.elapsed, lessThan(Duration(seconds: 1)))&lt;/code> — 效能斷言在不同機器上結果不同&lt;/li>
&lt;/ul>
&lt;p>處理策略：用事件驅動代替 timeout。等待 &lt;code>stream.first&lt;/code> 代替 &lt;code>delay(2s) + check&lt;/code>；用 completion signal 代替固定等待時間。如果必須用 timeout，設定寬裕的上限（10x 預期時間）而非精確的預期值。&lt;/p>
&lt;h3 id="環境差異">環境差異&lt;/h3>
&lt;p>Test 在不同環境下行為不同 — 作業系統、檔案系統、時區、locale、DNS 解析。&lt;/p>
&lt;p>常見模式：&lt;/p>
&lt;ul>
&lt;li>檔案路徑分隔符（&lt;code>/&lt;/code> vs &lt;code>\&lt;/code>）在不同 OS 下不同&lt;/li>
&lt;li>時間格式化結果依時區而定（UTC vs local）&lt;/li>
&lt;li>浮點數比較因 CPU 架構不同有微小差異&lt;/li>
&lt;/ul>
&lt;p>處理策略：用 &lt;code>path.join&lt;/code> 代替硬編碼路徑；時間操作用 UTC；浮點比較用 &lt;code>closeTo&lt;/code> 代替精確比較。在 CI 中固定環境變數（&lt;code>TZ=UTC&lt;/code>、&lt;code>LANG=en_US.UTF-8&lt;/code>）。&lt;/p>
&lt;h3 id="資源競爭">資源競爭&lt;/h3>
&lt;p>Test 依賴共享資源（port、暫存檔、資料庫行）— 平行執行時多個 test 同時存取同一資源，結果依賴執行順序。&lt;/p>
&lt;p>常見模式：&lt;/p>
&lt;ul>
&lt;li>多個 test 監聽同一個 port — 第二個綁定失敗&lt;/li>
&lt;li>多個 test 寫入同一個暫存檔 — 內容被覆蓋&lt;/li>
&lt;li>多個 test 操作同一個資料庫 table — 資料互相干擾&lt;/li>
&lt;/ul>
&lt;p>處理策略：每個 test 使用獨立的資源（隨機 port、唯一檔名、隔離的資料庫 schema）。如果資源無法隔離，sequential 執行相關 test（&lt;code>@sequential&lt;/code> 標註）。&lt;/p>
&lt;h3 id="非確定性輸出">非確定性輸出&lt;/h3>
&lt;p>程式碼的輸出本身不確定 — &lt;code>Set&lt;/code> 的迭代順序、&lt;code>Map&lt;/code> 的 key 順序、非同步操作的完成順序。&lt;/p>
&lt;p>常見模式：&lt;/p>
&lt;ul>
&lt;li>斷言 &lt;code>Set&lt;/code> 的 &lt;code>toString()&lt;/code> 結果等於特定字串 — &lt;code>Set&lt;/code> 的迭代順序不保證&lt;/li>
&lt;li>斷言 &lt;code>Future.wait([a, b]).then((results) =&amp;gt; results[0])&lt;/code> — &lt;code>a&lt;/code> 和 &lt;code>b&lt;/code> 的完成順序不固定&lt;/li>
&lt;li>斷言 JSON 序列化的 key 順序 — &lt;code>Map&lt;/code> 的 key 順序在不同實作中不同&lt;/li>
&lt;/ul>
&lt;p>處理策略：不斷言順序（用 &lt;code>containsAll&lt;/code> 代替 &lt;code>equals&lt;/code> 比較集合）；不斷言序列化格式（反序列化後比較值）；用 &lt;code>completion&lt;/code> matcher 代替順序假設。&lt;/p>
&lt;h2 id="診斷步驟">診斷步驟&lt;/h2>
&lt;p>發現疑似 flaky test 時的診斷步驟：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>確認 flaky&lt;/strong>：在乾淨環境連續跑 20 次，確認失敗是隨機的（如果每次都失敗，是 bug 不是 flaky）&lt;/li>
&lt;li>&lt;strong>收集失敗訊息&lt;/strong>：記錄每次失敗的 assertion 訊息、stack trace、環境資訊（OS 版本、CI 機器 ID）&lt;/li>
&lt;li>&lt;strong>分類&lt;/strong>：失敗訊息指向時間（timeout）→ 計時依賴；指向值不同 → 非確定性或環境差異；指向連接失敗 → 資源競爭&lt;/li>
&lt;li>&lt;strong>修復&lt;/strong>：根據分類使用對應的處理策略&lt;/li>
&lt;/ol>
&lt;p>分類和修復之外，flaky test 的根因有時來自 assertion 本身的設計 — &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問&lt;/a>提供判斷 assertion 是否有效的框架。如果 flaky 的根因是 mock 和真實服務的行為差異，回到 &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/mock-boundary-decision/" data-link-title="Mock 邊界判斷決策表" data-link-desc="什麼時候 mock 夠用、什麼時候需要真實服務 — 從 API 層 / 協議層 / 環境層的斷裂點判斷 mock 的適用範圍">Mock 邊界判斷決策表&lt;/a>判斷 mock 是否還適用。Protocol integration test 在 CI 中的&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/service-fixture-management/" data-link-title="CI 中的服務 fixture 管理" data-link-desc="在 CI 中啟動和停止真實服務的 test harness 設計 — Process.start / Docker / testcontainers 三種方案的適用場景">服務 fixture 管理&lt;/a>也是 flaky 的常見來源 — 服務啟動不完全就開始跑 test。&lt;/p></description><content:encoded><![CDATA[<p>Flaky test 是指在程式碼沒有改變的情況下，test 的結果在通過和失敗之間隨機切換。Flaky test 侵蝕團隊對 test suite 的信任 — 如果 test 經常「隨便」失敗，開發者會習慣性地 re-run 而非調查失敗原因，真正的 bug 可能在 re-run 中被忽略。</p>
<h2 id="四類根因">四類根因</h2>
<h3 id="計時依賴">計時依賴</h3>
<p>Test 依賴特定的時間條件 — timeout、delay、animation duration。系統負載不同時，時間條件可能滿足也可能不滿足。</p>
<p>常見模式：</p>
<ul>
<li><code>await Future.delayed(Duration(seconds: 2))</code> + assertion — 如果操作在 2 秒內完成，test 通過；如果 CI 機器負載高導致操作超過 2 秒，test 失敗</li>
<li><code>expect(stopwatch.elapsed, lessThan(Duration(seconds: 1)))</code> — 效能斷言在不同機器上結果不同</li>
</ul>
<p>處理策略：用事件驅動代替 timeout。等待 <code>stream.first</code> 代替 <code>delay(2s) + check</code>；用 completion signal 代替固定等待時間。如果必須用 timeout，設定寬裕的上限（10x 預期時間）而非精確的預期值。</p>
<h3 id="環境差異">環境差異</h3>
<p>Test 在不同環境下行為不同 — 作業系統、檔案系統、時區、locale、DNS 解析。</p>
<p>常見模式：</p>
<ul>
<li>檔案路徑分隔符（<code>/</code> vs <code>\</code>）在不同 OS 下不同</li>
<li>時間格式化結果依時區而定（UTC vs local）</li>
<li>浮點數比較因 CPU 架構不同有微小差異</li>
</ul>
<p>處理策略：用 <code>path.join</code> 代替硬編碼路徑；時間操作用 UTC；浮點比較用 <code>closeTo</code> 代替精確比較。在 CI 中固定環境變數（<code>TZ=UTC</code>、<code>LANG=en_US.UTF-8</code>）。</p>
<h3 id="資源競爭">資源競爭</h3>
<p>Test 依賴共享資源（port、暫存檔、資料庫行）— 平行執行時多個 test 同時存取同一資源，結果依賴執行順序。</p>
<p>常見模式：</p>
<ul>
<li>多個 test 監聽同一個 port — 第二個綁定失敗</li>
<li>多個 test 寫入同一個暫存檔 — 內容被覆蓋</li>
<li>多個 test 操作同一個資料庫 table — 資料互相干擾</li>
</ul>
<p>處理策略：每個 test 使用獨立的資源（隨機 port、唯一檔名、隔離的資料庫 schema）。如果資源無法隔離，sequential 執行相關 test（<code>@sequential</code> 標註）。</p>
<h3 id="非確定性輸出">非確定性輸出</h3>
<p>程式碼的輸出本身不確定 — <code>Set</code> 的迭代順序、<code>Map</code> 的 key 順序、非同步操作的完成順序。</p>
<p>常見模式：</p>
<ul>
<li>斷言 <code>Set</code> 的 <code>toString()</code> 結果等於特定字串 — <code>Set</code> 的迭代順序不保證</li>
<li>斷言 <code>Future.wait([a, b]).then((results) =&gt; results[0])</code> — <code>a</code> 和 <code>b</code> 的完成順序不固定</li>
<li>斷言 JSON 序列化的 key 順序 — <code>Map</code> 的 key 順序在不同實作中不同</li>
</ul>
<p>處理策略：不斷言順序（用 <code>containsAll</code> 代替 <code>equals</code> 比較集合）；不斷言序列化格式（反序列化後比較值）；用 <code>completion</code> matcher 代替順序假設。</p>
<h2 id="診斷步驟">診斷步驟</h2>
<p>發現疑似 flaky test 時的診斷步驟：</p>
<ol>
<li><strong>確認 flaky</strong>：在乾淨環境連續跑 20 次，確認失敗是隨機的（如果每次都失敗，是 bug 不是 flaky）</li>
<li><strong>收集失敗訊息</strong>：記錄每次失敗的 assertion 訊息、stack trace、環境資訊（OS 版本、CI 機器 ID）</li>
<li><strong>分類</strong>：失敗訊息指向時間（timeout）→ 計時依賴；指向值不同 → 非確定性或環境差異；指向連接失敗 → 資源競爭</li>
<li><strong>修復</strong>：根據分類使用對應的處理策略</li>
</ol>
<p>分類和修復之外，flaky test 的根因有時來自 assertion 本身的設計 — <a href="/blog/testing/05-test-design-judgment/assertion-quality/" data-link-title="Assertion 品質三問" data-link-desc="斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？— 三個問題判斷 assertion 是否有效">Assertion 品質三問</a>提供判斷 assertion 是否有效的框架。如果 flaky 的根因是 mock 和真實服務的行為差異，回到 <a href="/blog/testing/05-test-design-judgment/mock-boundary-decision/" data-link-title="Mock 邊界判斷決策表" data-link-desc="什麼時候 mock 夠用、什麼時候需要真實服務 — 從 API 層 / 協議層 / 環境層的斷裂點判斷 mock 的適用範圍">Mock 邊界判斷決策表</a>判斷 mock 是否還適用。Protocol integration test 在 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 三種方案的適用場景">服務 fixture 管理</a>也是 flaky 的常見來源 — 服務啟動不完全就開始跑 test。</p>
]]></content:encoded></item><item><title>Screen State Test</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/screen-state-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/screen-state-test/</guid><description>&lt;p>Screen state test 的核心概念是「驗證畫面層級的狀態機是否完整 — 每個狀態下使用者看到什麼、能操作什麼、怎麼離開」。它的斷言對象是使用者可見的畫面，和 unit test（斷言函式回傳值）及 &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/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Screen state test 是測試三層中的第三層。Unit test 驗證程式碼邏輯，protocol integration test 驗證協議互動，screen state test 驗證畫面狀態。同一段程式碼可能 unit test 通過但 screen state test 失敗 — 因為 UI binding 的問題讓正確的邏輯沒有反映到畫面上。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 screen state test 的訊號是畫面有多個狀態（loading / connected / error / disconnected）且狀態轉換邏輯複雜。&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>直接轉成 test case — 矩陣中每個狀態的「顯示」「可用操作」「退出路徑」各對應一個 assertion。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Screen state test 要決定用什麼工具驗證畫面（widget test / integration test / Playwright）、斷言的粒度（元素存在 / 文字內容 / 視覺比對）、和狀態的觸發方式（mock 觸發狀態切換 / 真實操作觸發）。&lt;/p></description><content:encoded><![CDATA[<p>Screen state test 的核心概念是「驗證畫面層級的狀態機是否完整 — 每個狀態下使用者看到什麼、能操作什麼、怎麼離開」。它的斷言對象是使用者可見的畫面，和 unit test（斷言函式回傳值）及 <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/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>Screen state test 是測試三層中的第三層。Unit test 驗證程式碼邏輯，protocol integration test 驗證協議互動，screen state test 驗證畫面狀態。同一段程式碼可能 unit test 通過但 screen state test 失敗 — 因為 UI binding 的問題讓正確的邏輯沒有反映到畫面上。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 screen state test 的訊號是畫面有多個狀態（loading / connected / error / disconnected）且狀態轉換邏輯複雜。<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>直接轉成 test case — 矩陣中每個狀態的「顯示」「可用操作」「退出路徑」各對應一個 assertion。</p>
<h2 id="設計責任">設計責任</h2>
<p>Screen state test 要決定用什麼工具驗證畫面（widget test / integration test / Playwright）、斷言的粒度（元素存在 / 文字內容 / 視覺比對）、和狀態的觸發方式（mock 觸發狀態切換 / 真實操作觸發）。</p>
]]></content:encoded></item><item><title>T.C4 Client-side log 缺失導致 debug 只能靠實機盲測</title><link>https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/</guid><description>&lt;p>這個案例的核心責任是說明「客戶端 log 設計」為什麼應該在功能企劃階段完成，而不是 debug 時才補。Log 不是 debug 工具，是可觀測性基礎設施。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的六個核心元件在實機測試前的 log 覆蓋狀態：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>元件&lt;/th>
 &lt;th>log 點數&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>ConnectionManager&lt;/td>
 &lt;td>0 → 10&lt;/td>
 &lt;td>W2 修復後補的 &lt;code>developer.log&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TerminalScreen&lt;/td>
 &lt;td>0 → 5&lt;/td>
 &lt;td>W2 修復後補的&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TtydProtocol&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>encode/decode/buildAuth 無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>BiometricService&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>isAvailable/authenticate 結果無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CredentialRepository&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>load/save/delete 操作無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EnrollmentScreen&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>QR 掃描/解析/儲存無 log&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>W2-004（P0：iOS 實機 WS stream 不觸發）的 debug 過程：無法從任何 log 判斷問題發生在 biometric → credential → WS connect → auth token → stream listen 的哪一步。開發者被迫在每個函式手動加 &lt;code>developer.log&lt;/code>，重新編譯，插拔裝置測試，反覆數次才定位到「stream 訂閱時機」問題。&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>debug 成本&lt;/td>
 &lt;td>每次修改→編譯→部署→測試約 3-5 分鐘&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>定位 W2-002 (auth token) 花費&lt;/td>
 &lt;td>約 30 分鐘反覆測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>若有連線生命週期 log&lt;/td>
 &lt;td>第一次連線就能看到「Step 3 之後無 auth token 發送」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Log 缺失把 debug 成本從秒級升到分鐘級&lt;/strong>。如果 ConnectionManager 在企劃階段就設計了「Step 1: biometric → Step 2: credential → Step 3: WS connect → Step 4: auth token → Step 5: listen stream」五步 log，W2-002 的 auth token 問題在第一次連線就能從 log 看到「Step 3 完成，Step 4 未執行」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>「事後補 log」的 log 品質較低&lt;/strong>。W2 修復時補的 &lt;code>developer.log&lt;/code> 格式不統一（有的帶 &lt;code>name:&lt;/code>，有的不帶；有的用 &lt;code>// i18n-exempt&lt;/code> 標記，有的忘了），沒有統一的 log 層級，沒有結構化欄位。事後補的 log 是救火工具，不是可觀測性設計。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>自用工具最適合自架 log 收集&lt;/strong>。app_tunnel 的 server 和 client 都在同一台機器上（或同一個 Tailscale tailnet），client 可以直接打 HTTP POST 到本機的 log endpoint，不需要 Sentry 或 Crashlytics。一個 Go 寫的 JSON log receiver（20 行）+ grep 就是完整的 debug 工具鏈。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Log 設計是功能規格的一部分&lt;/strong>。「連線到 ttyd 終端機」這個功能的規格不只是「建立 WS 連線」，還包含「每步有 log、失敗有 log、成功有 log」。跟 API 規格需要定義 request/response 一樣，連線功能需要定義 log 點。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>功能規格階段列出 log 點清單&lt;/strong>：每個功能的規格文件新增「可觀測性」欄位，列出啟動/步驟/錯誤/完成四類 log 點。&lt;/li>
&lt;li>&lt;strong>建立統一 log 層&lt;/strong>：封裝 &lt;code>developer.log&lt;/code> 為 &lt;code>AppLogger&lt;/code>，統一 name、level、格式。開發期用 &lt;code>developer.log&lt;/code>，後續可切換到 HTTP log endpoint。&lt;/li>
&lt;li>&lt;strong>自架 log endpoint 方案&lt;/strong>：本機 Go server 開一個 &lt;code>/log&lt;/code> POST endpoint，接收 JSON log，寫入檔案。Client 端 &lt;code>AppLogger&lt;/code> 在 debug mode 同時寫 console + POST 到 endpoint。開發期 grep 查詢，不需要 dashboard。&lt;/li>
&lt;li>&lt;strong>Protocol log 獨立一層&lt;/strong>：WebSocket frame type、payload 前綴、auth handshake 結果獨立記錄，跟 business log 分開。這層 log 在 release mode 應該能關閉。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計客戶端 log 方案 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">模組二：客戶端可觀測性&lt;/a>&lt;/li>
&lt;li>想理解三層 log 設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>想建自架 log endpoint → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &amp;#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「客戶端 log 設計」為什麼應該在功能企劃階段完成，而不是 debug 時才補。Log 不是 debug 工具，是可觀測性基礎設施。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的六個核心元件在實機測試前的 log 覆蓋狀態：</p>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>log 點數</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ConnectionManager</td>
          <td>0 → 10</td>
          <td>W2 修復後補的 <code>developer.log</code></td>
      </tr>
      <tr>
          <td>TerminalScreen</td>
          <td>0 → 5</td>
          <td>W2 修復後補的</td>
      </tr>
      <tr>
          <td>TtydProtocol</td>
          <td>0</td>
          <td>encode/decode/buildAuth 無 log</td>
      </tr>
      <tr>
          <td>BiometricService</td>
          <td>0</td>
          <td>isAvailable/authenticate 結果無 log</td>
      </tr>
      <tr>
          <td>CredentialRepository</td>
          <td>0</td>
          <td>load/save/delete 操作無 log</td>
      </tr>
      <tr>
          <td>EnrollmentScreen</td>
          <td>0</td>
          <td>QR 掃描/解析/儲存無 log</td>
      </tr>
  </tbody>
</table>
<p>W2-004（P0：iOS 實機 WS stream 不觸發）的 debug 過程：無法從任何 log 判斷問題發生在 biometric → credential → WS connect → auth token → stream listen 的哪一步。開發者被迫在每個函式手動加 <code>developer.log</code>，重新編譯，插拔裝置測試，反覆數次才定位到「stream 訂閱時機」問題。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>debug 成本</td>
          <td>每次修改→編譯→部署→測試約 3-5 分鐘</td>
      </tr>
      <tr>
          <td>定位 W2-002 (auth token) 花費</td>
          <td>約 30 分鐘反覆測試</td>
      </tr>
      <tr>
          <td>若有連線生命週期 log</td>
          <td>第一次連線就能看到「Step 3 之後無 auth token 發送」</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Log 缺失把 debug 成本從秒級升到分鐘級</strong>。如果 ConnectionManager 在企劃階段就設計了「Step 1: biometric → Step 2: credential → Step 3: WS connect → Step 4: auth token → Step 5: listen stream」五步 log，W2-002 的 auth token 問題在第一次連線就能從 log 看到「Step 3 完成，Step 4 未執行」。</p>
</li>
<li>
<p><strong>「事後補 log」的 log 品質較低</strong>。W2 修復時補的 <code>developer.log</code> 格式不統一（有的帶 <code>name:</code>，有的不帶；有的用 <code>// i18n-exempt</code> 標記，有的忘了），沒有統一的 log 層級，沒有結構化欄位。事後補的 log 是救火工具，不是可觀測性設計。</p>
</li>
<li>
<p><strong>自用工具最適合自架 log 收集</strong>。app_tunnel 的 server 和 client 都在同一台機器上（或同一個 Tailscale tailnet），client 可以直接打 HTTP POST 到本機的 log endpoint，不需要 Sentry 或 Crashlytics。一個 Go 寫的 JSON log receiver（20 行）+ grep 就是完整的 debug 工具鏈。</p>
</li>
<li>
<p><strong>Log 設計是功能規格的一部分</strong>。「連線到 ttyd 終端機」這個功能的規格不只是「建立 WS 連線」，還包含「每步有 log、失敗有 log、成功有 log」。跟 API 規格需要定義 request/response 一樣，連線功能需要定義 log 點。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>功能規格階段列出 log 點清單</strong>：每個功能的規格文件新增「可觀測性」欄位，列出啟動/步驟/錯誤/完成四類 log 點。</li>
<li><strong>建立統一 log 層</strong>：封裝 <code>developer.log</code> 為 <code>AppLogger</code>，統一 name、level、格式。開發期用 <code>developer.log</code>，後續可切換到 HTTP log endpoint。</li>
<li><strong>自架 log endpoint 方案</strong>：本機 Go server 開一個 <code>/log</code> POST endpoint，接收 JSON log，寫入檔案。Client 端 <code>AppLogger</code> 在 debug mode 同時寫 console + POST 到 endpoint。開發期 grep 查詢，不需要 dashboard。</li>
<li><strong>Protocol log 獨立一層</strong>：WebSocket frame type、payload 前綴、auth handshake 結果獨立記錄，跟 business log 分開。這層 log 在 release mode 應該能關閉。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計客戶端 log 方案 → <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">模組二：客戶端可觀測性</a></li>
<li>想理解三層 log 設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>想建自架 log endpoint → <a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a></li>
</ul>
]]></content:encoded></item><item><title>判斷原則：什麼時候需要 protocol integration test</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/when-protocol-integration-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/when-protocol-integration-test/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">Protocol integration test&lt;/a> 有成本 — 需要真實服務實例、環境準備、執行速度較慢、結果可能因環境差異而不穩定。判斷是否需要這一層測試，依據的是服務架構的特徵，而非主觀的「寫多一點比較安心」。&lt;/p>
&lt;h2 id="三個判斷維度">三個判斷維度&lt;/h2>
&lt;h3 id="維度一協議複雜度">維度一：協議複雜度&lt;/h3>
&lt;p>程式碼和外部服務之間的協議是否存在 API 層無法描述的語意？&lt;/p>
&lt;p>HTTP REST API 的協議複雜度相對低：request body 是 JSON、response body 是 JSON、status code 有明確語意。Mock 一個 REST endpoint（回傳固定 JSON）和真實 endpoint 的行為差異主要在效能和邊界案例，核心語意差距小。&lt;/p>
&lt;p>WebSocket 協議的複雜度較高：連線握手、frame type（text / binary / ping / pong / close）、分片（fragmentation）、壓縮擴展（permessage-deflate）、子協議協商 — 這些語意在 API 層（&lt;code>sink.add(dynamic)&lt;/code>）是不可見的。gRPC 的 streaming、deadline propagation、metadata header 也有類似特徵。&lt;/p>
&lt;p>判斷問題：&lt;strong>API 簽名是否隱藏了協議層的行為分支？&lt;/strong> 如果 API 用 &lt;code>dynamic&lt;/code>、&lt;code>Object&lt;/code>、&lt;code>Any&lt;/code> 等寬泛型別接受輸入，而協議層對不同輸入有不同處理方式，這就是需要 protocol integration test 的訊號。&lt;/p>
&lt;p>app_tunnel 的 &lt;code>sink.add(dynamic)&lt;/code> 就是這個模式 — API 簽名不區分 &lt;code>String&lt;/code> 和 &lt;code>Uint8List&lt;/code>，但協議層對兩者產生不同的 frame type（&lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1&lt;/a>）。&lt;/p>
&lt;h3 id="維度二mock-寬鬆度">維度二：Mock 寬鬆度&lt;/h3>
&lt;p>Mock 的行為是否比真實服務更寬容？&lt;/p>
&lt;p>Mock 通常是「最小可用」的實作 — 能讓 test 通過就好。這意味著 mock 的行為往往比真實服務寬鬆：不檢查認證、不限制速率、不要求特定順序、不區分輸入格式。&lt;/p>
&lt;p>寬鬆本身不是問題，但寬鬆程度和真實服務的差距決定了 mock 遮蔽的風險大小。判斷問題：&lt;strong>Mock 跳過了真實服務的哪些步驟？每個被跳過的步驟在業務上是否關鍵？&lt;/strong>&lt;/p>
&lt;p>app_tunnel 的 &lt;code>FakeWebSocketChannel&lt;/code> 跳過了 auth handshake — &lt;code>ready&lt;/code> 立即完成不需認證。Auth handshake 在業務上是關鍵步驟（沒有認證，ttyd 不推送資料），mock 跳過這一步讓「功能根本沒實作」變得不可見（&lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2&lt;/a>）。&lt;/p>
&lt;p>逐項列出 mock 跳過的步驟是一個實用的 audit 方法。寫出「&lt;code>FakeWebSocketChannel&lt;/code> 和 &lt;code>IOWebSocketChannel&lt;/code> 的行為差異清單」，每一個差異點就是潛在的遮蔽風險。&lt;/p>
&lt;h3 id="維度三失敗靜默度">維度三：失敗靜默度&lt;/h3>
&lt;p>外部服務收到非預期輸入時，回應是明確的錯誤還是靜默忽略？&lt;/p>
&lt;p>如果外部服務對錯誤輸入回傳 HTTP 400 或斷線，問題在實機測試時會快速浮現 — 程式碼進入 error 狀態，開發者看到明確的錯誤訊息。但如果外部服務靜默忽略，問題表現為「連線成功但沒有回應」，debug 方向可能完全錯誤。&lt;/p>
&lt;p>ttyd 收到 binary frame 時靜默忽略，不回傳錯誤碼也不斷線。這讓問題的表現從「frame type 錯誤」變成「終端機無回應」，開發者的 debug 方向是「為什麼 terminal 沒反應」而非「為什麼 frame type 不對」。&lt;/p>
&lt;p>判斷問題：&lt;strong>外部服務是否有靜默忽略的行為？&lt;/strong> 如果有，protocol integration test 的價值更高 — 因為即使在實機測試階段，靜默忽略也會增加 debug 成本。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">Protocol integration test</a> 有成本 — 需要真實服務實例、環境準備、執行速度較慢、結果可能因環境差異而不穩定。判斷是否需要這一層測試，依據的是服務架構的特徵，而非主觀的「寫多一點比較安心」。</p>
<h2 id="三個判斷維度">三個判斷維度</h2>
<h3 id="維度一協議複雜度">維度一：協議複雜度</h3>
<p>程式碼和外部服務之間的協議是否存在 API 層無法描述的語意？</p>
<p>HTTP REST API 的協議複雜度相對低：request body 是 JSON、response body 是 JSON、status code 有明確語意。Mock 一個 REST endpoint（回傳固定 JSON）和真實 endpoint 的行為差異主要在效能和邊界案例，核心語意差距小。</p>
<p>WebSocket 協議的複雜度較高：連線握手、frame type（text / binary / ping / pong / close）、分片（fragmentation）、壓縮擴展（permessage-deflate）、子協議協商 — 這些語意在 API 層（<code>sink.add(dynamic)</code>）是不可見的。gRPC 的 streaming、deadline propagation、metadata header 也有類似特徵。</p>
<p>判斷問題：<strong>API 簽名是否隱藏了協議層的行為分支？</strong> 如果 API 用 <code>dynamic</code>、<code>Object</code>、<code>Any</code> 等寬泛型別接受輸入，而協議層對不同輸入有不同處理方式，這就是需要 protocol integration test 的訊號。</p>
<p>app_tunnel 的 <code>sink.add(dynamic)</code> 就是這個模式 — API 簽名不區分 <code>String</code> 和 <code>Uint8List</code>，但協議層對兩者產生不同的 frame type（<a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1</a>）。</p>
<h3 id="維度二mock-寬鬆度">維度二：Mock 寬鬆度</h3>
<p>Mock 的行為是否比真實服務更寬容？</p>
<p>Mock 通常是「最小可用」的實作 — 能讓 test 通過就好。這意味著 mock 的行為往往比真實服務寬鬆：不檢查認證、不限制速率、不要求特定順序、不區分輸入格式。</p>
<p>寬鬆本身不是問題，但寬鬆程度和真實服務的差距決定了 mock 遮蔽的風險大小。判斷問題：<strong>Mock 跳過了真實服務的哪些步驟？每個被跳過的步驟在業務上是否關鍵？</strong></p>
<p>app_tunnel 的 <code>FakeWebSocketChannel</code> 跳過了 auth handshake — <code>ready</code> 立即完成不需認證。Auth handshake 在業務上是關鍵步驟（沒有認證，ttyd 不推送資料），mock 跳過這一步讓「功能根本沒實作」變得不可見（<a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2</a>）。</p>
<p>逐項列出 mock 跳過的步驟是一個實用的 audit 方法。寫出「<code>FakeWebSocketChannel</code> 和 <code>IOWebSocketChannel</code> 的行為差異清單」，每一個差異點就是潛在的遮蔽風險。</p>
<h3 id="維度三失敗靜默度">維度三：失敗靜默度</h3>
<p>外部服務收到非預期輸入時，回應是明確的錯誤還是靜默忽略？</p>
<p>如果外部服務對錯誤輸入回傳 HTTP 400 或斷線，問題在實機測試時會快速浮現 — 程式碼進入 error 狀態，開發者看到明確的錯誤訊息。但如果外部服務靜默忽略，問題表現為「連線成功但沒有回應」，debug 方向可能完全錯誤。</p>
<p>ttyd 收到 binary frame 時靜默忽略，不回傳錯誤碼也不斷線。這讓問題的表現從「frame type 錯誤」變成「終端機無回應」，開發者的 debug 方向是「為什麼 terminal 沒反應」而非「為什麼 frame type 不對」。</p>
<p>判斷問題：<strong>外部服務是否有靜默忽略的行為？</strong> 如果有，protocol integration test 的價值更高 — 因為即使在實機測試階段，靜默忽略也會增加 debug 成本。</p>
<h2 id="決策流程">決策流程</h2>
<p>以下流程不追求完備覆蓋所有情境，而是提供一個起點，根據上述三個維度的組合判斷 protocol integration test 的必要性。</p>
<p><strong>協議複雜度高（API 層和協議層有語意斷裂）：</strong> 需要 protocol integration test。即使 mock 寬鬆度低、失敗回報明確，語意斷裂本身就是 mock 結構性無法覆蓋的盲區。</p>
<p><strong>協議複雜度低，但 mock 寬鬆度高（mock 跳過業務關鍵步驟）：</strong> 需要 protocol integration test。Mock 跳過的步驟越多，「功能缺失不可見」的風險越大。</p>
<p><strong>協議複雜度低，mock 寬鬆度低：</strong> 依失敗靜默度判斷。如果外部服務靜默忽略錯誤，protocol integration test 有較高價值；如果錯誤回報明確，可以依賴實機測試階段的 error 來發現問題。</p>
<p><strong>成本極低的情境：</strong> 當外部服務可以在 test 環境輕鬆啟動時（自用工具 server+client 同機、Docker 一行啟動的 open source service），protocol integration test 的成本門檻大幅降低，三個維度中任何一個有疑慮就值得寫。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想實作 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>理解 mock 遮蔽的結構性原因 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>反模式：試圖用更多 mock test 補救 → <a href="/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/" data-link-title="反模式：用 mock 數量彌補 mock 盲區" data-link-desc="為什麼增加 mock test 數量無法跨越 mock 的結構性盲區 — 從 192 個 test 全過的案例拆解數量與覆蓋率的真正關係">反模式：用 mock 數量彌補 mock 盲區</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：自動化 UI 驗證</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/</guid><description>&lt;p>回答「畫面上的東西是否如設計工作」。狀態矩陣直接轉成 test case。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Widget test 的狀態覆蓋策略（從狀態矩陣推導 test case）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 導航路徑 test（back 按鈕、route 可達性）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Playwright 瀏覽器驗證流程&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 螢幕截圖比對（visual regression）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&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>：狀態矩陣是 test case 的 SOT&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式&lt;/a>：go vs push 語意影響 test 斷言&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「畫面上的東西是否如設計工作」。狀態矩陣直接轉成 test case。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Widget test 的狀態覆蓋策略（從狀態矩陣推導 test case）</li>
<li><input checked="" disabled="" type="checkbox"> 導航路徑 test（back 按鈕、route 可達性）</li>
<li><input checked="" disabled="" type="checkbox"> Playwright 瀏覽器驗證流程</li>
<li><input checked="" disabled="" type="checkbox"> 螢幕截圖比對（visual regression）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a>：狀態矩陣是 test case 的 SOT</li>
<li>← <a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式</a>：go vs push 語意影響 test 斷言</li>
</ul>
]]></content:encoded></item><item><title>螢幕截圖比對</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/</guid><description>&lt;p>螢幕截圖比對（visual regression testing）用基準截圖（baseline）和當前截圖的像素差異來偵測非預期的視覺變化。這一層驗證的是「畫面看起來是否和上次一樣」，捕捉 CSS 變更、layout 偏移、字體替換等邏輯 test 無法發現的視覺問題。&lt;/p>
&lt;h2 id="運作方式">運作方式&lt;/h2>
&lt;h3 id="建立-baseline">建立 baseline&lt;/h3>
&lt;p>第一次執行時擷取每個測試畫面的螢幕截圖，儲存為 baseline。Baseline 代表「目前正確的視覺狀態」。&lt;/p>
&lt;h3 id="比對差異">比對差異&lt;/h3>
&lt;p>後續執行時擷取當前截圖，和 baseline 逐像素比對。差異超過閾值時 test 失敗，產出 diff 圖片標示差異區域。&lt;/p>
&lt;h3 id="更新-baseline">更新 baseline&lt;/h3>
&lt;p>視覺變更是刻意的（新設計、改佈局）時，用新截圖覆蓋 baseline。更新 baseline 是明確的決策 — 代表「新的視覺狀態是正確的」。&lt;/p>
&lt;h2 id="playwright-的截圖比對">Playwright 的截圖比對&lt;/h2>
&lt;p>Playwright 內建 &lt;code>toHaveScreenshot()&lt;/code> 方法：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal screen matches baseline&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&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="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;http://localhost:8080/terminal&amp;#39;&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="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForSelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;terminal-screen&amp;#34;]&amp;#39;&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="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal-connected.png&amp;#39;&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">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">maxDiffPixelRatio&lt;/span>: &lt;span class="kt">0.01&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 允許 1% 像素差異
&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="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="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>首次執行時自動建立 baseline 截圖，後續執行時自動比對。Diff 圖片儲存在 test results 目錄。&lt;/p>
&lt;h2 id="diff-閾值設定">Diff 閾值設定&lt;/h2>
&lt;p>像素比對的閾值影響 test 的敏感度：&lt;/p>
&lt;p>&lt;strong>過低（0.001）&lt;/strong>：anti-aliasing 差異、字體渲染微調、次像素定位變化都會觸發失敗。Test 頻繁因無關變化而失敗（flaky）。&lt;/p>
&lt;p>&lt;strong>過高（0.1）&lt;/strong>：小型 layout 偏移、顏色微調、邊框消失可能不被偵測。Test 的保護力下降。&lt;/p>
&lt;p>&lt;strong>建議起點（0.01）&lt;/strong>：允許 1% 的像素差異。能容忍 anti-aliasing 差異，同時捕捉有意義的視覺變化。根據實際 flaky 頻率調整。&lt;/p>
&lt;h2 id="baseline-管理">Baseline 管理&lt;/h2>
&lt;h3 id="版本控制">版本控制&lt;/h3>
&lt;p>Baseline 截圖加入 git。每次視覺變更的 PR 包含 baseline 更新，reviewer 從 diff 中看到「視覺變化了什麼」。&lt;/p>
&lt;p>Baseline 檔案較大（PNG，數十 KB 到數百 KB）。Git LFS 適合管理這類二進位檔案。&lt;/p>
&lt;h3 id="跨平台差異">跨平台差異&lt;/h3>
&lt;p>不同作業系統的字體渲染、anti-aliasing 演算法不同。同一段 HTML 在 macOS 和 Linux 上的截圖會有微小差異。&lt;/p>
&lt;p>處理策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>一個平台一套 baseline&lt;/strong>：macOS 和 Linux 各自維護 baseline。CI 環境固定在一個平台。&lt;/li>
&lt;li>&lt;strong>只在 CI 比對&lt;/strong>：本地開發不跑截圖比對（平台差異導致 flaky），CI 環境固定平台後比對。&lt;/li>
&lt;/ul>
&lt;h3 id="動態內容">動態內容&lt;/h3>
&lt;p>畫面中有動態內容（時間戳、隨機 ID、動畫）時，截圖每次都不同。&lt;/p>
&lt;p>處理策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>遮蔽動態區域&lt;/strong>：截圖前用 CSS 隱藏動態元素，或在截圖比對時指定忽略區域。&lt;/li>
&lt;li>&lt;strong>固定動態值&lt;/strong>：test 中 mock 時間和隨機數，讓畫面內容確定。&lt;/li>
&lt;li>&lt;strong>只截靜態區域&lt;/strong>：用 element screenshot（&lt;code>locator.screenshot()&lt;/code>）而非 full page screenshot，只截不含動態內容的區域。&lt;/li>
&lt;/ul>
&lt;h2 id="和其他-test-層的關係">和其他 test 層的關係&lt;/h2>
&lt;p>截圖比對是 UI test 的最外層 — 驗證視覺呈現而非邏輯行為。它和 widget test（驗證 widget 結構）、導航 test（驗證路由行為）互補：&lt;/p>
&lt;p>widget test 通過但截圖比對失敗 = 邏輯正確但視覺不對（CSS bug）。截圖比對通過但 widget test 失敗 = 視覺沒變但邏輯壞了（功能 bug 還沒影響到視覺）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>狀態覆蓋策略 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略&lt;/a>&lt;/li>
&lt;li>Playwright 驗證流程 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程&lt;/a>&lt;/li>
&lt;li>畫面狀態矩陣 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>螢幕截圖比對（visual regression testing）用基準截圖（baseline）和當前截圖的像素差異來偵測非預期的視覺變化。這一層驗證的是「畫面看起來是否和上次一樣」，捕捉 CSS 變更、layout 偏移、字體替換等邏輯 test 無法發現的視覺問題。</p>
<h2 id="運作方式">運作方式</h2>
<h3 id="建立-baseline">建立 baseline</h3>
<p>第一次執行時擷取每個測試畫面的螢幕截圖，儲存為 baseline。Baseline 代表「目前正確的視覺狀態」。</p>
<h3 id="比對差異">比對差異</h3>
<p>後續執行時擷取當前截圖，和 baseline 逐像素比對。差異超過閾值時 test 失敗，產出 diff 圖片標示差異區域。</p>
<h3 id="更新-baseline">更新 baseline</h3>
<p>視覺變更是刻意的（新設計、改佈局）時，用新截圖覆蓋 baseline。更新 baseline 是明確的決策 — 代表「新的視覺狀態是正確的」。</p>
<h2 id="playwright-的截圖比對">Playwright 的截圖比對</h2>
<p>Playwright 內建 <code>toHaveScreenshot()</code> 方法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;terminal screen matches baseline&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;http://localhost:8080/terminal&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;terminal-screen&#34;]&#39;</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="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">).</span><span class="nx">toHaveScreenshot</span><span class="p">(</span><span class="s1">&#39;terminal-connected.png&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">maxDiffPixelRatio</span>: <span class="kt">0.01</span><span class="p">,</span>  <span class="c1">// 允許 1% 像素差異
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>首次執行時自動建立 baseline 截圖，後續執行時自動比對。Diff 圖片儲存在 test results 目錄。</p>
<h2 id="diff-閾值設定">Diff 閾值設定</h2>
<p>像素比對的閾值影響 test 的敏感度：</p>
<p><strong>過低（0.001）</strong>：anti-aliasing 差異、字體渲染微調、次像素定位變化都會觸發失敗。Test 頻繁因無關變化而失敗（flaky）。</p>
<p><strong>過高（0.1）</strong>：小型 layout 偏移、顏色微調、邊框消失可能不被偵測。Test 的保護力下降。</p>
<p><strong>建議起點（0.01）</strong>：允許 1% 的像素差異。能容忍 anti-aliasing 差異，同時捕捉有意義的視覺變化。根據實際 flaky 頻率調整。</p>
<h2 id="baseline-管理">Baseline 管理</h2>
<h3 id="版本控制">版本控制</h3>
<p>Baseline 截圖加入 git。每次視覺變更的 PR 包含 baseline 更新，reviewer 從 diff 中看到「視覺變化了什麼」。</p>
<p>Baseline 檔案較大（PNG，數十 KB 到數百 KB）。Git LFS 適合管理這類二進位檔案。</p>
<h3 id="跨平台差異">跨平台差異</h3>
<p>不同作業系統的字體渲染、anti-aliasing 演算法不同。同一段 HTML 在 macOS 和 Linux 上的截圖會有微小差異。</p>
<p>處理策略：</p>
<ul>
<li><strong>一個平台一套 baseline</strong>：macOS 和 Linux 各自維護 baseline。CI 環境固定在一個平台。</li>
<li><strong>只在 CI 比對</strong>：本地開發不跑截圖比對（平台差異導致 flaky），CI 環境固定平台後比對。</li>
</ul>
<h3 id="動態內容">動態內容</h3>
<p>畫面中有動態內容（時間戳、隨機 ID、動畫）時，截圖每次都不同。</p>
<p>處理策略：</p>
<ul>
<li><strong>遮蔽動態區域</strong>：截圖前用 CSS 隱藏動態元素，或在截圖比對時指定忽略區域。</li>
<li><strong>固定動態值</strong>：test 中 mock 時間和隨機數，讓畫面內容確定。</li>
<li><strong>只截靜態區域</strong>：用 element screenshot（<code>locator.screenshot()</code>）而非 full page screenshot，只截不含動態內容的區域。</li>
</ul>
<h2 id="和其他-test-層的關係">和其他 test 層的關係</h2>
<p>截圖比對是 UI test 的最外層 — 驗證視覺呈現而非邏輯行為。它和 widget test（驗證 widget 結構）、導航 test（驗證路由行為）互補：</p>
<p>widget test 通過但截圖比對失敗 = 邏輯正確但視覺不對（CSS bug）。截圖比對通過但 widget test 失敗 = 視覺沒變但邏輯壞了（功能 bug 還沒影響到視覺）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>Playwright 驗證流程 → <a href="/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程</a></li>
<li>畫面狀態矩陣 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣</a></li>
</ul>
]]></content:encoded></item><item><title>5.4 HTTP handler 測試</title><link>https://tarrragon.github.io/blog/go/05-error-testing/http-handler-test/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/http-handler-test/</guid><description>&lt;p>HTTP handler 測試的核心規則是不用啟動真實 server，也能驗證 request 進入 handler 後產生的 response。&lt;code>net/http/httptest&lt;/code> 提供 request builder 與 response recorder，讓 handler 可以像普通函式一樣被測試。&lt;/p>
&lt;h2 id="httptest-把-http-測試變成函式呼叫">&lt;code>httptest&lt;/code> 把 HTTP 測試變成函式呼叫&lt;/h2>
&lt;p>&lt;code>httptest&lt;/code> 的核心用途是建立測試用 request 與 response writer。handler 本來就是 &lt;code>func(http.ResponseWriter, *http.Request)&lt;/code>，所以測試可以直接呼叫 handler。&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">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ResponseWriter&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">*&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Request&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="k">if&lt;/span> &lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Method&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&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">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Error&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;method not allowed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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="k">return&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">w&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Header&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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="nx">fmt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Fprint&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">w&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">`{&amp;#34;status&amp;#34;:&amp;#34;ok&amp;#34;}`&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;/code>&lt;/pre>&lt;/div>&lt;p>這個 handler 可以不用啟動 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">TestHandleHealth&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">req&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">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/health&amp;#34;&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&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">NewRecorder&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="nf">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">res&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Result&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">defer&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&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"> 9&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCode&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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="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;status = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">res&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCode&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusOK&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;code>httptest.NewRequest&lt;/code> 建立 request，&lt;code>httptest.NewRecorder&lt;/code> 記錄 response。測試直接呼叫 &lt;code>handleHealth(rec, req)&lt;/code>，再檢查 recorder 產生的結果。&lt;/p>
&lt;h2 id="status-code-是第一個行為合約">status code 是第一個行為合約&lt;/h2>
&lt;p>HTTP response 的核心合約通常先看 status code。成功、輸入錯誤、方法不允許與伺服器錯誤，都應該有明確狀態碼。&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">TestHandleHealthMethodNotAllowed&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">req&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">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/health&amp;#34;&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&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">NewRecorder&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="nf">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&lt;/span>&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>&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="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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="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;status = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusMethodNotAllowed&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;code>rec.Code&lt;/code> 可以直接取得 handler 寫出的狀態碼。若 handler 沒有呼叫 &lt;code>WriteHeader&lt;/code>，但有寫 body，狀態碼通常會是 &lt;code>200&lt;/code>。&lt;/p>
&lt;p>測試狀態碼時，不要只檢查 body 字串。body 可能改文案，但 status code 才是呼叫端最依賴的協定訊號。&lt;/p>
&lt;h2 id="body-檢查要符合輸出格式">body 檢查要符合輸出格式&lt;/h2>
&lt;p>response body 的核心檢查方式應該配合輸出格式。純文字可以比對字串；JSON 應該解析成 struct 或 map 後再比對欄位。&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">TestHandleHealthBody&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">req&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">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodGet&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/health&amp;#34;&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"> 3&lt;/span>&lt;span class="cl"> &lt;span class="nx">rec&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">NewRecorder&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="nf">handleHealth&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">body&lt;/span> &lt;span class="kd">struct&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="nx">Status&lt;/span> &lt;span class="kt">string&lt;/span> &lt;span class="s">`json:&amp;#34;status&amp;#34;`&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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="nx">json&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewDecoder&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Body&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nf">Decode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">&amp;amp;&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">);&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">12&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;decode response body: %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">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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;ok&amp;#34;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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;status field = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">body&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Status&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ok&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>解析 JSON 後檢查欄位，比直接比對 &lt;code>{&amp;quot;status&amp;quot;:&amp;quot;ok&amp;quot;}&lt;/code> 更穩定。JSON 欄位順序、空白與換行不應該讓測試失敗。&lt;/p>
&lt;h2 id="request-body-可以用-stringsnewreader">request body 可以用 &lt;code>strings.NewReader&lt;/code>&lt;/h2>
&lt;p>測試 JSON request 的核心做法是把字串或 bytes 包成 reader。handler 看到的是 &lt;code>io.Reader&lt;/code>，不需要知道資料來自檔案、網路或測試字串。&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">TestHandleCreateUser&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">body&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">NewReader&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">`{&amp;#34;name&amp;#34;:&amp;#34;Alice&amp;#34;,&amp;#34;email&amp;#34;:&amp;#34;alice@example.com&amp;#34;}`&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">req&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">NewRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">MethodPost&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;/users&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">body&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="nx">req&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Header&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Set&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Content-Type&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;application/json&amp;#34;&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="nx">rec&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">NewRecorder&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="nx">handler&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">newCreateUserHandler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fakeUserCreator&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">handler&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">ServeHTTP&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">rec&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">req&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&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">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;status = %d, want %d&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">rec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Code&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">http&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">StatusCreated&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>strings.NewReader&lt;/code> 讓測試資料留在測試檔中，適合小型 JSON。若 request 很大或要重複使用，可以把測試資料放在 &lt;code>testdata&lt;/code> 目錄。&lt;/p></description><content:encoded><![CDATA[<p>HTTP handler 測試的核心規則是不用啟動真實 server，也能驗證 request 進入 handler 後產生的 response。<code>net/http/httptest</code> 提供 request builder 與 response recorder，讓 handler 可以像普通函式一樣被測試。</p>
<h2 id="httptest-把-http-測試變成函式呼叫"><code>httptest</code> 把 HTTP 測試變成函式呼叫</h2>
<p><code>httptest</code> 的核心用途是建立測試用 request 與 response writer。handler 本來就是 <code>func(http.ResponseWriter, *http.Request)</code>，所以測試可以直接呼叫 handler。</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">handleHealth</span><span class="p">(</span><span class="nx">w</span> <span class="nx">http</span><span class="p">.</span><span class="nx">ResponseWriter</span><span class="p">,</span> <span class="nx">r</span> <span class="o">*</span><span class="nx">http</span><span class="p">.</span><span class="nx">Request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">r</span><span class="p">.</span><span class="nx">Method</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">http</span><span class="p">.</span><span class="nf">Error</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">&#34;method not allowed&#34;</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="k">return</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></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">w</span><span class="p">.</span><span class="nf">Header</span><span class="p">().</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">fmt</span><span class="p">.</span><span class="nf">Fprint</span><span class="p">(</span><span class="nx">w</span><span class="p">,</span> <span class="s">`{&#34;status&#34;:&#34;ok&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個 handler 可以不用啟動 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">TestHandleHealth</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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">handleHealth</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</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">res</span> <span class="o">:=</span> <span class="nx">rec</span><span class="p">.</span><span class="nf">Result</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">defer</span> <span class="nx">res</span><span class="p">.</span><span class="nx">Body</span><span class="p">.</span><span class="nf">Close</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="k">if</span> <span class="nx">res</span><span class="p">.</span><span class="nx">StatusCode</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</span> <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">Fatalf</span><span class="p">(</span><span class="s">&#34;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">res</span><span class="p">.</span><span class="nx">StatusCode</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusOK</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><code>httptest.NewRequest</code> 建立 request，<code>httptest.NewRecorder</code> 記錄 response。測試直接呼叫 <code>handleHealth(rec, req)</code>，再檢查 recorder 產生的結果。</p>
<h2 id="status-code-是第一個行為合約">status code 是第一個行為合約</h2>
<p>HTTP response 的核心合約通常先看 status code。成功、輸入錯誤、方法不允許與伺服器錯誤，都應該有明確狀態碼。</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">TestHandleHealthMethodNotAllowed</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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">handleHealth</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</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="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusMethodNotAllowed</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><code>rec.Code</code> 可以直接取得 handler 寫出的狀態碼。若 handler 沒有呼叫 <code>WriteHeader</code>，但有寫 body，狀態碼通常會是 <code>200</code>。</p>
<p>測試狀態碼時，不要只檢查 body 字串。body 可能改文案，但 status code 才是呼叫端最依賴的協定訊號。</p>
<h2 id="body-檢查要符合輸出格式">body 檢查要符合輸出格式</h2>
<p>response body 的核心檢查方式應該配合輸出格式。純文字可以比對字串；JSON 應該解析成 struct 或 map 後再比對欄位。</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">TestHandleHealthBody</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodGet</span><span class="p">,</span> <span class="s">&#34;/health&#34;</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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">handleHealth</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</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="kd">var</span> <span class="nx">body</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">Status</span> <span class="kt">string</span> <span class="s">`json:&#34;status&#34;`</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">json</span><span class="p">.</span><span class="nf">NewDecoder</span><span class="p">(</span><span class="nx">rec</span><span class="p">.</span><span class="nx">Body</span><span class="p">).</span><span class="nf">Decode</span><span class="p">(</span><span class="o">&amp;</span><span class="nx">body</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;decode response body: %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="k">if</span> <span class="nx">body</span><span class="p">.</span><span class="nx">Status</span> <span class="o">!=</span> <span class="s">&#34;ok&#34;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</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;status field = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">body</span><span class="p">.</span><span class="nx">Status</span><span class="p">,</span> <span class="s">&#34;ok&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>解析 JSON 後檢查欄位，比直接比對 <code>{&quot;status&quot;:&quot;ok&quot;}</code> 更穩定。JSON 欄位順序、空白與換行不應該讓測試失敗。</p>
<h2 id="request-body-可以用-stringsnewreader">request body 可以用 <code>strings.NewReader</code></h2>
<p>測試 JSON request 的核心做法是把字串或 bytes 包成 reader。handler 看到的是 <code>io.Reader</code>，不需要知道資料來自檔案、網路或測試字串。</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">TestHandleCreateUser</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">body</span> <span class="o">:=</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">`{&#34;name&#34;:&#34;Alice&#34;,&#34;email&#34;:&#34;alice@example.com&#34;}`</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/users&#34;</span><span class="p">,</span> <span class="nx">body</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">req</span><span class="p">.</span><span class="nx">Header</span><span class="p">.</span><span class="nf">Set</span><span class="p">(</span><span class="s">&#34;Content-Type&#34;</span><span class="p">,</span> <span class="s">&#34;application/json&#34;</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="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">handler</span> <span class="o">:=</span> <span class="nf">newCreateUserHandler</span><span class="p">(</span><span class="nx">fakeUserCreator</span><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">handler</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</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="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusCreated</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>strings.NewReader</code> 讓測試資料留在測試檔中，適合小型 JSON。若 request 很大或要重複使用，可以把測試資料放在 <code>testdata</code> 目錄。</p>
<h2 id="依賴應該用-fake-隔離">依賴應該用 fake 隔離</h2>
<p>handler 測試的核心邊界是 HTTP 行為，不是資料庫或外部服務。若 handler 需要呼叫內部服務，可以提供 fake 實作，讓測試專注於 request/response。</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">type</span> <span class="nx">fakeUserCreator</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">id</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">err</span> <span class="kt">error</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><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="kd">func</span> <span class="p">(</span><span class="nx">f</span> <span class="nx">fakeUserCreator</span><span class="p">)</span> <span class="nf">CreateUser</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">name</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">email</span> <span class="kt">string</span><span class="p">)</span> <span class="p">(</span><span class="kt">string</span><span class="p">,</span> <span class="kt">error</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">if</span> <span class="nx">f</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"> 8</span><span class="cl">        <span class="k">return</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">f</span><span class="p">.</span><span class="nx">err</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="k">return</span> <span class="nx">f</span><span class="p">.</span><span class="nx">id</span><span class="p">,</span> <span class="kc">nil</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>成功案例可以讓 fake 回傳 id，失敗案例可以讓 fake 回傳錯誤。這樣測試可以分別驗證 <code>201 Created</code> 與 <code>500 Internal Server Error</code>。</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">TestHandleCreateUserServiceError</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">req</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRequest</span><span class="p">(</span><span class="nx">http</span><span class="p">.</span><span class="nx">MethodPost</span><span class="p">,</span> <span class="s">&#34;/users&#34;</span><span class="p">,</span> <span class="nx">strings</span><span class="p">.</span><span class="nf">NewReader</span><span class="p">(</span><span class="s">`{&#34;name&#34;:&#34;Alice&#34;,&#34;email&#34;:&#34;alice@example.com&#34;}`</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">rec</span> <span class="o">:=</span> <span class="nx">httptest</span><span class="p">.</span><span class="nf">NewRecorder</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">handler</span> <span class="o">:=</span> <span class="nf">newCreateUserHandler</span><span class="p">(</span><span class="nx">fakeUserCreator</span><span class="p">{</span><span class="nx">err</span><span class="p">:</span> <span class="nx">errors</span><span class="p">.</span><span class="nf">New</span><span class="p">(</span><span class="s">&#34;database unavailable&#34;</span><span class="p">)})</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">handler</span><span class="p">.</span><span class="nf">ServeHTTP</span><span class="p">(</span><span class="nx">rec</span><span class="p">,</span> <span class="nx">req</span><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="k">if</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span> <span class="o">!=</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</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;status = %d, want %d&#34;</span><span class="p">,</span> <span class="nx">rec</span><span class="p">.</span><span class="nx">Code</span><span class="p">,</span> <span class="nx">http</span><span class="p">.</span><span class="nx">StatusInternalServerError</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="p">}</span></span></span></code></pre></div><p>這是 handler 單元測試。資料庫連線、<a href="/blog/backend/knowledge-cards/migration/" data-link-title="Migration" data-link-desc="說明系統如何把資料、流量或結構從舊狀態移到新狀態">migration</a>、真實網路等行為應該放在更高層級的整合測試處理。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會處理時間注入，說明如何避免測試依賴真實現在時間。</p>
]]></content:encoded></item><item><title>5.4 table-driven test 的設計邊界</title><link>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/table-tests/</guid><description>&lt;p>Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例，但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>判斷什麼行為適合 table-driven test&lt;/li>
&lt;li>設計欄位少、意圖清楚的測試表&lt;/li>
&lt;li>發現 table test 膨脹成迷你框架的訊號&lt;/li>
&lt;li>拆分 validation、repository error、integration flow&lt;/li>
&lt;li>寫出能定位失敗情境的子測試名稱&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察table-driven-test-很容易被濫用">【觀察】table-driven test 很容易被濫用&lt;/h2>
&lt;p>Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> client、expected &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log&lt;/a>、expected event，測試就會變成難懂的迷你框架。&lt;/p>
&lt;p>失控表格示意：&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="nx">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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">name&lt;/span> &lt;span class="kt">string&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">method&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">body&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupRepo&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">setupClient&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">queueFull&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantStatus&lt;/span> &lt;span class="kt">int&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">wantMessage&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantEvent&lt;/span> &lt;span class="kt">bool&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">wantLog&lt;/span> &lt;span class="kt">bool&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="c1">// many unrelated cases&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>這種表格看似統一，實際上混合了 HTTP validation、repository error、client &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> full、event emission、log assertion。讀者必須同時理解多個系統層，才能看懂單一案例。&lt;/p>
&lt;h2 id="判讀好表格描述同一個行為維度">【判讀】好表格描述同一個行為維度&lt;/h2>
&lt;p>好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料，不改變測試流程。&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">TestNormalizeTopic&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">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;trim spaces&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; alerts &amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&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="p">{&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;lowercase&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;ALERTS&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&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 class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">input&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&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="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&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">14&lt;/span>&lt;span class="cl"> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NormalizeTopic&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&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;NormalizeTopic(%q) = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">want&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&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;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic&lt;/a> normalization。每個案例都只有 input 和 want，失敗時也能立刻看出是哪個 normalization 規則壞了。&lt;/p>
&lt;h2 id="策略表格欄位越多越要懷疑測試邊界">【策略】表格欄位越多，越要懷疑測試邊界&lt;/h2>
&lt;p>Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。&lt;/p>
&lt;p>拆分判斷：&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>很多 &lt;code>setupX bool&lt;/code>&lt;/td>
 &lt;td>setup 不一致&lt;/td>
 &lt;td>拆成不同測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>很多 &lt;code>wantX bool&lt;/code>&lt;/td>
 &lt;td>斷言目標不一致&lt;/td>
 &lt;td>拆成不同測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>loop 內大量 &lt;code>if tt...&lt;/code>&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>helper 隱藏主要斷言&lt;/td>
 &lt;td>可讀性下降&lt;/td>
 &lt;td>讓斷言留在測試本文&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格不是越通用越好。測試的第一責任是讓失敗可定位，不是消除所有重複。&lt;/p>
&lt;h2 id="執行validation-適合-table-test">【執行】validation 適合 table test&lt;/h2>
&lt;p>Validation 的核心特徵是輸入和輸出形狀一致，因此很適合 table-driven test。&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">TestValidateSubscribeRequest&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">tests&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kd">struct&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">name&lt;/span> &lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nx">request&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span> &lt;span class="kt">bool&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &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="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="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;valid topic&amp;#34;&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">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;alerts&amp;#34;&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="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">false&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="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="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;empty topic&amp;#34;&lt;/span>&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="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34;blank topic&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="nx">request&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">SubscribeTopicRequest&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">Topic&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s">&amp;#34; &amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl"> &lt;span class="nx">wantErr&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">tt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">tests&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="nx">t&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Run&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">name&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kd">func&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">26&lt;/span>&lt;span class="cl"> &lt;span class="nx">err&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">ValidateSubscribeRequest&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">request&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&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 class="o">!=&lt;/span> &lt;span class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&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;error = %v, wantErr %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 class="nx">tt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">wantErr&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這張表只問一件事：request 是否有效。它不測 WebSocket connection、不測 hub、不測 repository，因此案例可以保持簡潔。&lt;/p></description><content:encoded><![CDATA[<p>Table-driven test 的核心邊界是每張表只描述一個行為維度。它能降低重複並清楚列出案例，但不適合把多種 setup、多種執行方式與多種斷言硬塞進同一個測試。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>判斷什麼行為適合 table-driven test</li>
<li>設計欄位少、意圖清楚的測試表</li>
<li>發現 table test 膨脹成迷你框架的訊號</li>
<li>拆分 validation、repository error、integration flow</li>
<li>寫出能定位失敗情境的子測試名稱</li>
</ol>
<hr>
<h2 id="觀察table-driven-test-很容易被濫用">【觀察】table-driven test 很容易被濫用</h2>
<p>Table-driven test 的核心風險是「減少重複」被誤解成「所有案例都塞進一張表」。當表格開始同時控制 HTTP method、request body、repository 狀態、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> client、expected <a href="/blog/backend/knowledge-cards/log/" data-link-title="Log" data-link-desc="說明 log 如何記錄單一事件的上下文並支援事故排查">log</a>、expected event，測試就會變成難懂的迷你框架。</p>
<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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">name</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">method</span>        <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">body</span>          <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">setupRepo</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">setupClient</span>   <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">queueFull</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">wantStatus</span>    <span class="kt">int</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">wantMessage</span>   <span class="kt">string</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nx">wantEvent</span>     <span class="kt">bool</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">wantLog</span>       <span class="kt">bool</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="c1">// many unrelated cases</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種表格看似統一，實際上混合了 HTTP validation、repository error、client <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> full、event emission、log assertion。讀者必須同時理解多個系統層，才能看懂單一案例。</p>
<h2 id="判讀好表格描述同一個行為維度">【判讀】好表格描述同一個行為維度</h2>
<p>好的 table-driven test 的核心特徵是所有案例共享相同 setup、相同執行方式、相同斷言方式。表格只改變資料，不改變測試流程。</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">TestNormalizeTopic</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">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>  <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">input</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">want</span>  <span class="kt">string</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 class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;trim spaces&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34; alerts &#34;</span><span class="p">,</span> <span class="nx">want</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 class="nx">name</span><span class="p">:</span> <span class="s">&#34;lowercase&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;ALERTS&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;alerts&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;empty&#34;</span><span class="p">,</span> <span class="nx">input</span><span class="p">:</span> <span class="s">&#34;&#34;</span><span class="p">,</span> <span class="nx">want</span><span class="p">:</span> <span class="s">&#34;&#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></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">14</span><span class="cl">            <span class="nx">got</span> <span class="o">:=</span> <span class="nf">NormalizeTopic</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</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;NormalizeTopic(%q) = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">input</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <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="p">}</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表只測 <a href="/blog/backend/knowledge-cards/topic/" data-link-title="Topic" data-link-desc="說明 topic 如何把事件依主題分流給不同訂閱者">topic</a> normalization。每個案例都只有 input 和 want，失敗時也能立刻看出是哪個 normalization 規則壞了。</p>
<h2 id="策略表格欄位越多越要懷疑測試邊界">【策略】表格欄位越多，越要懷疑測試邊界</h2>
<p>Table 欄位的核心警訊是大量欄位只被少數案例使用。這通常表示不同測試目的被合併在一起。</p>
<p>拆分判斷：</p>
<table>
  <thead>
      <tr>
          <th>現象</th>
          <th>問題</th>
          <th>建議</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>很多 <code>setupX bool</code></td>
          <td>setup 不一致</td>
          <td>拆成不同測試</td>
      </tr>
      <tr>
          <td>很多 <code>wantX bool</code></td>
          <td>斷言目標不一致</td>
          <td>拆成不同測試</td>
      </tr>
      <tr>
          <td>loop 內大量 <code>if tt...</code></td>
          <td>測試流程不一致</td>
          <td>拆表或改成具名測試</td>
      </tr>
      <tr>
          <td>案例名稱很長仍說不清</td>
          <td>行為維度太多</td>
          <td>回到單一行為</td>
      </tr>
      <tr>
          <td>helper 隱藏主要斷言</td>
          <td>可讀性下降</td>
          <td>讓斷言留在測試本文</td>
      </tr>
  </tbody>
</table>
<p>表格不是越通用越好。測試的第一責任是讓失敗可定位，不是消除所有重複。</p>
<h2 id="執行validation-適合-table-test">【執行】validation 適合 table test</h2>
<p>Validation 的核心特徵是輸入和輸出形狀一致，因此很適合 table-driven test。</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">TestValidateSubscribeRequest</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">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">request</span> <span class="nx">SubscribeTopicRequest</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">wantErr</span> <span class="kt">bool</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 class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;valid topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nx">request</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="nx">wantErr</span><span class="p">:</span> <span class="kc">false</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><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;empty topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">            <span class="nx">request</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;&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</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 class="p">{</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;blank topic&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">request</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;   &#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</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><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">26</span><span class="cl">            <span class="nx">err</span> <span class="o">:=</span> <span class="nf">ValidateSubscribeRequest</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">request</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">28</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;error = %v, wantErr %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表只問一件事：request 是否有效。它不測 WebSocket connection、不測 hub、不測 repository，因此案例可以保持簡潔。</p>
<h2 id="執行狀態轉移也適合-table-test">【執行】狀態轉移也適合 table test</h2>
<p>狀態轉移的核心特徵是輸入狀態、事件、期待輸出狀態。只要流程一致，就適合 table-driven test。</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">TestJobTransition</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">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">        <span class="nx">name</span>    <span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="nx">current</span> <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">event</span>   <span class="nx">EventType</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">want</span>    <span class="nx">JobStatus</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="nx">wantErr</span> <span class="kt">bool</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 class="nx">name</span><span class="p">:</span>    <span class="s">&#34;pending starts&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobPending</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">            <span class="nx">want</span><span class="p">:</span>    <span class="nx">JobRunning</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 class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">            <span class="nx">name</span><span class="p">:</span>    <span class="s">&#34;running finishes&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobRunning</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobFinished</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="nx">want</span><span class="p">:</span>    <span class="nx">JobSucceeded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">        <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="nx">name</span><span class="p">:</span>    <span class="s">&#34;finished cannot start again&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">            <span class="nx">current</span><span class="p">:</span> <span class="nx">JobSucceeded</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">            <span class="nx">event</span><span class="p">:</span>   <span class="nx">EventJobStarted</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">            <span class="nx">wantErr</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="p">},</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">tt</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">tests</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">        <span class="nx">t</span><span class="p">.</span><span class="nf">Run</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">name</span><span class="p">,</span> <span class="kd">func</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">31</span><span class="cl">            <span class="nx">got</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nf">Transition</span><span class="p">(</span><span class="nx">tt</span><span class="p">.</span><span class="nx">current</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">event</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">            <span class="k">if</span> <span class="p">(</span><span class="nx">err</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">33</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;error = %v, wantErr %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">wantErr</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">35</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="o">&amp;&amp;</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">36</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;status = %s, want %s&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">tt</span><span class="p">.</span><span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">            <span class="p">}</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="p">})</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這張表的欄位都服務同一個行為：job status transition。若未來要測 repository 寫入失敗，應另開測試，不要塞進這張表。</p>
<h2 id="判讀不同-setup-應拆成不同測試">【判讀】不同 setup 應拆成不同測試</h2>
<p>測試拆分的核心原則是 setup 不同，通常就不是同一張表。HTTP validation、repository error、client queue full 都需要不同環境。</p>
<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">TestSubscribeValidation</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="c1">// 只測 request validation</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><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="kd">func</span> <span class="nf">TestSubscribeAddsTopic</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"> 6</span><span class="cl">    <span class="c1">// 只測成功訂閱後 client state</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="kd">func</span> <span class="nf">TestSubscribeReturnsErrorWhenClientQueueFull</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">10</span><span class="cl">    <span class="c1">// 只測 send buffer 滿載時的錯誤語意</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這些測試可能有少量重複，但每個測試的失敗原因更清楚。測試重複一點可以接受；測試意圖混在一起會讓維護成本更高。</p>
<h2 id="策略helper-只包樣板不包判斷">【策略】helper 只包樣板，不包判斷</h2>
<p>Test helper 的核心責任是降低重複 setup，不應隱藏主要斷言。讀者應能在測試本文看到這個測試到底在驗證什麼。</p>
<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">mustJSON</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">value</span> <span class="kt">any</span><span class="p">)</span> <span class="nx">json</span><span class="p">.</span><span class="nx">RawMessage</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 class="nx">data</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">json</span><span class="p">.</span><span class="nf">Marshal</span><span class="p">(</span><span class="nx">value</span><span class="p">)</span>
</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="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;marshal json: %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 class="k">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="ln">8</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">assertSubscribeScenario</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">tt</span> <span class="nx">subscribeScenario</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="c1">// setup HTTP, setup WebSocket, setup repository,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// execute action, check response, check logs, check events</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>後者把測試主要邏輯藏進 helper。表格看起來短，但讀者必須跳到 helper 才知道每個欄位如何影響流程。</p>
<h2 id="執行子測試名稱要描述情境">【執行】子測試名稱要描述情境</h2>
<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="nx">tests</span> <span class="o">:=</span> <span class="p">[]</span><span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nx">name</span> <span class="kt">string</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="c1">// ...</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;missing topic&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">{</span><span class="nx">name</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">7</span><span class="cl">    <span class="p">{</span><span class="nx">name</span><span class="p">:</span> <span class="s">&#34;queue full returns unavailable&#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></code></pre></div><p><code>go test</code> 輸出會包含 <code>TestValidateSubscribeRequest/missing_topic</code> 這類資訊。當 CI 失敗時，讀者能先知道哪個情境壞了，再看 got/want 差異。</p>
<p>命名應該描述輸入情境或規則，不需要寫成完整句子，也不要只寫 <code>case 1</code>。</p>
<h2 id="測試table-test-本身也要保持可讀">【測試】table test 本身也要保持可讀</h2>
<p>Table-driven test 的核心完成標準是讀者能掃過表格就理解規則。若必須讀整個 loop 才懂欄位意義，表格設計就不夠清楚。</p>
<p>自檢問題：</p>
<ul>
<li>這張表是否只測一個行為？</li>
<li>每個欄位是否幾乎每個案例都用得到？</li>
<li>測試 loop 裡是否有大量條件分支？</li>
<li>子測試名稱是否能定位失敗情境？</li>
<li>got/want 斷言是否直接留在測試本文？</li>
</ul>
<p>任一題答否，先考慮拆測試，而不是加更多欄位。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章先處理單一行為的多組資料案例；property-based、fuzz 與 snapshot 測試，會在下列章節再往外延伸：</p>
<ul>
<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>
</ul>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 test case 設計、handler boundary 與 command 驗證；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：testing 基礎</a></li>
<li><a href="/blog/go/07-refactoring/handler-boundary/" data-link-title="7.1 把 handler 邏輯拆成可測單元" data-link-desc="分離 HTTP 協定處理與核心邏輯">Go：把 handler 邏輯拆成可測單元</a></li>
<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/06-practical/new-event-type/" data-link-title="6.2 如何新增一種 domain event" data-link-desc="擴展事件常數、輸入驗證與處理流程">Go：如何新增一種 domain event</a></li>
<li><a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend：可靠性驗證流程</a></li>
</ul>
<h2 id="小結">小結</h2>
<p>Table-driven test 適合同一個行為的多組資料，不適合混合多種 setup 與斷言。欄位膨脹、loop 裡大量 <code>if tt...</code>、helper 隱藏主要判斷，都是拆表訊號。好的測試表讓案例更清楚，而不是把測試變成迷你框架。</p>
]]></content:encoded></item><item><title>5.4 Mock 與測試隔離</title><link>https://tarrragon.github.io/blog/python/05-error-testing/mock/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/05-error-testing/mock/</guid><description>&lt;p>當測試的程式碼依賴外部資源（檔案系統、網路、stdin/stdout）時，我們需要使用 Mock 來隔離這些依賴，確保測試的可靠性和速度。&lt;/p>
&lt;h2 id="為什麼需要-mock">為什麼需要 Mock？&lt;/h2>
&lt;h3 id="問題場景">問題場景&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">read_hook_input&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&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="s2">&amp;#34;&amp;#34;&amp;#34;從 stdin 讀取 JSON 輸入&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">sys&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdin&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="c1"># 測試時如何提供 stdin？&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="使用-mock-解決">使用 Mock 解決&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">patch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">io&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StringIO&lt;/span>
&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">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_read_hook_input&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="n">json_input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;{&amp;#34;key&amp;#34;: &amp;#34;value&amp;#34;}&amp;#39;&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="c1"># 用 StringIO 替換 sys.stdin&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">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">StringIO&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json_input&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">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_hook_input&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;value&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="unittestmock-基礎">unittest.mock 基礎&lt;/h2>
&lt;h3 id="patch-裝飾器">patch 裝飾器&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">patch&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestMyFunction&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="nd">@patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;module.function_to_mock&amp;#34;&lt;/span>&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="k">def&lt;/span> &lt;span class="nf">test_something&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mock_func&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="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">return_value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;mocked result&amp;#34;&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">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">my_function&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;expected&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="patch-上下文管理器">patch 上下文管理器&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">test_something&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;module.function&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">mock_func&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">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">return_value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;mocked&amp;#34;&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">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">my_function&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="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;expected&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="實際範例測試-hook-io">實際範例：測試 Hook IO&lt;/h2>
&lt;p>來自 &lt;code>.claude/lib/tests/test_hook_io.py&lt;/code>：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">io&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">StringIO&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">patch&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="kn">from&lt;/span> &lt;span class="nn">hook_io&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">read_hook_input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">write_hook_output&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestReadHookInput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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="s2">&amp;#34;&amp;#34;&amp;#34;測試 read_hook_input 函式&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&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"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_valid_json_input&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&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="s2">&amp;#34;&amp;#34;&amp;#34;測試有效的 JSON 輸入&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;tool_name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Write&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;file_path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/test.txt&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="n">json_input&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dumps&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">test_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="c1"># Mock sys.stdin&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">StringIO&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">json_input&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_hook_input&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">test_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_invalid_json_returns_empty_dict&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試無效的 JSON&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdin&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">StringIO&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;not valid json&amp;#34;&lt;/span>&lt;span class="p">)):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">read_hook_input&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">result&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">29&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestWriteHookOutput&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試 write_hook_output 函式&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_output_json_format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試輸出為有效的 JSON&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;decision&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;allow&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="c1"># Mock sys.stdout&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">new_callable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">StringIO&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="n">write_hook_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">test_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl"> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getvalue&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">43&lt;/span>&lt;span class="cl"> &lt;span class="c1"># 驗證輸出是有效的 JSON&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">44&lt;/span>&lt;span class="cl"> &lt;span class="n">parsed&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">45&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parsed&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;decision&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="s2">&amp;#34;allow&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">46&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">47&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_chinese_preserved&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">48&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試中文字元被保留&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">49&lt;/span>&lt;span class="cl"> &lt;span class="n">test_data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;你好&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">50&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">51&lt;/span>&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;sys.stdout&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">new_callable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">StringIO&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">52&lt;/span>&lt;span class="cl"> &lt;span class="n">write_hook_output&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">test_data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">ensure_ascii&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">53&lt;/span>&lt;span class="cl"> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_stdout&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getvalue&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">54&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">55&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertIn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;你好&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="mock-物件的設定">Mock 物件的設定&lt;/h2>
&lt;h3 id="return_value">return_value&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Mock&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Mock&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">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">return_value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">42&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="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 42&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="side_effect---動態返回值">side_effect - 動態返回值&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Mock&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Mock&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="c1"># 依序返回不同值&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">side_effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">3&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="n">mock_func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 1&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">mock_func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 2&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">mock_func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="c1"># 根據輸入返回不同值&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">side_effect_func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">x&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="k">return&lt;/span> &lt;span class="n">x&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">side_effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">side_effect_func&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 10&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="side_effect---拋出異常">side_effect - 拋出異常&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Mock&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Mock&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">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">side_effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="ne">ValueError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;Error!&amp;#34;&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="n">mock_func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 拋出 ValueError&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="驗證-mock-被呼叫">驗證 Mock 被呼叫&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Mock&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">call&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Mock&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="c1"># 呼叫 mock&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;value&amp;#34;&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="n">mock_func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c1"># 驗證呼叫&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assert_called&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 被呼叫過&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">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assert_called_once&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># 只被呼叫一次（這會失敗）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assert_called_with&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># 最後一次呼叫的參數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="c1"># 檢查所有呼叫&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assert_has_calls&lt;/span>&lt;span class="p">([&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="n">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;value&amp;#34;&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="n">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="c1"># 呼叫次數&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">mock_func&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">call_count&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="實際範例測試-git-工具">實際範例：測試 Git 工具&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">unittest&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">unittest.mock&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">patch&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">Mock&lt;/span>
&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">&lt;span class="kn">from&lt;/span> &lt;span class="nn">git_utils&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">run_git_command&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">get_current_branch&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">TestRunGitCommand&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unittest&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TestCase&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nd">@patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;subprocess.run&amp;#34;&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="k">def&lt;/span> &lt;span class="nf">test_successful_command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mock_run&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="s2">&amp;#34;&amp;#34;&amp;#34;測試成功的 git 命令&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="c1"># 設定 mock 返回值&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Mock&lt;/span>&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="n">mock_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">returncode&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;main&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stderr&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_run&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">return_value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_result&lt;/span>
&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"> &lt;span class="n">success&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">run_git_command&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;branch&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;--show-current&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertTrue&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">success&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">output&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;main&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="c1"># 驗證 subprocess.run 被正確呼叫&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_run&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assert_called_once&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl"> &lt;span class="n">call_args&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_run&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">call_args&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">call_args&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">][&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;git&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;branch&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;--show-current&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="nd">@patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;subprocess.run&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_failed_command&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mock_run&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試失敗的 git 命令&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Mock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">returncode&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stdout&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">stderr&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;fatal: not a git repository&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_run&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">return_value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">mock_result&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="n">success&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">run_git_command&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertFalse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">success&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertIn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;not a git repository&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">43&lt;/span>&lt;span class="cl"> &lt;span class="nd">@patch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;subprocess.run&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">44&lt;/span>&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">test_timeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">mock_run&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">45&lt;/span>&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;測試命令超時&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">46&lt;/span>&lt;span class="cl"> &lt;span class="kn">import&lt;/span> &lt;span class="nn">subprocess&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">47&lt;/span>&lt;span class="cl"> &lt;span class="n">mock_run&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">side_effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">TimeoutExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;git&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">48&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">49&lt;/span>&lt;span class="cl"> &lt;span class="n">success&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">run_git_command&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s2">&amp;#34;status&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">timeout&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">10&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">50&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">51&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertFalse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">success&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">52&lt;/span>&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">assertIn&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;timed out&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">output&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="magicmock">MagicMock&lt;/h2>
&lt;p>&lt;code>MagicMock&lt;/code> 自動支援魔術方法：&lt;/p></description><content:encoded><![CDATA[<p>當測試的程式碼依賴外部資源（檔案系統、網路、stdin/stdout）時，我們需要使用 Mock 來隔離這些依賴，確保測試的可靠性和速度。</p>
<h2 id="為什麼需要-mock">為什麼需要 Mock？</h2>
<h3 id="問題場景">問題場景</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">read_hook_input</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="s2">&#34;&#34;&#34;從 stdin 讀取 JSON 輸入&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="k">return</span> <span class="n">json</span><span class="o">.</span><span class="n">load</span><span class="p">(</span><span class="n">sys</span><span class="o">.</span><span class="n">stdin</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="c1"># 測試時如何提供 stdin？</span></span></span></code></pre></div><h3 id="使用-mock-解決">使用 Mock 解決</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">io</span> <span class="kn">import</span> <span class="n">StringIO</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">def</span> <span class="nf">test_read_hook_input</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">json_input</span> <span class="o">=</span> <span class="s1">&#39;{&#34;key&#34;: &#34;value&#34;}&#39;</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="c1"># 用 StringIO 替換 sys.stdin</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdin&#34;</span><span class="p">,</span> <span class="n">StringIO</span><span class="p">(</span><span class="n">json_input</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">read_hook_input</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="k">assert</span> <span class="n">result</span> <span class="o">==</span> <span class="p">{</span><span class="s2">&#34;key&#34;</span><span class="p">:</span> <span class="s2">&#34;value&#34;</span><span class="p">}</span></span></span></code></pre></div><h2 id="unittestmock-基礎">unittest.mock 基礎</h2>
<h3 id="patch-裝飾器">patch 裝飾器</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">class</span> <span class="nc">TestMyFunction</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</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="nd">@patch</span><span class="p">(</span><span class="s2">&#34;module.function_to_mock&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">def</span> <span class="nf">test_something</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_func</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="n">mock_func</span><span class="o">.</span><span class="n">return_value</span> <span class="o">=</span> <span class="s2">&#34;mocked result&#34;</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">my_function</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="s2">&#34;expected&#34;</span><span class="p">)</span></span></span></code></pre></div><h3 id="patch-上下文管理器">patch 上下文管理器</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">test_something</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;module.function&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">mock_func</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="n">mock_func</span><span class="o">.</span><span class="n">return_value</span> <span class="o">=</span> <span class="s2">&#34;mocked&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">my_function</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="s2">&#34;expected&#34;</span><span class="p">)</span></span></span></code></pre></div><h2 id="實際範例測試-hook-io">實際範例：測試 Hook IO</h2>
<p>來自 <code>.claude/lib/tests/test_hook_io.py</code>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">json</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="kn">from</span> <span class="nn">io</span> <span class="kn">import</span> <span class="n">StringIO</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</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="kn">from</span> <span class="nn">hook_io</span> <span class="kn">import</span> <span class="n">read_hook_input</span><span class="p">,</span> <span class="n">write_hook_output</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="k">class</span> <span class="nc">TestReadHookInput</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試 read_hook_input 函式&#34;&#34;&#34;</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="k">def</span> <span class="nf">test_valid_json_input</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試有效的 JSON 輸入&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;tool_name&#34;</span><span class="p">:</span> <span class="s2">&#34;Write&#34;</span><span class="p">,</span> <span class="s2">&#34;file_path&#34;</span><span class="p">:</span> <span class="s2">&#34;/test.txt&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">json_input</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">dumps</span><span class="p">(</span><span class="n">test_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="c1"># Mock sys.stdin</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdin&#34;</span><span class="p">,</span> <span class="n">StringIO</span><span class="p">(</span><span class="n">json_input</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">            <span class="n">result</span> <span class="o">=</span> <span class="n">read_hook_input</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="n">test_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="k">def</span> <span class="nf">test_invalid_json_returns_empty_dict</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試無效的 JSON&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdin&#34;</span><span class="p">,</span> <span class="n">StringIO</span><span class="p">(</span><span class="s2">&#34;not valid json&#34;</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">            <span class="n">result</span> <span class="o">=</span> <span class="n">read_hook_input</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">
</span></span><span class="line"><span class="ln">28</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">,</span> <span class="p">{})</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">
</span></span><span class="line"><span class="ln">30</span><span class="cl">
</span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="k">class</span> <span class="nc">TestWriteHookOutput</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">    <span class="s2">&#34;&#34;&#34;測試 write_hook_output 函式&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="k">def</span> <span class="nf">test_output_json_format</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試輸出為有效的 JSON&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;decision&#34;</span><span class="p">:</span> <span class="s2">&#34;allow&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="c1"># Mock sys.stdout</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdout&#34;</span><span class="p">,</span> <span class="n">new_callable</span><span class="o">=</span><span class="n">StringIO</span><span class="p">)</span> <span class="k">as</span> <span class="n">mock_stdout</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">            <span class="n">write_hook_output</span><span class="p">(</span><span class="n">test_data</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">            <span class="n">output</span> <span class="o">=</span> <span class="n">mock_stdout</span><span class="o">.</span><span class="n">getvalue</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">
</span></span><span class="line"><span class="ln">43</span><span class="cl">        <span class="c1"># 驗證輸出是有效的 JSON</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">        <span class="n">parsed</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">output</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">parsed</span><span class="p">[</span><span class="s2">&#34;decision&#34;</span><span class="p">],</span> <span class="s2">&#34;allow&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">
</span></span><span class="line"><span class="ln">47</span><span class="cl">    <span class="k">def</span> <span class="nf">test_chinese_preserved</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試中文字元被保留&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">        <span class="n">test_data</span> <span class="o">=</span> <span class="p">{</span><span class="s2">&#34;message&#34;</span><span class="p">:</span> <span class="s2">&#34;你好&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;sys.stdout&#34;</span><span class="p">,</span> <span class="n">new_callable</span><span class="o">=</span><span class="n">StringIO</span><span class="p">)</span> <span class="k">as</span> <span class="n">mock_stdout</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">            <span class="n">write_hook_output</span><span class="p">(</span><span class="n">test_data</span><span class="p">,</span> <span class="n">ensure_ascii</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">53</span><span class="cl">            <span class="n">output</span> <span class="o">=</span> <span class="n">mock_stdout</span><span class="o">.</span><span class="n">getvalue</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">
</span></span><span class="line"><span class="ln">55</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertIn</span><span class="p">(</span><span class="s2">&#34;你好&#34;</span><span class="p">,</span> <span class="n">output</span><span class="p">)</span></span></span></code></pre></div><h2 id="mock-物件的設定">Mock 物件的設定</h2>
<h3 id="return_value">return_value</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">Mock</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">mock_func</span> <span class="o">=</span> <span class="n">Mock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">return_value</span> <span class="o">=</span> <span class="mi">42</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="n">result</span> <span class="o">=</span> <span class="n">mock_func</span><span class="p">()</span>  <span class="c1"># 42</span></span></span></code></pre></div><h3 id="side_effect---動態返回值">side_effect - 動態返回值</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">Mock</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">mock_func</span> <span class="o">=</span> <span class="n">Mock</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="c1"># 依序返回不同值</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">side_effect</span> <span class="o">=</span> <span class="p">[</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="mi">3</span><span class="p">]</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">mock_func</span><span class="p">()</span>  <span class="c1"># 1</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="n">mock_func</span><span class="p">()</span>  <span class="c1"># 2</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="n">mock_func</span><span class="p">()</span>  <span class="c1"># 3</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="c1"># 根據輸入返回不同值</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="k">def</span> <span class="nf">side_effect_func</span><span class="p">(</span><span class="n">x</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">return</span> <span class="n">x</span> <span class="o">*</span> <span class="mi">2</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="n">mock_func</span><span class="o">.</span><span class="n">side_effect</span> <span class="o">=</span> <span class="n">side_effect_func</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="n">mock_func</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>  <span class="c1"># 10</span></span></span></code></pre></div><h3 id="side_effect---拋出異常">side_effect - 拋出異常</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">Mock</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">mock_func</span> <span class="o">=</span> <span class="n">Mock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">side_effect</span> <span class="o">=</span> <span class="ne">ValueError</span><span class="p">(</span><span class="s2">&#34;Error!&#34;</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="n">mock_func</span><span class="p">()</span>  <span class="c1"># 拋出 ValueError</span></span></span></code></pre></div><h2 id="驗證-mock-被呼叫">驗證 Mock 被呼叫</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">Mock</span><span class="p">,</span> <span class="n">call</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="n">mock_func</span> <span class="o">=</span> <span class="n">Mock</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="c1"># 呼叫 mock</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="n">mock_func</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="s2">&#34;value&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">mock_func</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">4</span><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="c1"># 驗證呼叫</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">assert_called</span><span class="p">()</span>              <span class="c1"># 被呼叫過</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">assert_called_once</span><span class="p">()</span>         <span class="c1"># 只被呼叫一次（這會失敗）</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">assert_called_with</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">4</span><span class="p">)</span>     <span class="c1"># 最後一次呼叫的參數</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"># 檢查所有呼叫</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="n">mock_func</span><span class="o">.</span><span class="n">assert_has_calls</span><span class="p">([</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">call</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="mi">2</span><span class="p">,</span> <span class="n">key</span><span class="o">=</span><span class="s2">&#34;value&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="n">call</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="mi">4</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></span><span class="line"><span class="ln">20</span><span class="cl"><span class="c1"># 呼叫次數</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">mock_func</span><span class="o">.</span><span class="n">call_count</span><span class="p">,</span> <span class="mi">2</span><span class="p">)</span></span></span></code></pre></div><h2 id="實際範例測試-git-工具">實際範例：測試 Git 工具</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kn">import</span> <span class="nn">unittest</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</span><span class="p">,</span> <span class="n">Mock</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="kn">from</span> <span class="nn">git_utils</span> <span class="kn">import</span> <span class="n">run_git_command</span><span class="p">,</span> <span class="n">get_current_branch</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></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="k">class</span> <span class="nc">TestRunGitCommand</span><span class="p">(</span><span class="n">unittest</span><span class="o">.</span><span class="n">TestCase</span><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="nd">@patch</span><span class="p">(</span><span class="s2">&#34;subprocess.run&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">def</span> <span class="nf">test_successful_command</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_run</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試成功的 git 命令&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="c1"># 設定 mock 返回值</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">        <span class="n">mock_result</span> <span class="o">=</span> <span class="n">Mock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">        <span class="n">mock_result</span><span class="o">.</span><span class="n">returncode</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="n">mock_result</span><span class="o">.</span><span class="n">stdout</span> <span class="o">=</span> <span class="s2">&#34;main</span><span class="se">\n</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="n">mock_result</span><span class="o">.</span><span class="n">stderr</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">mock_run</span><span class="o">.</span><span class="n">return_value</span> <span class="o">=</span> <span class="n">mock_result</span>
</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">        <span class="n">success</span><span class="p">,</span> <span class="n">output</span> <span class="o">=</span> <span class="n">run_git_command</span><span class="p">([</span><span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="s2">&#34;--show-current&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertTrue</span><span class="p">(</span><span class="n">success</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">output</span><span class="p">,</span> <span class="s2">&#34;main&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">
</span></span><span class="line"><span class="ln">24</span><span class="cl">        <span class="c1"># 驗證 subprocess.run 被正確呼叫</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">        <span class="n">mock_run</span><span class="o">.</span><span class="n">assert_called_once</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">        <span class="n">call_args</span> <span class="o">=</span> <span class="n">mock_run</span><span class="o">.</span><span class="n">call_args</span>
</span></span><span class="line"><span class="ln">27</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">call_args</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="mi">0</span><span class="p">],</span> <span class="p">[</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="s2">&#34;--show-current&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;subprocess.run&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="k">def</span> <span class="nf">test_failed_command</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_run</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試失敗的 git 命令&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">        <span class="n">mock_result</span> <span class="o">=</span> <span class="n">Mock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln">33</span><span class="cl">        <span class="n">mock_result</span><span class="o">.</span><span class="n">returncode</span> <span class="o">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">        <span class="n">mock_result</span><span class="o">.</span><span class="n">stdout</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">        <span class="n">mock_result</span><span class="o">.</span><span class="n">stderr</span> <span class="o">=</span> <span class="s2">&#34;fatal: not a git repository&#34;</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">        <span class="n">mock_run</span><span class="o">.</span><span class="n">return_value</span> <span class="o">=</span> <span class="n">mock_result</span>
</span></span><span class="line"><span class="ln">37</span><span class="cl">
</span></span><span class="line"><span class="ln">38</span><span class="cl">        <span class="n">success</span><span class="p">,</span> <span class="n">output</span> <span class="o">=</span> <span class="n">run_git_command</span><span class="p">([</span><span class="s2">&#34;status&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">
</span></span><span class="line"><span class="ln">40</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertFalse</span><span class="p">(</span><span class="n">success</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">41</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertIn</span><span class="p">(</span><span class="s2">&#34;not a git repository&#34;</span><span class="p">,</span> <span class="n">output</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">42</span><span class="cl">
</span></span><span class="line"><span class="ln">43</span><span class="cl">    <span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;subprocess.run&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">    <span class="k">def</span> <span class="nf">test_timeout</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_run</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">        <span class="s2">&#34;&#34;&#34;測試命令超時&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln">46</span><span class="cl">        <span class="kn">import</span> <span class="nn">subprocess</span>
</span></span><span class="line"><span class="ln">47</span><span class="cl">        <span class="n">mock_run</span><span class="o">.</span><span class="n">side_effect</span> <span class="o">=</span> <span class="n">subprocess</span><span class="o">.</span><span class="n">TimeoutExpired</span><span class="p">(</span><span class="s2">&#34;git&#34;</span><span class="p">,</span> <span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">
</span></span><span class="line"><span class="ln">49</span><span class="cl">        <span class="n">success</span><span class="p">,</span> <span class="n">output</span> <span class="o">=</span> <span class="n">run_git_command</span><span class="p">([</span><span class="s2">&#34;status&#34;</span><span class="p">],</span> <span class="n">timeout</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">
</span></span><span class="line"><span class="ln">51</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertFalse</span><span class="p">(</span><span class="n">success</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">52</span><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">assertIn</span><span class="p">(</span><span class="s2">&#34;timed out&#34;</span><span class="p">,</span> <span class="n">output</span><span class="p">)</span></span></span></code></pre></div><h2 id="magicmock">MagicMock</h2>
<p><code>MagicMock</code> 自動支援魔術方法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">MagicMock</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">mock</span> <span class="o">=</span> <span class="n">MagicMock</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="c1"># 自動支援各種操作</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="n">mock</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>          <span class="c1"># 不會報錯</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">mock</span><span class="o">.</span><span class="n">anything</span><span class="p">()</span>  <span class="c1"># 返回另一個 MagicMock</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="nb">len</span><span class="p">(</span><span class="n">mock</span><span class="p">)</span>        <span class="c1"># 返回預設值</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="nb">str</span><span class="p">(</span><span class="n">mock</span><span class="p">)</span>        <span class="c1"># 返回字串</span></span></span></code></pre></div><h2 id="測試檔案操作">測試檔案操作</h2>
<h3 id="使用-mock_open">使用 mock_open</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</span><span class="p">,</span> <span class="n">mock_open</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">test_read_config</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">config_content</span> <span class="o">=</span> <span class="s1">&#39;{&#34;key&#34;: &#34;value&#34;}&#39;</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="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;builtins.open&#34;</span><span class="p">,</span> <span class="n">mock_open</span><span class="p">(</span><span class="n">read_data</span><span class="o">=</span><span class="n">config_content</span><span class="p">)):</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">        <span class="n">result</span> <span class="o">=</span> <span class="n">load_config</span><span class="p">(</span><span class="s2">&#34;config.json&#34;</span><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="bp">self</span><span class="o">.</span><span class="n">assertEqual</span><span class="p">(</span><span class="n">result</span><span class="p">[</span><span class="s2">&#34;key&#34;</span><span class="p">],</span> <span class="s2">&#34;value&#34;</span><span class="p">)</span></span></span></code></pre></div><h3 id="測試-path-物件">測試 Path 物件</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</span><span class="p">,</span> <span class="n">Mock</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">test_check_file_exists</span><span class="p">():</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">with</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;pathlib.Path.exists&#34;</span><span class="p">)</span> <span class="k">as</span> <span class="n">mock_exists</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">        <span class="n">mock_exists</span><span class="o">.</span><span class="n">return_value</span> <span class="o">=</span> <span class="kc">True</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="n">result</span> <span class="o">=</span> <span class="n">check_file_exists</span><span class="p">(</span><span class="s2">&#34;/some/path&#34;</span><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="bp">self</span><span class="o">.</span><span class="n">assertTrue</span><span class="p">(</span><span class="n">result</span><span class="p">)</span></span></span></code></pre></div><h2 id="patch-的位置">patch 的位置</h2>
<p><strong>重要</strong>：patch 的目標是模組匯入的位置，而非定義的位置。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># module_a.py</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="kn">from</span> <span class="nn">os</span> <span class="kn">import</span> <span class="n">getcwd</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">def</span> <span class="nf">my_function</span><span class="p">():</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">return</span> <span class="n">getcwd</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="c1"># test_module_a.py</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"># 正確：patch 匯入的位置</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;module_a.getcwd&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="k">def</span> <span class="nf">test_my_function</span><span class="p">(</span><span class="n">mock_getcwd</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="o">...</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="c1"># 錯誤：patch 定義的位置</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;os.getcwd&#34;</span><span class="p">)</span>  <span class="c1"># 不會生效！</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="k">def</span> <span class="nf">test_my_function</span><span class="p">(</span><span class="n">mock_getcwd</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="o">...</span></span></span></code></pre></div><h2 id="最佳實踐">最佳實踐</h2>
<h3 id="1-只-mock-外部依賴">1. 只 Mock 外部依賴</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 好：Mock 外部系統</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;subprocess.run&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">def</span> <span class="nf">test_git_command</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_run</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="o">...</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"># 不好：Mock 內部邏輯</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;my_module.internal_helper&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="k">def</span> <span class="nf">test_my_function</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_helper</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl">    <span class="o">...</span>  <span class="c1"># 過度 mock 會讓測試變脆弱</span></span></span></code></pre></div><h3 id="2-使用-autospec">2. 使用 autospec</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="kn">from</span> <span class="nn">unittest.mock</span> <span class="kn">import</span> <span class="n">patch</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># autospec 確保 mock 的簽名與原函式相同</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nd">@patch</span><span class="p">(</span><span class="s2">&#34;module.function&#34;</span><span class="p">,</span> <span class="n">autospec</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="k">def</span> <span class="nf">test_something</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">mock_func</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="c1"># 如果呼叫簽名錯誤會報錯</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="n">mock_func</span><span class="p">(</span><span class="s2">&#34;wrong&#34;</span><span class="p">,</span> <span class="s2">&#34;args&#34;</span><span class="p">)</span>  <span class="c1"># 可能報錯</span></span></span></code></pre></div><h3 id="3-清理-mock">3. 清理 Mock</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">def</span> <span class="nf">setUp</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">patcher</span> <span class="o">=</span> <span class="n">patch</span><span class="p">(</span><span class="s2">&#34;module.function&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">mock_func</span> <span class="o">=</span> <span class="bp">self</span><span class="o">.</span><span class="n">patcher</span><span class="o">.</span><span class="n">start</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="k">def</span> <span class="nf">tearDown</span><span class="p">(</span><span class="bp">self</span><span class="p">):</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="bp">self</span><span class="o">.</span><span class="n">patcher</span><span class="o">.</span><span class="n">stop</span><span class="p">()</span>  <span class="c1"># 確保清理</span></span></span></code></pre></div><h2 id="思考題">思考題</h2>
<ol>
<li>patch 的目標為什麼是匯入位置而非定義位置？</li>
<li><code>Mock</code> 和 <code>MagicMock</code> 有什麼區別？</li>
<li>什麼時候應該使用 <code>autospec=True</code>？</li>
</ol>
<h2 id="實作練習">實作練習</h2>
<ol>
<li>為 <code>get_current_branch()</code> 撰寫使用 Mock 的測試</li>
<li>測試一個讀取檔案的函式，使用 <code>mock_open</code></li>
<li>測試一個會拋出異常的外部呼叫，使用 <code>side_effect</code></li>
</ol>
<hr>
<p><em>上一章：<a href="/blog/python/05-error-testing/unittest/" data-link-title="5.3 unittest 基礎" data-link-desc="撰寫第一個單元測試">unittest 基礎</a></em>
<em>下一模組：<a href="/blog/python/04-oop/" data-link-title="模組四：物件導向設計" data-link-desc="Python 的物件導向設計與設計模式">物件導向設計</a></em></p>
]]></content:encoded></item><item><title>反模式：用 mock 數量彌補 mock 盲區</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/</guid><description>&lt;p>當 mock test 全過但實機出問題時，常見的第一反應是「test 不夠多」或「覆蓋率不夠高」。這個反應假設 mock test 的問題在數量，而實際上問題在層級 — mock test 驗證的對象和實機暴露的問題不在同一層。增加 mock test 數量擴展的是同一層的覆蓋範圍，不會跨越到另一層。&lt;/p>
&lt;h2 id="數量與層級的區別">數量與層級的區別&lt;/h2>
&lt;p>app_tunnel 的 192 個 unit test 覆蓋了 &lt;code>ConnectionManager&lt;/code>、&lt;code>AnsiParser&lt;/code>、&lt;code>TerminalBuffer&lt;/code> 等元件的邏輯分支。如果在 mock test 全過但實機失敗後，反應是「再寫 50 個 test」，新寫的 test 會使用同一個 &lt;code>FakeWebSocketChannel&lt;/code>，測試更多的邏輯分支 — 更多的輸入組合、更多的邊界條件、更多的錯誤處理路徑。&lt;/p>
&lt;p>這 50 個新 test 和原來的 192 個 test 在同一個 mock 環境中執行，受到同一個 &lt;code>FakeWebSocketChannel&lt;/code> 的行為限制。&lt;code>FakeWebSocketChannel&lt;/code> 不區分 text frame 和 binary frame — 這個限制在第 1 個 test 和第 242 個 test 中都一樣。數量增加了，遮蔽範圍沒有改變。&lt;/p>
&lt;p>用類比說明：用純水測試淨水器的過濾效果，不管測 1 杯還是 1000 杯，結論都是「水很乾淨」。問題在測試材料 — 需要用含有雜質的水測試才能驗證過濾功能。Mock 是純水，真實服務互動是含雜質的水。&lt;/p>
&lt;h2 id="覆蓋率指標的盲點">覆蓋率指標的盲點&lt;/h2>
&lt;p>Line coverage 和 branch coverage 衡量的是「程式碼中有多少行 / 分支被 test 執行過」。這些指標在同一層 test 內有意義 — 100% branch coverage 的 unit test 確保每個 if/else 都被走過。&lt;/p>
&lt;p>但覆蓋率指標不區分 test 的依賴環境。一個使用 &lt;code>FakeWebSocketChannel&lt;/code> 的 test 和一個使用 &lt;code>IOWebSocketChannel&lt;/code> 的 test 走過同一行 &lt;code>sink.add(data)&lt;/code> — 在覆蓋率報告中是同一行被覆蓋，但驗證的語意完全不同。&lt;/p>
&lt;p>覆蓋率 100% 意味著「在 mock 環境中，所有程式碼分支都被走過」。這不等於「在真實環境中，所有程式碼分支的行為都是正確的」。app_tunnel 的 &lt;code>sendData()&lt;/code> 在覆蓋率報告中是「已覆蓋」的，但覆蓋它的 test 用的是不區分 frame type 的 fake。&lt;/p>
&lt;h2 id="這個反模式如何在團隊中擴散">這個反模式如何在團隊中擴散&lt;/h2>
&lt;p>「test 不夠多」是一個容易執行、容易衡量的回應。在沒有獨立 QA 驗收流程的團隊中（覆蓋率報告是主要品質指標），寫更多 test 可以提高覆蓋率數字，覆蓋率數字上升給團隊信心。相比之下，「需要一個新的 test 層級」需要建置新的 test 環境、學習不同的 test 技術、接受較慢的執行速度。&lt;/p>
&lt;p>這個成本差異讓團隊傾向於在既有的 mock test 層加量，而非引入新的 test 層。每一輪加量後覆蓋率上升，團隊信心增加，但 mock 遮蔽的盲區從未被觸及。問題在下一次實機測試或 production incident 中再次浮現，觸發新一輪的「test 不夠多」反應。&lt;/p>
&lt;p>打破這個循環的起點是區分「同層覆蓋率不足」和「層級缺失」。如果問題是同層覆蓋率不足（某個分支沒被 test 走到），加 test 有效。如果問題是層級缺失（mock 結構性地遮蔽了某類行為），加同一層的 test 無效，需要引入新的 test 層級。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;p>以下訊號指向「層級缺失」而非「數量不足」：&lt;/p>
&lt;p>&lt;strong>test 全過但實機失敗的 bug 類型集中在外部互動&lt;/strong>：連線問題、認證問題、資料格式問題、逾時問題 — 這些問題的共同特徵是發生在程式碼與外部服務的邊界上，不是程式碼內部的邏輯錯誤。&lt;/p>
&lt;p>&lt;strong>修復後原有 test 不需要改動&lt;/strong>：如果 bug 修復只加了新程式碼（例如新增 auth handshake 步驟）而原有 test 全部不受影響，說明原有 test 從一開始就沒有覆蓋這個行為 — 整個 test 層級不涵蓋這類行為。&lt;/p>
&lt;p>&lt;strong>bug 修復是型別轉換或編碼調整&lt;/strong>：&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code> 這類修復改變的是資料在協議層的表現，不是程式邏輯。在 mock 環境中，這個修改前後的行為完全相同 — mock 不區分 frame type。&lt;/p>
&lt;p>區分「同層覆蓋率不足」和「層級缺失」之後，回到&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 各層職責、驗證目標與盲區的完整論述">三層定義與職責表&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 無法也不應該模擬協議行為">Mock 遮蔽機制分析&lt;/a>中展開。如果判斷結果是層級缺失，&lt;a href="https://tarrragon.github.io/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&lt;/a> 提供引入新層級的決策流程。&lt;/p></description><content:encoded><![CDATA[<p>當 mock test 全過但實機出問題時，常見的第一反應是「test 不夠多」或「覆蓋率不夠高」。這個反應假設 mock test 的問題在數量，而實際上問題在層級 — mock test 驗證的對象和實機暴露的問題不在同一層。增加 mock test 數量擴展的是同一層的覆蓋範圍，不會跨越到另一層。</p>
<h2 id="數量與層級的區別">數量與層級的區別</h2>
<p>app_tunnel 的 192 個 unit test 覆蓋了 <code>ConnectionManager</code>、<code>AnsiParser</code>、<code>TerminalBuffer</code> 等元件的邏輯分支。如果在 mock test 全過但實機失敗後，反應是「再寫 50 個 test」，新寫的 test 會使用同一個 <code>FakeWebSocketChannel</code>，測試更多的邏輯分支 — 更多的輸入組合、更多的邊界條件、更多的錯誤處理路徑。</p>
<p>這 50 個新 test 和原來的 192 個 test 在同一個 mock 環境中執行，受到同一個 <code>FakeWebSocketChannel</code> 的行為限制。<code>FakeWebSocketChannel</code> 不區分 text frame 和 binary frame — 這個限制在第 1 個 test 和第 242 個 test 中都一樣。數量增加了，遮蔽範圍沒有改變。</p>
<p>用類比說明：用純水測試淨水器的過濾效果，不管測 1 杯還是 1000 杯，結論都是「水很乾淨」。問題在測試材料 — 需要用含有雜質的水測試才能驗證過濾功能。Mock 是純水，真實服務互動是含雜質的水。</p>
<h2 id="覆蓋率指標的盲點">覆蓋率指標的盲點</h2>
<p>Line coverage 和 branch coverage 衡量的是「程式碼中有多少行 / 分支被 test 執行過」。這些指標在同一層 test 內有意義 — 100% branch coverage 的 unit test 確保每個 if/else 都被走過。</p>
<p>但覆蓋率指標不區分 test 的依賴環境。一個使用 <code>FakeWebSocketChannel</code> 的 test 和一個使用 <code>IOWebSocketChannel</code> 的 test 走過同一行 <code>sink.add(data)</code> — 在覆蓋率報告中是同一行被覆蓋，但驗證的語意完全不同。</p>
<p>覆蓋率 100% 意味著「在 mock 環境中，所有程式碼分支都被走過」。這不等於「在真實環境中，所有程式碼分支的行為都是正確的」。app_tunnel 的 <code>sendData()</code> 在覆蓋率報告中是「已覆蓋」的，但覆蓋它的 test 用的是不區分 frame type 的 fake。</p>
<h2 id="這個反模式如何在團隊中擴散">這個反模式如何在團隊中擴散</h2>
<p>「test 不夠多」是一個容易執行、容易衡量的回應。在沒有獨立 QA 驗收流程的團隊中（覆蓋率報告是主要品質指標），寫更多 test 可以提高覆蓋率數字，覆蓋率數字上升給團隊信心。相比之下，「需要一個新的 test 層級」需要建置新的 test 環境、學習不同的 test 技術、接受較慢的執行速度。</p>
<p>這個成本差異讓團隊傾向於在既有的 mock test 層加量，而非引入新的 test 層。每一輪加量後覆蓋率上升，團隊信心增加，但 mock 遮蔽的盲區從未被觸及。問題在下一次實機測試或 production incident 中再次浮現，觸發新一輪的「test 不夠多」反應。</p>
<p>打破這個循環的起點是區分「同層覆蓋率不足」和「層級缺失」。如果問題是同層覆蓋率不足（某個分支沒被 test 走到），加 test 有效。如果問題是層級缺失（mock 結構性地遮蔽了某類行為），加同一層的 test 無效，需要引入新的 test 層級。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<p>以下訊號指向「層級缺失」而非「數量不足」：</p>
<p><strong>test 全過但實機失敗的 bug 類型集中在外部互動</strong>：連線問題、認證問題、資料格式問題、逾時問題 — 這些問題的共同特徵是發生在程式碼與外部服務的邊界上，不是程式碼內部的邏輯錯誤。</p>
<p><strong>修復後原有 test 不需要改動</strong>：如果 bug 修復只加了新程式碼（例如新增 auth handshake 步驟）而原有 test 全部不受影響，說明原有 test 從一開始就沒有覆蓋這個行為 — 整個 test 層級不涵蓋這類行為。</p>
<p><strong>bug 修復是型別轉換或編碼調整</strong>：<code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code> 這類修復改變的是資料在協議層的表現，不是程式邏輯。在 mock 環境中，這個修改前後的行為完全相同 — mock 不區分 frame type。</p>
<p>區分「同層覆蓋率不足」和「層級缺失」之後，回到<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>確認每層的邊界。Mock 遮蔽的結構性原因在 <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/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>
]]></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>開發環境 vs 真機的 gate 行為差異表</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/</guid><description>&lt;p>開發環境遮蔽 gate 問題的機制是：模擬器或 debug build 中的 gate 行為比真機寬鬆，讓問題在開發階段不可見，直到實機測試或 production 才浮現。這和 mock 遮蔽 protocol 問題的機制結構相同（&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>
&lt;h2 id="差異機制">差異機制&lt;/h2>
&lt;h3 id="模擬器不支援硬體功能">模擬器不支援硬體功能&lt;/h3>
&lt;p>iOS 模擬器不支援 Face ID / Touch ID 硬體。&lt;code>local_auth&lt;/code> 的 &lt;code>isAvailable()&lt;/code> 在模擬器上回傳 &lt;code>false&lt;/code>（&lt;code>isDeviceSupported()&lt;/code> 為 &lt;code>true&lt;/code> 但 &lt;code>getAvailableBiometrics()&lt;/code> 為空），app 跳過認證走預設路徑。&lt;/p>
&lt;p>在真機上 &lt;code>isAvailable()&lt;/code> 回傳 &lt;code>true&lt;/code>，app 嘗試認證，如果設定了 &lt;code>biometricOnly: true&lt;/code> 且 Face ID 失敗，使用者被擋住。模擬器上「跳過認證直接使用」的體驗讓開發者以為認證流程沒有問題（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h3 id="debug-build-的權限行為不同">Debug build 的權限行為不同&lt;/h3>
&lt;p>某些平台在 debug build 和 release build 的權限處理不同。例如 Android 的某些 OEM 客製化系統在 debug mode 下自動授予特定權限，release mode 下需要手動授權。&lt;/p>
&lt;h3 id="test-環境跳過-gate">Test 環境跳過 gate&lt;/h3>
&lt;p>Unit test 和 integration test 通常 mock 掉所有 gate — &lt;code>FakeBiometricService&lt;/code> 永遠回傳成功，&lt;code>FakeNetworkChecker&lt;/code> 永遠回傳已連線。這和名義 integration test 的問題相同 — test 環境的「一切正常」遮蔽了真實環境的 gate 失敗場景。&lt;/p>
&lt;h2 id="gate-行為差異表">Gate 行為差異表&lt;/h2>
&lt;p>在功能規格中建立一張差異表，列出每個 gate 在不同環境下的行為差異：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&lt;/th>
 &lt;th>模擬器行為&lt;/th>
 &lt;th>真機 debug&lt;/th>
 &lt;th>真機 release&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;td>正常&lt;/td>
 &lt;td>模擬器上看不到 fallback 缺失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路連線&lt;/td>
 &lt;td>通常正常（host 網路）&lt;/td>
 &lt;td>可斷 WiFi 測試&lt;/td>
 &lt;td>行動網路 + WiFi&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;td>模擬器無法測試真實權限流程&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>藍牙&lt;/td>
 &lt;td>不支援&lt;/td>
 &lt;td>可測試&lt;/td>
 &lt;td>正常&lt;/td>
 &lt;td>模擬器完全跳過藍牙相關功能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Push 通知&lt;/td>
 &lt;td>不支援（iOS 模擬器）&lt;/td>
 &lt;td>可測試&lt;/td>
 &lt;td>正常&lt;/td>
 &lt;td>通知觸發的導航路徑在模擬器不可測&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>App 簽名驗證&lt;/td>
 &lt;td>debug 簽名自動通過&lt;/td>
 &lt;td>debug 簽名&lt;/td>
 &lt;td>release 簽名&lt;/td>
 &lt;td>簽名相關的 gate 只在 release 生效&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="差異表的使用方式">差異表的使用方式&lt;/h2>
&lt;h3 id="開發階段">開發階段&lt;/h3>
&lt;p>開發者對照差異表，意識到哪些 gate 在當前環境下沒有被真實驗證。差異表中「模擬器行為」和「真機 release」不同的行 = 需要上真機確認的項目。&lt;/p>
&lt;h3 id="實機測試規劃">實機測試規劃&lt;/h3>
&lt;p>測試計畫中針對差異表的每一行設計測試案例。生物辨識的測試案例必須涵蓋「Face ID 失敗時的 fallback」，網路連線的測試案例必須涵蓋「飛航模式下的 UX」。&lt;/p>
&lt;h3 id="code-review">Code review&lt;/h3>
&lt;p>Review 涉及 gate 的程式碼時，對照差異表確認 fallback 路徑是否存在。如果 review 的程式碼用了 &lt;code>biometricOnly: true&lt;/code>，差異表立刻提示「模擬器上看不到這個問題，需要上真機確認 fallback」。&lt;/p>
&lt;p>差異表揭露的問題和 testing 領域的 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>從 API 層 vs 協議層的角度分析同一類問題。差異表本身是&lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">三問設計法&lt;/a>在實機驗證階段的延伸，biometric gate 的完整 fallback 設計見 &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>開發環境遮蔽 gate 問題的機制是：模擬器或 debug build 中的 gate 行為比真機寬鬆，讓問題在開發階段不可見，直到實機測試或 production 才浮現。這和 mock 遮蔽 protocol 問題的機制結構相同（<a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一</a>）— 開發環境的「寬鬆模式」讓功能缺失變得不可見。</p>
<h2 id="差異機制">差異機制</h2>
<h3 id="模擬器不支援硬體功能">模擬器不支援硬體功能</h3>
<p>iOS 模擬器不支援 Face ID / Touch ID 硬體。<code>local_auth</code> 的 <code>isAvailable()</code> 在模擬器上回傳 <code>false</code>（<code>isDeviceSupported()</code> 為 <code>true</code> 但 <code>getAvailableBiometrics()</code> 為空），app 跳過認證走預設路徑。</p>
<p>在真機上 <code>isAvailable()</code> 回傳 <code>true</code>，app 嘗試認證，如果設定了 <code>biometricOnly: true</code> 且 Face ID 失敗，使用者被擋住。模擬器上「跳過認證直接使用」的體驗讓開發者以為認證流程沒有問題（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h3 id="debug-build-的權限行為不同">Debug build 的權限行為不同</h3>
<p>某些平台在 debug build 和 release build 的權限處理不同。例如 Android 的某些 OEM 客製化系統在 debug mode 下自動授予特定權限，release mode 下需要手動授權。</p>
<h3 id="test-環境跳過-gate">Test 環境跳過 gate</h3>
<p>Unit test 和 integration test 通常 mock 掉所有 gate — <code>FakeBiometricService</code> 永遠回傳成功，<code>FakeNetworkChecker</code> 永遠回傳已連線。這和名義 integration test 的問題相同 — test 環境的「一切正常」遮蔽了真實環境的 gate 失敗場景。</p>
<h2 id="gate-行為差異表">Gate 行為差異表</h2>
<p>在功能規格中建立一張差異表，列出每個 gate 在不同環境下的行為差異：</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>模擬器行為</th>
          <th>真機 debug</th>
          <th>真機 release</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>生物辨識</td>
          <td>跳過（硬體不可用）</td>
          <td>可測試（需設定）</td>
          <td>正常</td>
          <td>模擬器上看不到 fallback 缺失</td>
      </tr>
      <tr>
          <td>網路連線</td>
          <td>通常正常（host 網路）</td>
          <td>可斷 WiFi 測試</td>
          <td>行動網路 + WiFi</td>
          <td>模擬器的網路狀態不代表行動網路</td>
      </tr>
      <tr>
          <td>相機權限</td>
          <td>無相機（或虛擬相機）</td>
          <td>可測試</td>
          <td>正常</td>
          <td>模擬器無法測試真實權限流程</td>
      </tr>
      <tr>
          <td>藍牙</td>
          <td>不支援</td>
          <td>可測試</td>
          <td>正常</td>
          <td>模擬器完全跳過藍牙相關功能</td>
      </tr>
      <tr>
          <td>Push 通知</td>
          <td>不支援（iOS 模擬器）</td>
          <td>可測試</td>
          <td>正常</td>
          <td>通知觸發的導航路徑在模擬器不可測</td>
      </tr>
      <tr>
          <td>App 簽名驗證</td>
          <td>debug 簽名自動通過</td>
          <td>debug 簽名</td>
          <td>release 簽名</td>
          <td>簽名相關的 gate 只在 release 生效</td>
      </tr>
  </tbody>
</table>
<h2 id="差異表的使用方式">差異表的使用方式</h2>
<h3 id="開發階段">開發階段</h3>
<p>開發者對照差異表，意識到哪些 gate 在當前環境下沒有被真實驗證。差異表中「模擬器行為」和「真機 release」不同的行 = 需要上真機確認的項目。</p>
<h3 id="實機測試規劃">實機測試規劃</h3>
<p>測試計畫中針對差異表的每一行設計測試案例。生物辨識的測試案例必須涵蓋「Face ID 失敗時的 fallback」，網路連線的測試案例必須涵蓋「飛航模式下的 UX」。</p>
<h3 id="code-review">Code review</h3>
<p>Review 涉及 gate 的程式碼時，對照差異表確認 fallback 路徑是否存在。如果 review 的程式碼用了 <code>biometricOnly: true</code>，差異表立刻提示「模擬器上看不到這個問題，需要上真機確認 fallback」。</p>
<p>差異表揭露的問題和 testing 領域的 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>從 API 層 vs 協議層的角度分析同一類問題。差異表本身是<a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">三問設計法</a>在實機驗證階段的延伸，biometric gate 的完整 fallback 設計見 <a href="/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計</a>。</p>
]]></content:encoded></item><item><title>模組五：測試設計判斷</title><link>https://tarrragon.github.io/blog/testing/05-test-design-judgment/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/05-test-design-judgment/</guid><description>&lt;p>回答「這個斷言該怎麼寫」「這個 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-4&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3&lt;/a>&lt;/td>
 &lt;td>手寫測試資料是真實環境的乾淨子集&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TF-5&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3&lt;/a>&lt;/td>
 &lt;td>Parser 透傳未知序列的靜默副作用&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Mock 邊界判斷決策表（什麼時候 mock 夠、什麼時候需要 real）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Test data 代表性（手寫 vs 錄製 vs 生成）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Assertion 品質三問（斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Flaky test 根因分類（計時依賴 / 環境差異 / 資源競爭 / 非確定性）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/05-platform-adaptation/" data-link-title="模組五：平台適配" data-link-desc="JS CORS / Flutter isolate / Python GIL / Go graceful shutdown — 各平台的特殊考量">monitoring 模組五 平台適配&lt;/a>：各平台的 error 攔截機制差異影響 test 設計&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「這個斷言該怎麼寫」「這個 mock 邊界對嗎」。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>TF-4</td>
          <td><a href="/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3</a></td>
          <td>手寫測試資料是真實環境的乾淨子集</td>
      </tr>
      <tr>
          <td>TF-5</td>
          <td><a href="/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3</a></td>
          <td>Parser 透傳未知序列的靜默副作用</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Mock 邊界判斷決策表（什麼時候 mock 夠、什麼時候需要 real）</li>
<li><input checked="" disabled="" type="checkbox"> Test data 代表性（手寫 vs 錄製 vs 生成）</li>
<li><input checked="" disabled="" type="checkbox"> Assertion 品質三問（斷言的是行為嗎？能區分正確和錯誤嗎？會 flaky 嗎？）</li>
<li><input checked="" disabled="" type="checkbox"> Flaky test 根因分類（計時依賴 / 環境差異 / 資源競爭 / 非確定性）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/monitoring/05-platform-adaptation/" data-link-title="模組五：平台適配" data-link-desc="JS CORS / Flutter isolate / Python GIL / Go graceful shutdown — 各平台的特殊考量">monitoring 模組五 平台適配</a>：各平台的 error 攔截機制差異影響 test 設計</li>
</ul>
]]></content:encoded></item><item><title>5.5 時間注入與 deterministic test</title><link>https://tarrragon.github.io/blog/go/05-error-testing/time-injection/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/time-injection/</guid><description>&lt;p>時間注入的核心目標是讓測試可以控制「現在時間」。只要函式內部直接呼叫 &lt;code>time.Now()&lt;/code>，測試結果就可能受執行時間影響；把時間來源改成參數或小介面後，測試就能重現固定情境。&lt;/p>
&lt;h2 id="真實時間會讓測試不穩定">真實時間會讓測試不穩定&lt;/h2>
&lt;p>測試不穩定的核心原因是輸入不完全由測試控制。時間是最常見的隱性輸入，因為 &lt;code>time.Now()&lt;/code> 每次呼叫都會得到不同結果。&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">IsExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">bool&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="k">return&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Now&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個函式看起來只有一個參數，但實際上還依賴目前時間。測試如果用 &lt;code>time.Now().Add(...)&lt;/code> 組 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline&lt;/a>，可能因為執行延遲、時區或邊界條件而變得脆弱。&lt;/p>
&lt;p>更好的做法是把現在時間傳進去。&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">IsExpired&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kt">bool&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="k">return&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>函式的所有重要輸入都變成參數後，測試可以完全控制情境。&lt;/p>
&lt;h2 id="參數注入適合純邏輯">參數注入適合純邏輯&lt;/h2>
&lt;p>參數注入的核心用途是處理單次計算。當函式只是判斷過期、計算剩餘時間或產生 timestamp，直接把 &lt;code>now time.Time&lt;/code> 傳入通常最簡單。&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">Remaining&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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="k">if&lt;/span> &lt;span class="nx">now&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">deadline&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="k">return&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &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="k">return&lt;/span> &lt;span class="nx">deadline&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sub&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span>&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>測試可以建立固定時間點。&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">TestRemaining&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">now&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">deadline&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Remaining&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">deadline&lt;/span>&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="nx">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">5&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Minute&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">
&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">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&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;Remaining() = %v, want %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個測試不會因為今天是哪一天、測試跑得快或慢而改變結果。參數注入也讓函式更容易理解，因為時間依賴直接出現在函式簽名中。&lt;/p>
&lt;h2 id="provider-函式適合需要多次取時間的元件">provider 函式適合需要多次取時間的元件&lt;/h2>
&lt;p>時間 provider 的核心用途是讓長生命週期元件可以取得目前時間，但測試仍能替換時間來源。最簡單的 provider 是 &lt;code>func() time.Time&lt;/code>。&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">type&lt;/span> &lt;span class="nx">TokenGenerator&lt;/span> &lt;span class="kd">struct&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">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&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="kd">func&lt;/span> &lt;span class="nf">NewTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">now&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">TokenGenerator&lt;/span> &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="k">return&lt;/span> &lt;span class="nx">TokenGenerator&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nx">now&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">now&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">g&lt;/span> &lt;span class="nx">TokenGenerator&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">NewToken&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">userID&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">Token&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="k">return&lt;/span> &lt;span class="nx">Token&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="nx">UserID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">userID&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">CreatedAt&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nx">g&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">now&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>time.Now&lt;/code>。&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="nx">generator&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Now&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>測試可以傳入固定時間。&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">TestTokenGenerator&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">fixedNow&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&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">generator&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">NewTokenGenerator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">func&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Time&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="k">return&lt;/span> &lt;span class="nx">fixedNow&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">token&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">generator&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">NewToken&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;user_123&amp;#34;&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="nx">token&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Equal&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">fixedNow&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="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;CreatedAt = %v, want %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">token&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CreatedAt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">fixedNow&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>func() time.Time&lt;/code> 比完整介面更輕量，適合只需要目前時間的情境。若元件還需要 timer、ticker 或 sleep，才需要更完整的 clock abstraction。&lt;/p>
&lt;h2 id="duration-測試應控制時間">duration 測試應控制時間&lt;/h2>
&lt;p>測試 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 的核心原則是驗證邏輯，不是讓測試真的睡很久。&lt;code>time.Sleep&lt;/code> 會讓測試慢，也會讓測試受排程影響。&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">RetryDelay&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">attempt&lt;/span> &lt;span class="kt">int&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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="k">if&lt;/span> &lt;span class="nx">attempt&lt;/span> &lt;span class="o">&amp;lt;=&lt;/span> &lt;span class="mi">0&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="k">return&lt;/span> &lt;span class="mi">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &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="k">return&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Duration&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">attempt&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="mi">100&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Millisecond&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這種邏輯應該直接測回傳的 duration。&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">TestRetryDelay&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">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">RetryDelay&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&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">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="mi">300&lt;/span> &lt;span class="o">*&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Millisecond&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="k">if&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="nx">want&lt;/span> &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="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;RetryDelay() = %v, want %v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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="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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>若真的要測等待行為，應把等待機制包成可替換依賴，讓測試使用 fake sleeper，而不是呼叫真實 &lt;code>time.Sleep&lt;/code>。&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">type&lt;/span> &lt;span class="nx">Sleeper&lt;/span> &lt;span class="kd">interface&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="nf">Sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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="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="kd">type&lt;/span> &lt;span class="nx">realSleeper&lt;/span> &lt;span class="kd">struct&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="kd">func&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">realSleeper&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nf">Sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">d&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Duration&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="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">d&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;/code>&lt;/pre>&lt;/div>&lt;p>這種抽象只有在等待行為本身需要測試時才值得加入。不要為了形式而把所有 &lt;code>time&lt;/code> API 都包起來。&lt;/p>
&lt;h2 id="時區要明確">時區要明確&lt;/h2>
&lt;p>時間測試的核心規則是使用明確時區。測試資料若依賴本機時區，可能在不同開發機或 CI 環境得到不同結果。&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="nx">createdAt&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">10&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">UTC&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>使用 &lt;code>time.UTC&lt;/code> 能讓測試在不同環境保持一致。若功能本來就和特定時區有關，應用 &lt;code>time.LoadLocation&lt;/code> 明確載入。&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="nx">loc&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">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">LoadLocation&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s">&amp;#34;Asia/Taipei&amp;#34;&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="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">3&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;load location: %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">4&lt;/span>&lt;span class="cl">&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="nx">localTime&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Date&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2026&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">4&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">22&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">18&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">loc&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不要讓測試默默依賴 &lt;code>time.Local&lt;/code>，除非測試目的就是驗證本機時區設定。&lt;/p>
&lt;h2 id="下一章">下一章&lt;/h2>
&lt;p>下一章會進入並發行為測試，說明如何驗證 goroutine、channel 與共享狀態。&lt;/p>
&lt;h2 id="延伸閱讀">延伸閱讀&lt;/h2>
&lt;p>本章處理入門測試中的時間依賴。若要測長時間 worker、ticker 排程、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket&lt;/a> cleanup 或 deadline，可以接著閱讀 &lt;a href="https://tarrragon.github.io/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">Go 進階：時間注入與狀態轉移測試&lt;/a>；若 timeout 來自部署平台或 load balancer，則閱讀 &lt;a href="https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>時間注入的核心目標是讓測試可以控制「現在時間」。只要函式內部直接呼叫 <code>time.Now()</code>，測試結果就可能受執行時間影響；把時間來源改成參數或小介面後，測試就能重現固定情境。</p>
<h2 id="真實時間會讓測試不穩定">真實時間會讓測試不穩定</h2>
<p>測試不穩定的核心原因是輸入不完全由測試控制。時間是最常見的隱性輸入，因為 <code>time.Now()</code> 每次呼叫都會得到不同結果。</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">IsExpired</span><span class="p">(</span><span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Now</span><span class="p">().</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式看起來只有一個參數，但實際上還依賴目前時間。測試如果用 <code>time.Now().Add(...)</code> 組 <a href="/blog/backend/knowledge-cards/deadline/" data-link-title="Deadline" data-link-desc="說明整體操作的截止時間如何沿著服務邊界傳遞">deadline</a>，可能因為執行延遲、時區或邊界條件而變得脆弱。</p>
<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">IsExpired</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="kt">bool</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">return</span> <span class="nx">now</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>函式的所有重要輸入都變成參數後，測試可以完全控制情境。</p>
<h2 id="參數注入適合純邏輯">參數注入適合純邏輯</h2>
<p>參數注入的核心用途是處理單次計算。當函式只是判斷過期、計算剩餘時間或產生 timestamp，直接把 <code>now time.Time</code> 傳入通常最簡單。</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">Remaining</span><span class="p">(</span><span class="nx">now</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">,</span> <span class="nx">deadline</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">now</span><span class="p">.</span><span class="nf">After</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">3</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="nx">deadline</span><span class="p">.</span><span class="nf">Sub</span><span class="p">(</span><span class="nx">now</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">6</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">TestRemaining</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">now</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</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">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">5</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</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">got</span> <span class="o">:=</span> <span class="nf">Remaining</span><span class="p">(</span><span class="nx">now</span><span class="p">,</span> <span class="nx">deadline</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="mi">5</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Minute</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</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;Remaining() = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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="p">}</span></span></span></code></pre></div><p>這個測試不會因為今天是哪一天、測試跑得快或慢而改變結果。參數注入也讓函式更容易理解，因為時間依賴直接出現在函式簽名中。</p>
<h2 id="provider-函式適合需要多次取時間的元件">provider 函式適合需要多次取時間的元件</h2>
<p>時間 provider 的核心用途是讓長生命週期元件可以取得目前時間，但測試仍能替換時間來源。最簡單的 provider 是 <code>func() time.Time</code>。</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">type</span> <span class="nx">TokenGenerator</span> <span class="kd">struct</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><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="kd">func</span> <span class="nf">NewTokenGenerator</span><span class="p">(</span><span class="nx">now</span> <span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span><span class="p">)</span> <span class="nx">TokenGenerator</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">TokenGenerator</span><span class="p">{</span><span class="nx">now</span><span class="p">:</span> <span class="nx">now</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></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="kd">func</span> <span class="p">(</span><span class="nx">g</span> <span class="nx">TokenGenerator</span><span class="p">)</span> <span class="nf">NewToken</span><span class="p">(</span><span class="nx">userID</span> <span class="kt">string</span><span class="p">)</span> <span class="nx">Token</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="nx">Token</span><span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="nx">UserID</span><span class="p">:</span>    <span class="nx">userID</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="nx">CreatedAt</span><span class="p">:</span> <span class="nx">g</span><span class="p">.</span><span class="nf">now</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>time.Now</code>。</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="nx">generator</span> <span class="o">:=</span> <span class="nf">NewTokenGenerator</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Now</span><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">TestTokenGenerator</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">fixedNow</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">generator</span> <span class="o">:=</span> <span class="nf">NewTokenGenerator</span><span class="p">(</span><span class="kd">func</span><span class="p">()</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Time</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">        <span class="k">return</span> <span class="nx">fixedNow</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></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">token</span> <span class="o">:=</span> <span class="nx">generator</span><span class="p">.</span><span class="nf">NewToken</span><span class="p">(</span><span class="s">&#34;user_123&#34;</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="nx">token</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">.</span><span class="nf">Equal</span><span class="p">(</span><span class="nx">fixedNow</span><span class="p">)</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;CreatedAt = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">token</span><span class="p">.</span><span class="nx">CreatedAt</span><span class="p">,</span> <span class="nx">fixedNow</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="p">}</span></span></span></code></pre></div><p><code>func() time.Time</code> 比完整介面更輕量，適合只需要目前時間的情境。若元件還需要 timer、ticker 或 sleep，才需要更完整的 clock abstraction。</p>
<h2 id="duration-測試應控制時間">duration 測試應控制時間</h2>
<p>測試 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 的核心原則是驗證邏輯，不是讓測試真的睡很久。<code>time.Sleep</code> 會讓測試慢，也會讓測試受排程影響。</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">RetryDelay</span><span class="p">(</span><span class="nx">attempt</span> <span class="kt">int</span><span class="p">)</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">if</span> <span class="nx">attempt</span> <span class="o">&lt;=</span> <span class="mi">0</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="k">return</span> <span class="mi">0</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <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="k">return</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Duration</span><span class="p">(</span><span class="nx">attempt</span><span class="p">)</span> <span class="o">*</span> <span class="mi">100</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種邏輯應該直接測回傳的 duration。</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">TestRetryDelay</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">got</span> <span class="o">:=</span> <span class="nf">RetryDelay</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="mi">300</span> <span class="o">*</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Millisecond</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="k">if</span> <span class="nx">got</span> <span class="o">!=</span> <span class="nx">want</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</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;RetryDelay() = %v, want %v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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="p">}</span></span></span></code></pre></div><p>若真的要測等待行為，應把等待機制包成可替換依賴，讓測試使用 fake sleeper，而不是呼叫真實 <code>time.Sleep</code>。</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">type</span> <span class="nx">Sleeper</span> <span class="kd">interface</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="nf">Sleep</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><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="kd">type</span> <span class="nx">realSleeper</span> <span class="kd">struct</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="kd">func</span> <span class="p">(</span><span class="nx">realSleeper</span><span class="p">)</span> <span class="nf">Sleep</span><span class="p">(</span><span class="nx">d</span> <span class="nx">time</span><span class="p">.</span><span class="nx">Duration</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="nx">time</span><span class="p">.</span><span class="nf">Sleep</span><span class="p">(</span><span class="nx">d</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這種抽象只有在等待行為本身需要測試時才值得加入。不要為了形式而把所有 <code>time</code> API 都包起來。</p>
<h2 id="時區要明確">時區要明確</h2>
<p>時間測試的核心規則是使用明確時區。測試資料若依賴本機時區，可能在不同開發機或 CI 環境得到不同結果。</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="nx">createdAt</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">10</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">time</span><span class="p">.</span><span class="nx">UTC</span><span class="p">)</span></span></span></code></pre></div><p>使用 <code>time.UTC</code> 能讓測試在不同環境保持一致。若功能本來就和特定時區有關，應用 <code>time.LoadLocation</code> 明確載入。</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="nx">loc</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">LoadLocation</span><span class="p">(</span><span class="s">&#34;Asia/Taipei&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">2</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">3</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;load location: %v&#34;</span><span class="p">,</span> <span class="nx">err</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><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="nx">localTime</span> <span class="o">:=</span> <span class="nx">time</span><span class="p">.</span><span class="nf">Date</span><span class="p">(</span><span class="mi">2026</span><span class="p">,</span> <span class="mi">4</span><span class="p">,</span> <span class="mi">22</span><span class="p">,</span> <span class="mi">18</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="nx">loc</span><span class="p">)</span></span></span></code></pre></div><p>不要讓測試默默依賴 <code>time.Local</code>，除非測試目的就是驗證本機時區設定。</p>
<h2 id="下一章">下一章</h2>
<p>下一章會進入並發行為測試，說明如何驗證 goroutine、channel 與共享狀態。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本章處理入門測試中的時間依賴。若要測長時間 worker、ticker 排程、<a href="/blog/backend/knowledge-cards/websocket/" data-link-title="WebSocket" data-link-desc="說明 WebSocket 如何提供長連線雙向即時通訊">WebSocket</a> cleanup 或 deadline，可以接著閱讀 <a href="/blog/go-advanced/05-testing-reliability/time-control/" data-link-title="5.1 時間注入與狀態轉移測試" data-link-desc="讓時間相關邏輯可重現">Go 進階：時間注入與狀態轉移測試</a>；若 timeout 來自部署平台或 load balancer，則閱讀 <a href="/blog/go-advanced/07-distributed-operations/deployment-contracts/" data-link-title="7.5 Kubernetes、systemd 與 load balancer 合約" data-link-desc="理解部署平台如何影響 Go 服務的 shutdown、health 與資源限制">Go 進階：Kubernetes、systemd 與 load balancer 合約</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>模組五：錯誤處理與測試</title><link>https://tarrragon.github.io/blog/go/05-error-testing/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/</guid><description>&lt;p>Go 的錯誤處理偏向顯式：錯誤是回傳值，呼叫者要直接面對。Go 的測試也偏向直接：建立輸入、執行函式、檢查輸出。本模組把錯誤處理、單元測試、table-driven test、HTTP 測試與並發測試串成一組可落地的驗證方法。&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/05-error-testing/errors/" data-link-title="5.1 錯誤回傳與早期返回" data-link-desc="寫出可追蹤的失敗路徑">5.1&lt;/a>&lt;/td>
 &lt;td>錯誤回傳與早期返回&lt;/td>
 &lt;td>寫出可追蹤的失敗路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">5.2&lt;/a>&lt;/td>
 &lt;td>testing 基礎&lt;/td>
 &lt;td>用 &lt;code>testing&lt;/code> package 驗證函式行為&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">5.3&lt;/a>&lt;/td>
 &lt;td>table-driven test&lt;/td>
 &lt;td>用表格整理多組輸入輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/http-handler-test/" data-link-title="5.4 HTTP handler 測試" data-link-desc="用 httptest 驗證 request 與 response">5.4&lt;/a>&lt;/td>
 &lt;td>HTTP handler 測試&lt;/td>
 &lt;td>用 &lt;code>httptest&lt;/code> 驗證 request/response&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/time-injection/" data-link-title="5.5 時間注入與 deterministic test" data-link-desc="用 time provider 避免測試依賴真實時間">5.5&lt;/a>&lt;/td>
 &lt;td>時間注入與 deterministic test&lt;/td>
 &lt;td>避免測試依賴真實時間&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/concurrency-test/" data-link-title="5.6 並發行為測試" data-link-desc="測試 channel、goroutine 與狀態更新">5.6&lt;/a>&lt;/td>
 &lt;td>並發行為測試&lt;/td>
 &lt;td>測試 channel、goroutine 與狀態更新&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/service-reliability/" data-link-title="5.7 錯誤處理與測試在高併發服務中的角色" data-link-desc="把錯誤路徑、測試保護與並發行為放進服務可靠性觀點">5.7&lt;/a>&lt;/td>
 &lt;td>錯誤處理與測試在高併發服務中的角色&lt;/td>
 &lt;td>把錯誤路徑與並發邊界納入可靠性觀點&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="本模組使用的範例主題">本模組使用的範例主題&lt;/h2>
&lt;ul>
&lt;li>函式單元測試&lt;/li>
&lt;li>table-driven test&lt;/li>
&lt;li>HTTP handler 測試&lt;/li>
&lt;li>時間相關測試&lt;/li>
&lt;li>channel 與 goroutine 測試&lt;/li>
&lt;/ul>
&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 2 小時&lt;/p></description><content:encoded><![CDATA[<p>Go 的錯誤處理偏向顯式：錯誤是回傳值，呼叫者要直接面對。Go 的測試也偏向直接：建立輸入、執行函式、檢查輸出。本模組把錯誤處理、單元測試、table-driven test、HTTP 測試與並發測試串成一組可落地的驗證方法。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/go/05-error-testing/errors/" data-link-title="5.1 錯誤回傳與早期返回" data-link-desc="寫出可追蹤的失敗路徑">5.1</a></td>
          <td>錯誤回傳與早期返回</td>
          <td>寫出可追蹤的失敗路徑</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">5.2</a></td>
          <td>testing 基礎</td>
          <td>用 <code>testing</code> package 驗證函式行為</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/table-driven-test/" data-link-title="5.3 table-driven test" data-link-desc="用表格整理多組輸入、預期輸出與錯誤情境">5.3</a></td>
          <td>table-driven test</td>
          <td>用表格整理多組輸入輸出</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/http-handler-test/" data-link-title="5.4 HTTP handler 測試" data-link-desc="用 httptest 驗證 request 與 response">5.4</a></td>
          <td>HTTP handler 測試</td>
          <td>用 <code>httptest</code> 驗證 request/response</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/time-injection/" data-link-title="5.5 時間注入與 deterministic test" data-link-desc="用 time provider 避免測試依賴真實時間">5.5</a></td>
          <td>時間注入與 deterministic test</td>
          <td>避免測試依賴真實時間</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/concurrency-test/" data-link-title="5.6 並發行為測試" data-link-desc="測試 channel、goroutine 與狀態更新">5.6</a></td>
          <td>並發行為測試</td>
          <td>測試 channel、goroutine 與狀態更新</td>
      </tr>
      <tr>
          <td><a href="/blog/go/05-error-testing/service-reliability/" data-link-title="5.7 錯誤處理與測試在高併發服務中的角色" data-link-desc="把錯誤路徑、測試保護與並發行為放進服務可靠性觀點">5.7</a></td>
          <td>錯誤處理與測試在高併發服務中的角色</td>
          <td>把錯誤路徑與並發邊界納入可靠性觀點</td>
      </tr>
  </tbody>
</table>
<h2 id="本模組使用的範例主題">本模組使用的範例主題</h2>
<ul>
<li>函式單元測試</li>
<li>table-driven test</li>
<li>HTTP handler 測試</li>
<li>時間相關測試</li>
<li>channel 與 goroutine 測試</li>
</ul>
<h2 id="學習時間">學習時間</h2>
<p>預計 2 小時</p>
]]></content:encoded></item><item><title>模組五：錯誤處理與測試</title><link>https://tarrragon.github.io/blog/python/05-error-testing/</link><pubDate>Tue, 20 Jan 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/python/05-error-testing/</guid><description>&lt;p>穩健的程式碼需要妥善處理錯誤情況，並透過測試確保品質。本模組介紹 Python 的異常處理策略和測試技巧。&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/python/05-error-testing/exception/" data-link-title="5.1 異常處理策略" data-link-desc="何時捕獲、何時拋出">5.1&lt;/a>&lt;/td>
 &lt;td>異常處理策略&lt;/td>
 &lt;td>何時捕獲、何時拋出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/return-values/" data-link-title="5.2 返回值設計" data-link-desc="(bool, str) 模式的應用">5.2&lt;/a>&lt;/td>
 &lt;td>返回值設計&lt;/td>
 &lt;td>&lt;code>(bool, str)&lt;/code> 模式的應用&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/unittest/" data-link-title="5.3 unittest 基礎" data-link-desc="撰寫第一個單元測試">5.3&lt;/a>&lt;/td>
 &lt;td>unittest 基礎&lt;/td>
 &lt;td>撰寫第一個測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/mock/" data-link-title="5.4 Mock 與測試隔離" data-link-desc="隔離外部依賴">5.4&lt;/a>&lt;/td>
 &lt;td>Mock 與測試隔離&lt;/td>
 &lt;td>隔離外部依賴&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5&lt;/a>&lt;/td>
 &lt;td>頂層例外處理機制&lt;/td>
 &lt;td>&lt;code>run_hook_safely&lt;/code> 與統一錯誤基礎設施&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/python/05-error-testing/observability-design/" data-link-title="5.6 Hook 系統可觀測性設計" data-link-desc="日誌架構、錯誤可見性、健康監控：讓 44 個 Hook 的運行狀態透明可追蹤">5.6&lt;/a>&lt;/td>
 &lt;td>Hook 系統可觀測性設計&lt;/td>
 &lt;td>日誌架構、錯誤可見性、健康監控&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="實際範例來源">實際範例來源&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>主題&lt;/th>
 &lt;th>範例來源&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>異常處理&lt;/td>
 &lt;td>&lt;code>git_utils.py&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>返回值設計&lt;/td>
 &lt;td>Hook 系統的 &lt;code>(bool, str)&lt;/code> 模式&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>unittest&lt;/td>
 &lt;td>&lt;code>tests/&lt;/code> 目錄&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mock&lt;/td>
 &lt;td>&lt;code>test_hook_io.py&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>頂層例外處理&lt;/td>
 &lt;td>&lt;code>hook_utils.py&lt;/code>（W22-W25）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>可觀測性設計&lt;/td>
 &lt;td>&lt;code>hook_utils.py&lt;/code> 日誌架構 + IMP-003/005/006 錯誤模式&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="核心理念">核心理念&lt;/h2>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1"># Hook 系統的返回值設計模式&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">validate_something&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">tuple&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">bool&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&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="s2">&amp;#34;&amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s2"> 返回 (成功與否, 訊息)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s2"> - True, &amp;#34;成功訊息&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 class="s2"> - False, &amp;#34;錯誤訊息&amp;#34; - 驗證失敗
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s2"> &amp;#34;&amp;#34;&amp;#34;&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="n">some_condition&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="k">return&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;驗證通過&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;驗證失敗：原因說明&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="學習路徑">學習路徑&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">異常處理 → 返回值設計 → unittest 基礎 → Mock 技巧 → 頂層例外處理機制 → 可觀測性設計&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="學習時間">學習時間&lt;/h2>
&lt;p>預計 90-110 分鐘&lt;/p></description><content:encoded><![CDATA[<p>穩健的程式碼需要妥善處理錯誤情況，並透過測試確保品質。本模組介紹 Python 的異常處理策略和測試技巧。</p>
<h2 id="章節列表">章節列表</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>主題</th>
          <th>關鍵收穫</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/python/05-error-testing/exception/" data-link-title="5.1 異常處理策略" data-link-desc="何時捕獲、何時拋出">5.1</a></td>
          <td>異常處理策略</td>
          <td>何時捕獲、何時拋出</td>
      </tr>
      <tr>
          <td><a href="/blog/python/05-error-testing/return-values/" data-link-title="5.2 返回值設計" data-link-desc="(bool, str) 模式的應用">5.2</a></td>
          <td>返回值設計</td>
          <td><code>(bool, str)</code> 模式的應用</td>
      </tr>
      <tr>
          <td><a href="/blog/python/05-error-testing/unittest/" data-link-title="5.3 unittest 基礎" data-link-desc="撰寫第一個單元測試">5.3</a></td>
          <td>unittest 基礎</td>
          <td>撰寫第一個測試</td>
      </tr>
      <tr>
          <td><a href="/blog/python/05-error-testing/mock/" data-link-title="5.4 Mock 與測試隔離" data-link-desc="隔離外部依賴">5.4</a></td>
          <td>Mock 與測試隔離</td>
          <td>隔離外部依賴</td>
      </tr>
      <tr>
          <td><a href="/blog/python/05-error-testing/error-infrastructure/" data-link-title="5.5 頂層例外處理機制" data-link-desc="run_hook_safely 與統一錯誤基礎設施">5.5</a></td>
          <td>頂層例外處理機制</td>
          <td><code>run_hook_safely</code> 與統一錯誤基礎設施</td>
      </tr>
      <tr>
          <td><a href="/blog/python/05-error-testing/observability-design/" data-link-title="5.6 Hook 系統可觀測性設計" data-link-desc="日誌架構、錯誤可見性、健康監控：讓 44 個 Hook 的運行狀態透明可追蹤">5.6</a></td>
          <td>Hook 系統可觀測性設計</td>
          <td>日誌架構、錯誤可見性、健康監控</td>
      </tr>
  </tbody>
</table>
<h2 id="實際範例來源">實際範例來源</h2>
<table>
  <thead>
      <tr>
          <th>主題</th>
          <th>範例來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>異常處理</td>
          <td><code>git_utils.py</code></td>
      </tr>
      <tr>
          <td>返回值設計</td>
          <td>Hook 系統的 <code>(bool, str)</code> 模式</td>
      </tr>
      <tr>
          <td>unittest</td>
          <td><code>tests/</code> 目錄</td>
      </tr>
      <tr>
          <td>Mock</td>
          <td><code>test_hook_io.py</code></td>
      </tr>
      <tr>
          <td>頂層例外處理</td>
          <td><code>hook_utils.py</code>（W22-W25）</td>
      </tr>
      <tr>
          <td>可觀測性設計</td>
          <td><code>hook_utils.py</code> 日誌架構 + IMP-003/005/006 錯誤模式</td>
      </tr>
  </tbody>
</table>
<h2 id="核心理念">核心理念</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Hook 系統的返回值設計模式</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="k">def</span> <span class="nf">validate_something</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="nb">tuple</span><span class="p">[</span><span class="nb">bool</span><span class="p">,</span> <span class="nb">str</span><span class="p">]:</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="s2">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s2">    返回 (成功與否, 訊息)
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s2">    - True, &#34;成功訊息&#34; - 驗證通過
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s2">    - False, &#34;錯誤訊息&#34; - 驗證失敗
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s2">    &#34;&#34;&#34;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="n">some_condition</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="kc">True</span><span class="p">,</span> <span class="s2">&#34;驗證通過&#34;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">return</span> <span class="kc">False</span><span class="p">,</span> <span class="s2">&#34;驗證失敗：原因說明&#34;</span></span></span></code></pre></div><h2 id="學習路徑">學習路徑</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">異常處理 → 返回值設計 → unittest 基礎 → Mock 技巧 → 頂層例外處理機制 → 可觀測性設計</span></span></code></pre></div><h2 id="學習時間">學習時間</h2>
<p>預計 90-110 分鐘</p>
]]></content:encoded></item><item><title>5.6 並發行為測試</title><link>https://tarrragon.github.io/blog/go/05-error-testing/concurrency-test/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/concurrency-test/</guid><description>&lt;p>並發測試的核心目標是驗證可觀察的同步行為，而不是猜測 goroutine 的執行順序。Go 的 goroutine 由 scheduler 安排，測試應該用 channel、context、WaitGroup 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a> 表達「什麼結果必須發生」。&lt;/p>
&lt;h2 id="並發測試應等待明確訊號">並發測試應等待明確訊號&lt;/h2>
&lt;p>並發程式的核心限制是執行順序不穩定。測試如果假設某個 goroutine 一定先跑，通常會變成偶發失敗。&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">sendAsync&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ch&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="kt">string&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="k">go&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">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">ch&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="s">&amp;#34;ready&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &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;/code>&lt;/pre>&lt;/div>&lt;p>測試不應該在呼叫後立刻假設資料已經送出，而應該等待明確訊號。&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">TestSendAsync&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">ch&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="nf">sendAsync&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ch&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="k">select&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">case&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ch&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">got&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s">&amp;#34;ready&amp;#34;&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;message = %q, want %q&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;ready&amp;#34;&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="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&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">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;timeout waiting for message&amp;#34;&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>select&lt;/code> 加 timeout 可以避免測試永久卡住。timeout 不應該用來證明程式正確，只是測試失敗時的保護機制。&lt;/p>
&lt;h2 id="channel-測試要驗證傳遞結果">channel 測試要驗證傳遞結果&lt;/h2>
&lt;p>channel 測試的核心問題是資料是否被送到預期位置。測試應該觀察 channel 收到的值，或觀察 channel 關閉後的狀態。&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">Produce&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ids&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&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">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">go&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">out&lt;/span>&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="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">ids&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="nx">out&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="nx">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">out&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;/code>&lt;/pre>&lt;/div>&lt;p>這個函式回傳只讀 channel，呼叫端可以 range 讀取直到 channel 關閉。&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">TestProduce&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">out&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nf">Produce&lt;/span>&lt;span class="p">([]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;b&amp;#34;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">got&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="nx">id&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">out&lt;/span> &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="nx">got&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="nb">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">id&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">want&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s">&amp;#34;a&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s">&amp;#34;b&amp;#34;&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="k">if&lt;/span> &lt;span class="p">!&lt;/span>&lt;span class="nx">reflect&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">DeepEqual&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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">11&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;Produce() = %#v, want %#v&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">got&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">want&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>這個測試沒有使用 sleep。channel 關閉就是明確完成訊號，測試可以自然結束。&lt;/p>
&lt;h2 id="context-用來測試退出">context 用來測試退出&lt;/h2>
&lt;p>goroutine 退出測試的核心做法是提供可取消的 &lt;code>context.Context&lt;/code>，再等待函式發出完成訊號。沒有退出訊號的 goroutine 很難可靠測試。&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">RunWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">jobs&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">done&lt;/span> &lt;span class="kd">chan&lt;/span>&lt;span class="o">&amp;lt;-&lt;/span> &lt;span class="kd">struct&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="k">defer&lt;/span> &lt;span class="nb">close&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">done&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">for&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="k">select&lt;/span> &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="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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">return&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">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">jobs&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="c1">// process job&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="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;/code>&lt;/pre>&lt;/div>&lt;p>測試可以取消 context，然後確認 &lt;code>done&lt;/code> 被關閉。&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">TestRunWorkerStops&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">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">cancel&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">WithCancel&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">context&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Background&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">jobs&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kt">string&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="nx">done&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nb">make&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kd">chan&lt;/span> &lt;span class="kd">struct&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="k">go&lt;/span> &lt;span class="nf">RunWorker&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">ctx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">jobs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">done&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="nf">cancel&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="k">select&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="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">done&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="k">case&lt;/span> &lt;span class="o">&amp;lt;-&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">After&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">time&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Second&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">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;worker did not stop&amp;#34;&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>done&lt;/code> channel 是測試與 goroutine 之間的完成協定。若程式沒有這種協定，測試只能猜測 goroutine 是否已經退出。&lt;/p>
&lt;h2 id="syncwaitgroup-適合等待一組工作完成">&lt;code>sync.WaitGroup&lt;/code> 適合等待一組工作完成&lt;/h2>
&lt;p>&lt;code>WaitGroup&lt;/code> 的核心用途是等待已知數量的 goroutine 完成。它適合 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out&lt;/a> 工作、批次處理與測試中需要等多個背景任務結束的情境。&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">ProcessAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">items&lt;/span> &lt;span class="p">[]&lt;/span>&lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">process&lt;/span> &lt;span class="kd">func&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kt">string&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="kd">var&lt;/span> &lt;span class="nx">wg&lt;/span> &lt;span class="nx">sync&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">WaitGroup&lt;/span>
&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"> &lt;span class="k">for&lt;/span> &lt;span class="nx">_&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="k">range&lt;/span> &lt;span class="nx">items&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="nx">item&lt;/span> &lt;span class="o">:=&lt;/span> &lt;span class="nx">item&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Add&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&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">go&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"> 8&lt;/span>&lt;span class="cl"> &lt;span class="k">defer&lt;/span> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Done&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="nf">process&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">item&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="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">wg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nf">Wait&lt;/span>&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>測試可以用 mutex 保護共享 slice，並在函式回傳後檢查結果。&lt;/p></description><content:encoded><![CDATA[<p>並發測試的核心目標是驗證可觀察的同步行為，而不是猜測 goroutine 的執行順序。Go 的 goroutine 由 scheduler 安排，測試應該用 channel、context、WaitGroup 與 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a> 表達「什麼結果必須發生」。</p>
<h2 id="並發測試應等待明確訊號">並發測試應等待明確訊號</h2>
<p>並發程式的核心限制是執行順序不穩定。測試如果假設某個 goroutine 一定先跑，通常會變成偶發失敗。</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">sendAsync</span><span class="p">(</span><span class="nx">ch</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">    <span class="k">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">        <span class="nx">ch</span> <span class="o">&lt;-</span> <span class="s">&#34;ready&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">}()</span>
</span></span><span class="line"><span class="ln">5</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">TestSendAsync</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">ch</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">,</span> <span class="mi">1</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">sendAsync</span><span class="p">(</span><span class="nx">ch</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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="k">case</span> <span class="nx">got</span> <span class="o">:=</span> <span class="o">&lt;-</span><span class="nx">ch</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">got</span> <span class="o">!=</span> <span class="s">&#34;ready&#34;</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;message = %q, want %q&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="s">&#34;ready&#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="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</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;timeout waiting for message&#34;</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>select</code> 加 timeout 可以避免測試永久卡住。timeout 不應該用來證明程式正確，只是測試失敗時的保護機制。</p>
<h2 id="channel-測試要驗證傳遞結果">channel 測試要驗證傳遞結果</h2>
<p>channel 測試的核心問題是資料是否被送到預期位置。測試應該觀察 channel 收到的值，或觀察 channel 關閉後的狀態。</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">Produce</span><span class="p">(</span><span class="nx">ids</span> <span class="p">[]</span><span class="kt">string</span><span class="p">)</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="kt">string</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="nx">out</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</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">go</span> <span class="kd">func</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">out</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">ids</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">            <span class="nx">out</span> <span class="o">&lt;-</span> <span class="nx">id</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">return</span> <span class="nx">out</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>這個函式回傳只讀 channel，呼叫端可以 range 讀取直到 channel 關閉。</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">TestProduce</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">out</span> <span class="o">:=</span> <span class="nf">Produce</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</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="kd">var</span> <span class="nx">got</span> <span class="p">[]</span><span class="kt">string</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="k">for</span> <span class="nx">id</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">out</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">got</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">id</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></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">reflect</span><span class="p">.</span><span class="nf">DeepEqual</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><span class="p">)</span> <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">Fatalf</span><span class="p">(</span><span class="s">&#34;Produce() = %#v, want %#v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</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>這個測試沒有使用 sleep。channel 關閉就是明確完成訊號，測試可以自然結束。</p>
<h2 id="context-用來測試退出">context 用來測試退出</h2>
<p>goroutine 退出測試的核心做法是提供可取消的 <code>context.Context</code>，再等待函式發出完成訊號。沒有退出訊號的 goroutine 很難可靠測試。</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">RunWorker</span><span class="p">(</span><span class="nx">ctx</span> <span class="nx">context</span><span class="p">.</span><span class="nx">Context</span><span class="p">,</span> <span class="nx">jobs</span> <span class="o">&lt;-</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">,</span> <span class="nx">done</span> <span class="kd">chan</span><span class="o">&lt;-</span> <span class="kd">struct</span><span class="p">{})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="k">defer</span> <span class="nb">close</span><span class="p">(</span><span class="nx">done</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">for</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">ctx</span><span class="p">.</span><span class="nf">Done</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="k">case</span> <span class="o">&lt;-</span><span class="nx">jobs</span><span class="p">:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="c1">// process job</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="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>測試可以取消 context，然後確認 <code>done</code> 被關閉。</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">TestRunWorkerStops</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">ctx</span><span class="p">,</span> <span class="nx">cancel</span> <span class="o">:=</span> <span class="nx">context</span><span class="p">.</span><span class="nf">WithCancel</span><span class="p">(</span><span class="nx">context</span><span class="p">.</span><span class="nf">Background</span><span class="p">())</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">jobs</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kt">string</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">done</span> <span class="o">:=</span> <span class="nb">make</span><span class="p">(</span><span class="kd">chan</span> <span class="kd">struct</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="k">go</span> <span class="nf">RunWorker</span><span class="p">(</span><span class="nx">ctx</span><span class="p">,</span> <span class="nx">jobs</span><span class="p">,</span> <span class="nx">done</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nf">cancel</span><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="k">select</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">done</span><span class="p">:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="k">case</span> <span class="o">&lt;-</span><span class="nx">time</span><span class="p">.</span><span class="nf">After</span><span class="p">(</span><span class="nx">time</span><span class="p">.</span><span class="nx">Second</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;worker did not stop&#34;</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>done</code> channel 是測試與 goroutine 之間的完成協定。若程式沒有這種協定，測試只能猜測 goroutine 是否已經退出。</p>
<h2 id="syncwaitgroup-適合等待一組工作完成"><code>sync.WaitGroup</code> 適合等待一組工作完成</h2>
<p><code>WaitGroup</code> 的核心用途是等待已知數量的 goroutine 完成。它適合 <a href="/blog/backend/knowledge-cards/fan-out/" data-link-title="Fan-out" data-link-desc="說明單一事件同時分發給多個下游的訊息拓撲">fan-out</a> 工作、批次處理與測試中需要等多個背景任務結束的情境。</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">ProcessAll</span><span class="p">(</span><span class="nx">items</span> <span class="p">[]</span><span class="kt">string</span><span class="p">,</span> <span class="nx">process</span> <span class="kd">func</span><span class="p">(</span><span class="kt">string</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">    <span class="kd">var</span> <span class="nx">wg</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">WaitGroup</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">for</span> <span class="nx">_</span><span class="p">,</span> <span class="nx">item</span> <span class="o">:=</span> <span class="k">range</span> <span class="nx">items</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="nx">item</span> <span class="o">:=</span> <span class="nx">item</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">wg</span><span class="p">.</span><span class="nf">Add</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">go</span> <span class="kd">func</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">defer</span> <span class="nx">wg</span><span class="p">.</span><span class="nf">Done</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">            <span class="nf">process</span><span class="p">(</span><span class="nx">item</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="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">wg</span><span class="p">.</span><span class="nf">Wait</span><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>測試可以用 mutex 保護共享 slice，並在函式回傳後檢查結果。</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">TestProcessAll</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="kd">var</span> <span class="nx">mu</span> <span class="nx">sync</span><span class="p">.</span><span class="nx">Mutex</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">var</span> <span class="nx">got</span> <span class="p">[]</span><span class="kt">string</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">ProcessAll</span><span class="p">([]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">},</span> <span class="kd">func</span><span class="p">(</span><span class="nx">item</span> <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">        <span class="nx">mu</span><span class="p">.</span><span class="nf">Lock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">defer</span> <span class="nx">mu</span><span class="p">.</span><span class="nf">Unlock</span><span class="p">()</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">        <span class="nx">got</span> <span class="p">=</span> <span class="nb">append</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">item</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></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="nx">sort</span><span class="p">.</span><span class="nf">Strings</span><span class="p">(</span><span class="nx">got</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="nx">want</span> <span class="o">:=</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span><span class="s">&#34;a&#34;</span><span class="p">,</span> <span class="s">&#34;b&#34;</span><span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="k">if</span> <span class="p">!</span><span class="nx">reflect</span><span class="p">.</span><span class="nf">DeepEqual</span><span class="p">(</span><span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</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;processed = %#v, want %#v&#34;</span><span class="p">,</span> <span class="nx">got</span><span class="p">,</span> <span class="nx">want</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>因為 goroutine 執行順序不固定，測試先排序再比較。這表示測試關心「所有項目都有被處理」，不關心處理順序。</p>
<h2 id="race-detector-檢查共享狀態">race detector 檢查共享狀態</h2>
<p>共享狀態測試的核心風險是 data race。Go 提供 race detector，可以在測試時檢查多個 goroutine 是否未同步讀寫同一份資料。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">go <span class="nb">test</span> -race ./...</span></span></code></pre></div><p><code>-race</code> 會讓測試變慢，但能抓出許多一般斷言看不見的並發錯誤。只要程式有 goroutine 與共享資料，定期跑 race test 就很有價值。</p>
<p>race detector 不是邏輯正確性的完整證明。它能檢查資料競爭，但不能保證事件順序、<a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 策略或 timeout 行為都符合需求；這些仍然要靠明確測試案例。</p>
]]></content:encoded></item><item><title>7.6 CI、fuzz、load test 與 chaos testing</title><link>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go-advanced/07-distributed-operations/reliability-pipeline/</guid><description>&lt;p>可靠性驗證流程的核心責任是讓不同層級的測試回答不同風險。Unit test 驗證規則，integration test 驗證協定協作，race test 檢查資料競爭，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fuzz-test/" data-link-title="Fuzz Test" data-link-desc="說明用隨機與變異輸入驗證解析器與邊界處理健壯性">fuzz test&lt;/a> 尋找輸入邊界，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test&lt;/a> 驗證容量，&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/chaos-test/" data-link-title="Chaos Test" data-link-desc="說明透過受控故障注入驗證系統在異常條件下的恢復能力">chaos test&lt;/a> 驗證失敗復原。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>分辨不同測試層級各自要防的風險&lt;/li>
&lt;li>把 race、fuzz、load 與 chaos 放到合適的流程裡&lt;/li>
&lt;li>設計能回饋容量規劃的驗證流程&lt;/li>
&lt;li>不把端到端測試當成萬能答案&lt;/li>
&lt;li>讓測試結果回到 deployment 與 runtime 邊界&lt;/li>
&lt;/ol>
&lt;h2 id="前置章節">前置章節&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎&lt;/a>&lt;/li>
&lt;li>&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 實際互動">Go 進階：WebSocket integration test&lt;/a>&lt;/li>
&lt;li>&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 找資料競爭">Go 進階：race condition 檢查&lt;/a>&lt;/li>
&lt;li>&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="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="後續撰寫方向">後續撰寫方向&lt;/h2>
&lt;ol>
&lt;li>CI 中哪些測試應每次執行，哪些可以排程或合併前執行。&lt;/li>
&lt;li>Fuzzing 適合驗證 parser、normalizer 與 protocol decoder 的哪些邊界。&lt;/li>
&lt;li>Load test 如何設定 client 數、message rate、payload size 與觀測指標。&lt;/li>
&lt;li>Chaos testing 如何模擬 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker&lt;/a> 斷線、資料庫延遲、server shutdown 與網路抖動。&lt;/li>
&lt;li>測試結果如何回饋到 capacity planning 與 feature gate。&lt;/li>
&lt;/ol>
&lt;h2 id="觀察不同測試層級回答不同問題">【觀察】不同測試層級回答不同問題&lt;/h2>
&lt;p>可靠性驗證最怕的錯誤，是把所有測試都塞成一種樣子。不同層級應該分工：&lt;/p>
&lt;ul>
&lt;li>unit test：規則有沒有寫對&lt;/li>
&lt;li>integration test：協定與元件有沒有接對&lt;/li>
&lt;li>race test：並發邊界有沒有資料競爭&lt;/li>
&lt;li>fuzz test：輸入邊界有沒有漏掉&lt;/li>
&lt;li>load test：容量與延遲是否能接受&lt;/li>
&lt;li>chaos test：失敗發生時系統能不能復原&lt;/li>
&lt;/ul>
&lt;h2 id="判讀race-test-是輔助檢查">【判讀】race test 是輔助檢查&lt;/h2>
&lt;p>&lt;code>go test -race&lt;/code> 能抓出實際跑到的資料競爭，但它不是正確性保證。真正的重點仍然是：&lt;/p>
&lt;ul>
&lt;li>state owner 是誰&lt;/li>
&lt;li>哪些資料需要 lock&lt;/li>
&lt;li>哪些資料應該只讓單一 goroutine 擁有&lt;/li>
&lt;li>哪些資料應該複製而不是共享&lt;/li>
&lt;/ul>
&lt;h2 id="策略load-test-的輸出要能回到容量判斷">【策略】load test 的輸出要能回到容量判斷&lt;/h2>
&lt;p>load test 不應只是跑出一個數字，還要能回答：&lt;/p>
&lt;ul>
&lt;li>哪個 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 開始變長&lt;/li>
&lt;li>哪個 DB &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool&lt;/a> 開始飽和&lt;/li>
&lt;li>哪種 message rate 會讓 latency 明顯上升&lt;/li>
&lt;li>哪個 memory curve 表示需要調整 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer&lt;/a> 或 GC 參數&lt;/li>
&lt;/ul>
&lt;p>如果沒有這些觀察點，壓測結果就很難轉成實際修正。&lt;/p>
&lt;h2 id="執行chaos-test-應該模擬真實失敗">【執行】chaos test 應該模擬真實失敗&lt;/h2>
&lt;p>chaos test 的重點是模擬真實世界常見的失敗：&lt;/p>
&lt;ul>
&lt;li>broker 暫時不可用&lt;/li>
&lt;li>&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database&lt;/a> 延遲上升&lt;/li>
&lt;li>shutdown 中斷流量&lt;/li>
&lt;li>網路抖動或 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>這些情境應該回到 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown&lt;/a>、retry、&lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency&lt;/a> 與 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure&lt;/a> 設計。&lt;/p></description><content:encoded><![CDATA[<p>可靠性驗證流程的核心責任是讓不同層級的測試回答不同風險。Unit test 驗證規則，integration test 驗證協定協作，race test 檢查資料競爭，<a href="/blog/backend/knowledge-cards/fuzz-test/" data-link-title="Fuzz Test" data-link-desc="說明用隨機與變異輸入驗證解析器與邊界處理健壯性">fuzz test</a> 尋找輸入邊界，<a href="/blog/backend/knowledge-cards/load-test/" data-link-title="Load Test" data-link-desc="說明在預期流量下驗證容量、延遲與降級策略的測試">load test</a> 驗證容量，<a href="/blog/backend/knowledge-cards/chaos-test/" data-link-title="Chaos Test" data-link-desc="說明透過受控故障注入驗證系統在異常條件下的恢復能力">chaos test</a> 驗證失敗復原。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>分辨不同測試層級各自要防的風險</li>
<li>把 race、fuzz、load 與 chaos 放到合適的流程裡</li>
<li>設計能回饋容量規劃的驗證流程</li>
<li>不把端到端測試當成萬能答案</li>
<li>讓測試結果回到 deployment 與 runtime 邊界</li>
</ol>
<h2 id="前置章節">前置章節</h2>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go 入門：testing 基礎</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界</a></li>
</ul>
<h2 id="後續撰寫方向">後續撰寫方向</h2>
<ol>
<li>CI 中哪些測試應每次執行，哪些可以排程或合併前執行。</li>
<li>Fuzzing 適合驗證 parser、normalizer 與 protocol decoder 的哪些邊界。</li>
<li>Load test 如何設定 client 數、message rate、payload size 與觀測指標。</li>
<li>Chaos testing 如何模擬 <a href="/blog/backend/knowledge-cards/broker/" data-link-title="Broker" data-link-desc="說明 broker 在訊息傳遞系統中負責保存、路由與交付訊息">broker</a> 斷線、資料庫延遲、server shutdown 與網路抖動。</li>
<li>測試結果如何回饋到 capacity planning 與 feature gate。</li>
</ol>
<h2 id="觀察不同測試層級回答不同問題">【觀察】不同測試層級回答不同問題</h2>
<p>可靠性驗證最怕的錯誤，是把所有測試都塞成一種樣子。不同層級應該分工：</p>
<ul>
<li>unit test：規則有沒有寫對</li>
<li>integration test：協定與元件有沒有接對</li>
<li>race test：並發邊界有沒有資料競爭</li>
<li>fuzz test：輸入邊界有沒有漏掉</li>
<li>load test：容量與延遲是否能接受</li>
<li>chaos test：失敗發生時系統能不能復原</li>
</ul>
<h2 id="判讀race-test-是輔助檢查">【判讀】race test 是輔助檢查</h2>
<p><code>go test -race</code> 能抓出實際跑到的資料競爭，但它不是正確性保證。真正的重點仍然是：</p>
<ul>
<li>state owner 是誰</li>
<li>哪些資料需要 lock</li>
<li>哪些資料應該只讓單一 goroutine 擁有</li>
<li>哪些資料應該複製而不是共享</li>
</ul>
<h2 id="策略load-test-的輸出要能回到容量判斷">【策略】load test 的輸出要能回到容量判斷</h2>
<p>load test 不應只是跑出一個數字，還要能回答：</p>
<ul>
<li>哪個 <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 開始變長</li>
<li>哪個 DB <a href="/blog/backend/knowledge-cards/connection-pool/" data-link-title="Connection Pool" data-link-desc="說明連線池如何限制下游資源並影響服務容量">connection pool</a> 開始飽和</li>
<li>哪種 message rate 會讓 latency 明顯上升</li>
<li>哪個 memory curve 表示需要調整 <a href="/blog/backend/knowledge-cards/buffer/" data-link-title="Buffer" data-link-desc="說明系統如何用暫存空間吸收短暫速度差與尖峰流量">buffer</a> 或 GC 參數</li>
</ul>
<p>如果沒有這些觀察點，壓測結果就很難轉成實際修正。</p>
<h2 id="執行chaos-test-應該模擬真實失敗">【執行】chaos test 應該模擬真實失敗</h2>
<p>chaos test 的重點是模擬真實世界常見的失敗：</p>
<ul>
<li>broker 暫時不可用</li>
<li><a href="/blog/backend/knowledge-cards/database/" data-link-title="Database" data-link-desc="說明 database 在後端系統中如何承擔正式狀態、查詢與一致性責任">database</a> 延遲上升</li>
<li>shutdown 中斷流量</li>
<li>網路抖動或 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a></li>
</ul>
<p>這些情境應該回到 <a href="/blog/backend/knowledge-cards/graceful-shutdown/" data-link-title="Graceful Shutdown" data-link-desc="說明服務停止前如何排空流量、完成工作與保存狀態">graceful shutdown</a>、retry、<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 與 <a href="/blog/backend/knowledge-cards/backpressure/" data-link-title="Backpressure" data-link-desc="說明下游處理速度不足時系統如何讓上游依下游能力送出工作">backpressure</a> 設計。</p>
<h2 id="延伸測試結果應回饋到-feature-gate">【延伸】測試結果應回饋到 feature gate</h2>
<p>如果某個功能在 load test 或 chaos test 下風險太高，最直接的做法不一定是先修完整系統，也可能是先用 feature gate 逐步推出、觀察與回收。</p>
<h2 id="本章不處理">本章不處理</h2>
<p>本章不會綁定特定 CI 或壓測平台。教材重點會放在測試層級分工，避免把所有風險都塞進端到端測試。</p>
<h2 id="和-go-教材的關係">和 Go 教材的關係</h2>
<p>這一章承接的是 Go 的並發測試與可靠性驗證；如果你要先回看語言教材，可以讀：</p>
<ul>
<li><a href="/blog/go/05-error-testing/testing-basics/" data-link-title="5.2 testing 基礎" data-link-desc="用 testing package 驗證函式行為">Go：測試基礎</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/websocket-integration/" data-link-title="5.2 WebSocket integration test" data-link-desc="驗證 client/server 實際互動">Go 進階：WebSocket integration test</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/race-check/" data-link-title="5.3 race condition 檢查" data-link-desc="用 go test -race 找資料競爭">Go 進階：race condition 檢查</a></li>
<li><a href="/blog/go-advanced/05-testing-reliability/table-tests/" data-link-title="5.4 table-driven test 的設計邊界" data-link-desc="避免測試資料混雜太多概念">Go 進階：table-driven test 的設計邊界</a></li>
</ul>
]]></content:encoded></item><item><title>5.7 錯誤處理與測試在高併發服務中的角色</title><link>https://tarrragon.github.io/blog/go/05-error-testing/service-reliability/</link><pubDate>Thu, 23 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/go/05-error-testing/service-reliability/</guid><description>&lt;p>高併發服務的可靠性來自錯誤處理與測試共同形成的保護機制。錯誤處理讓失敗路徑可見，測試讓失敗路徑可重現；兩者一起決定系統在壓力下是否仍能被理解、修復與持續交付。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>學完本章後，你將能夠：&lt;/p>
&lt;ol>
&lt;li>把錯誤處理看成可靠性的一部分&lt;/li>
&lt;li>區分可恢復錯誤與不可恢復錯誤&lt;/li>
&lt;li>用測試保護失敗路徑與並發路徑&lt;/li>
&lt;li>讓 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout&lt;/a>、取消與 race condition 能被提早發現&lt;/li>
&lt;li>理解為什麼高併發服務更需要明確的測試邊界&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="觀察錯誤是服務常態">【觀察】錯誤是服務常態&lt;/h2>
&lt;p>在高併發服務裡，錯誤是日常情況的一部分。網路會失敗、下游會超時、資料會不完整、狀態會競爭；這些情境都應進入系統設計，而不是只留給事故發生後人工排查。&lt;/p>
&lt;p>Go 把錯誤放在回傳值中，就是要讓這些常態能被直接看見。&lt;/p>
&lt;h2 id="判讀測試要先保護脆弱邊界">【判讀】測試要先保護脆弱邊界&lt;/h2>
&lt;p>高併發服務最容易出問題的地方，通常是：&lt;/p>
&lt;ul>
&lt;li>HTTP handler 與外部輸入邊界&lt;/li>
&lt;li>goroutine 之間的共享狀態&lt;/li>
&lt;li>timeout 與 cancellation&lt;/li>
&lt;li>event / &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue&lt;/a> 的重複或漏處理&lt;/li>
&lt;/ul>
&lt;p>這些邊界都應該有測試。人工驗證可以輔助檢查流程，但它無法穩定重現 timeout、取消、race condition 與高併發失敗路徑。&lt;/p>
&lt;h2 id="策略錯誤路徑也要被測">【策略】錯誤路徑也要被測&lt;/h2>
&lt;p>服務測試需要同時覆蓋成功路徑與失敗路徑。只驗證成功路徑會讓 timeout、下游錯誤、取消與狀態競爭在壓力下才暴露，修復成本會更高。&lt;/p>
&lt;p>你至少應該測：&lt;/p>
&lt;ul>
&lt;li>參數不合法時是否回傳穩定錯誤&lt;/li>
&lt;li>下游失敗時是否有正確的包裝錯誤&lt;/li>
&lt;li>timeout 是否真的會停止工作&lt;/li>
&lt;li>取消 context 後 goroutine 是否退出&lt;/li>
&lt;/ul>
&lt;h2 id="執行並發測試要看資源是否被正確回收">【執行】並發測試要看資源是否被正確回收&lt;/h2>
&lt;p>高併發測試的核心目標是確認資源會被正確回收。跑很多 goroutine 只是製造壓力；真正需要驗證的是：&lt;/p>
&lt;ul>
&lt;li>goroutine 會不會 leak&lt;/li>
&lt;li>channel 會不會卡住&lt;/li>
&lt;li>鎖的範圍是否合理&lt;/li>
&lt;li>資源關閉後流程是否停止&lt;/li>
&lt;/ul>
&lt;p>可靠性測試至少要證明流程能正確結束。只證明「看起來可以跑」會漏掉 goroutine leak、channel 卡住、鎖範圍過大與資源未釋放等問題。&lt;/p></description><content:encoded><![CDATA[<p>高併發服務的可靠性來自錯誤處理與測試共同形成的保護機制。錯誤處理讓失敗路徑可見，測試讓失敗路徑可重現；兩者一起決定系統在壓力下是否仍能被理解、修復與持續交付。</p>
<h2 id="本章目標">本章目標</h2>
<p>學完本章後，你將能夠：</p>
<ol>
<li>把錯誤處理看成可靠性的一部分</li>
<li>區分可恢復錯誤與不可恢復錯誤</li>
<li>用測試保護失敗路徑與並發路徑</li>
<li>讓 <a href="/blog/backend/knowledge-cards/timeout/" data-link-title="Timeout" data-link-desc="說明等待外部操作的時間上限如何保護資源與使用者體驗">timeout</a>、取消與 race condition 能被提早發現</li>
<li>理解為什麼高併發服務更需要明確的測試邊界</li>
</ol>
<hr>
<h2 id="觀察錯誤是服務常態">【觀察】錯誤是服務常態</h2>
<p>在高併發服務裡，錯誤是日常情況的一部分。網路會失敗、下游會超時、資料會不完整、狀態會競爭；這些情境都應進入系統設計，而不是只留給事故發生後人工排查。</p>
<p>Go 把錯誤放在回傳值中，就是要讓這些常態能被直接看見。</p>
<h2 id="判讀測試要先保護脆弱邊界">【判讀】測試要先保護脆弱邊界</h2>
<p>高併發服務最容易出問題的地方，通常是：</p>
<ul>
<li>HTTP handler 與外部輸入邊界</li>
<li>goroutine 之間的共享狀態</li>
<li>timeout 與 cancellation</li>
<li>event / <a href="/blog/backend/knowledge-cards/queue/" data-link-title="Queue" data-link-desc="說明 queue 如何保存等待處理的工作並形成容量邊界">queue</a> 的重複或漏處理</li>
</ul>
<p>這些邊界都應該有測試。人工驗證可以輔助檢查流程，但它無法穩定重現 timeout、取消、race condition 與高併發失敗路徑。</p>
<h2 id="策略錯誤路徑也要被測">【策略】錯誤路徑也要被測</h2>
<p>服務測試需要同時覆蓋成功路徑與失敗路徑。只驗證成功路徑會讓 timeout、下游錯誤、取消與狀態競爭在壓力下才暴露，修復成本會更高。</p>
<p>你至少應該測：</p>
<ul>
<li>參數不合法時是否回傳穩定錯誤</li>
<li>下游失敗時是否有正確的包裝錯誤</li>
<li>timeout 是否真的會停止工作</li>
<li>取消 context 後 goroutine 是否退出</li>
</ul>
<h2 id="執行並發測試要看資源是否被正確回收">【執行】並發測試要看資源是否被正確回收</h2>
<p>高併發測試的核心目標是確認資源會被正確回收。跑很多 goroutine 只是製造壓力；真正需要驗證的是：</p>
<ul>
<li>goroutine 會不會 leak</li>
<li>channel 會不會卡住</li>
<li>鎖的範圍是否合理</li>
<li>資源關閉後流程是否停止</li>
</ul>
<p>可靠性測試至少要證明流程能正確結束。只證明「看起來可以跑」會漏掉 goroutine leak、channel 卡住、鎖範圍過大與資源未釋放等問題。</p>
]]></content:encoded></item><item><title>用前端測試把排版問題自動化</title><link>https://tarrragon.github.io/blog/report/layout-tests-with-playwright/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/layout-tests-with-playwright/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>當一個版型被 debug 兩次以上、就值得寫成 playwright 測試。&lt;/strong> 測試替代「手動檢查 + 截圖」的循環、讓版型回歸可被機器發現。下次有人改 CSS 時、測試會立刻指出哪個假設被破壞。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼版型問題適合自動化">為什麼版型問題適合自動化&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&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>邊界條件多（viewport、字型、互動狀態）&lt;/td>
 &lt;td>人眼難以涵蓋全部組合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>變動觸發點不明顯（改 token、改 theme）&lt;/td>
 &lt;td>改一處不知道哪裡會壞&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>視覺問題往往來自相對關係&lt;/td>
 &lt;td>截圖只看絕對位置、看不出關係&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>人腦適合「驚訝時注意」、不適合「重複檢查 100 個 case 是否如預期」。後者是機器擅長的。&lt;/p>
&lt;h3 id="兩種測試層次">兩種測試層次&lt;/h3>
&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;/td>
 &lt;td>整頁與基準截圖比對&lt;/td>
 &lt;td>Percy / Chromatic / Playwright snapshot&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結構斷言&lt;/td>
 &lt;td>特定元素的位置 / 尺寸 / 順序&lt;/td>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code> + &lt;code>expect&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩種互補。視覺迴歸抓「整頁有沒有變」、結構斷言抓「特定關係有沒有保持」。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的測試機會">這次任務的測試機會&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁的版型在這次開發中被 debug 多輪：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Debug 次數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Filter sidebar 跨 viewport 顯示 / 隱藏&lt;/td>
 &lt;td>5+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scope UI 三狀態下的位置&lt;/td>
 &lt;td>4+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結果區跟 sidebar 頂端對齊&lt;/td>
 &lt;td>3+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter 順序 type 在前&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>每個 debug 過 ≥ 2 次的版型場景都值得寫測試 — 表示這個地方很容易壞、未來改 CSS 還會踩。&lt;/p>
&lt;h3 id="執行寫-playwright-測試">執行：寫 playwright 測試&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// tests/search-layout.spec.js
&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="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">test&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">expect&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="nx">from&lt;/span> &lt;span class="s1">&amp;#39;@playwright/test&amp;#39;&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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">describe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;search page layout&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;desktop ≥ 1400 顯示左側 filter sidebar&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &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="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setViewportSize&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">1440&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">900&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="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/blog/search/&amp;#39;&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="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&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="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForSelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&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>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">slot&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-filter-slot&amp;#39;&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="kr">const&lt;/span> &lt;span class="nx">isVisible&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">isVisible&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="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">isVisible&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kc">true&lt;/span>&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>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">filterParent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">evaluate&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterParent&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toContain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;search-filter-slot&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;viewport &amp;lt; 1400 filter 留在 pagefind drawer&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setViewportSize&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">900&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/blog/search/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForSelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">filterParent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">evaluate&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterParent&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toContain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;scope UI 在三互動狀態下都在 input 與 results 之間&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setViewportSize&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">1440&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">900&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/blog/search/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">selector&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">38&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">evaluate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">y&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">selector&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 狀態 1：初始載入
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">scopeY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">43&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">inputY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">44&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeGreaterThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">45&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">46&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 狀態 2：點 input
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">47&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">48&lt;/span>&lt;span class="cl"> &lt;span class="nx">scopeY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">49&lt;/span>&lt;span class="cl"> &lt;span class="nx">inputY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">50&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeGreaterThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">51&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">52&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 狀態 3：輸入字
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">53&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">54&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForSelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__results .pagefind-ui__result&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">55&lt;/span>&lt;span class="cl"> &lt;span class="nx">scopeY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">56&lt;/span>&lt;span class="cl"> &lt;span class="nx">inputY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">57&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">resultsY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__results&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">58&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeGreaterThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">59&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeLessThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">resultsY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">60&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">61&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>expect&lt;/code> 對應一條版型契約 — 這條被破壞時測試紅、改 CSS 的人立刻知道。&lt;/p>
&lt;hr>
&lt;h2 id="測試的維護成本與收益">測試的維護成本與收益&lt;/h2>
&lt;h3 id="內在屬性比較">內在屬性比較&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>屬性&lt;/th>
 &lt;th>手動檢查&lt;/th>
 &lt;th>Playwright 測試&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>高 — 跑所有 case&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規範化&lt;/td>
 &lt;td>否 — 知識在腦中&lt;/td>
 &lt;td>是 — 寫成可讀的 expect&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>教學價值&lt;/td>
 &lt;td>低 — 新人需要被告知&lt;/td>
 &lt;td>高 — 測試本身是文件&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 1 次寫成本中、第 2 次以後成本碾壓手動。&lt;strong>門檻在「會 debug 第 2 次嗎」&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>當一個版型被 debug 兩次以上、就值得寫成 playwright 測試。</strong> 測試替代「手動檢查 + 截圖」的循環、讓版型回歸可被機器發現。下次有人改 CSS 時、測試會立刻指出哪個假設被破壞。</p>
<hr>
<h2 id="為什麼版型問題適合自動化">為什麼版型問題適合自動化</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>排版問題的特徵：</p>
<table>
  <thead>
      <tr>
          <th>特徵</th>
          <th>對手動檢查的不利</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>邊界條件多（viewport、字型、互動狀態）</td>
          <td>人眼難以涵蓋全部組合</td>
      </tr>
      <tr>
          <td>變動觸發點不明顯（改 token、改 theme）</td>
          <td>改一處不知道哪裡會壞</td>
      </tr>
      <tr>
          <td>視覺問題往往來自相對關係</td>
          <td>截圖只看絕對位置、看不出關係</td>
      </tr>
  </tbody>
</table>
<p>人腦適合「驚訝時注意」、不適合「重複檢查 100 個 case 是否如預期」。後者是機器擅長的。</p>
<h3 id="兩種測試層次">兩種測試層次</h3>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>測什麼</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>視覺迴歸</td>
          <td>整頁與基準截圖比對</td>
          <td>Percy / Chromatic / Playwright snapshot</td>
      </tr>
      <tr>
          <td>結構斷言</td>
          <td>特定元素的位置 / 尺寸 / 順序</td>
          <td>Playwright <code>browser_evaluate</code> + <code>expect</code></td>
      </tr>
  </tbody>
</table>
<p>兩種互補。視覺迴歸抓「整頁有沒有變」、結構斷言抓「特定關係有沒有保持」。</p>
<hr>
<h2 id="這次任務的測試機會">這次任務的測試機會</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁的版型在這次開發中被 debug 多輪：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Debug 次數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter sidebar 跨 viewport 顯示 / 隱藏</td>
          <td>5+</td>
      </tr>
      <tr>
          <td>Scope UI 三狀態下的位置</td>
          <td>4+</td>
      </tr>
      <tr>
          <td>結果區跟 sidebar 頂端對齊</td>
          <td>3+</td>
      </tr>
      <tr>
          <td>Filter 順序 type 在前</td>
          <td>2</td>
      </tr>
  </tbody>
</table>
<h3 id="判讀">判讀</h3>
<p>每個 debug 過 ≥ 2 次的版型場景都值得寫測試 — 表示這個地方很容易壞、未來改 CSS 還會踩。</p>
<h3 id="執行寫-playwright-測試">執行：寫 playwright 測試</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// tests/search-layout.spec.js
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;@playwright/test&#39;</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">test</span><span class="p">.</span><span class="nx">describe</span><span class="p">(</span><span class="s1">&#39;search page layout&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">test</span><span class="p">(</span><span class="s1">&#39;desktop ≥ 1400 顯示左側 filter sidebar&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1440</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">900</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/blog/search/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="s1">&#39;pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</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="kr">const</span> <span class="nx">slot</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kr">const</span> <span class="nx">isVisible</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">isVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">isVisible</span><span class="p">).</span><span class="nx">toBe</span><span class="p">(</span><span class="kc">true</span><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="kr">const</span> <span class="nx">filterParent</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">).</span><span class="nx">parentElement</span><span class="p">.</span><span class="nx">className</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">filterParent</span><span class="p">).</span><span class="nx">toContain</span><span class="p">(</span><span class="s1">&#39;search-filter-slot&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="nx">test</span><span class="p">(</span><span class="s1">&#39;viewport &lt; 1400 filter 留在 pagefind drawer&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1024</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">900</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/blog/search/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="s1">&#39;pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="kr">const</span> <span class="nx">filterParent</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">      <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">).</span><span class="nx">parentElement</span><span class="p">.</span><span class="nx">className</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">);</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">filterParent</span><span class="p">).</span><span class="nx">toContain</span><span class="p">(</span><span class="s1">&#39;pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">  <span class="nx">test</span><span class="p">(</span><span class="s1">&#39;scope UI 在三互動狀態下都在 input 與 results 之間&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1440</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">900</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/blog/search/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="kr">async</span> <span class="kd">function</span> <span class="nx">getY</span><span class="p">(</span><span class="nx">selector</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">      <span class="k">return</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(</span><span class="nx">s</span> <span class="p">=&gt;</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="nx">s</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">y</span><span class="p">,</span> <span class="nx">selector</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="c1">// 狀態 1：初始載入
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="c1"></span>    <span class="kd">let</span> <span class="nx">scopeY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">    <span class="kd">let</span> <span class="nx">inputY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">inputY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">
</span></span><span class="line"><span class="ln">46</span><span class="cl">    <span class="c1">// 狀態 2：點 input
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="c1"></span>    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">    <span class="nx">scopeY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">    <span class="nx">inputY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">inputY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">
</span></span><span class="line"><span class="ln">52</span><span class="cl">    <span class="c1">// 狀態 3：輸入字
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="c1"></span>    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="s1">&#39;pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results .pagefind-ui__result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">    <span class="nx">scopeY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">    <span class="nx">inputY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">    <span class="kr">const</span> <span class="nx">resultsY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">inputY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeLessThan</span><span class="p">(</span><span class="nx">resultsY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>每個 <code>expect</code> 對應一條版型契約 — 這條被破壞時測試紅、改 CSS 的人立刻知道。</p>
<hr>
<h2 id="測試的維護成本與收益">測試的維護成本與收益</h2>
<h3 id="內在屬性比較">內在屬性比較</h3>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>手動檢查</th>
          <th>Playwright 測試</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>高 — 跑所有 case</td>
      </tr>
      <tr>
          <td>規範化</td>
          <td>否 — 知識在腦中</td>
          <td>是 — 寫成可讀的 expect</td>
      </tr>
      <tr>
          <td>教學價值</td>
          <td>低 — 新人需要被告知</td>
          <td>高 — 測試本身是文件</td>
      </tr>
  </tbody>
</table>
<p>第 1 次寫成本中、第 2 次以後成本碾壓手動。<strong>門檻在「會 debug 第 2 次嗎」</strong>。</p>
<hr>
<h2 id="測試什麼不測什麼">測試什麼、不測什麼</h2>
<h3 id="適合測試的版型場景">適合測試的版型場景</h3>
<ul>
<li>跨 viewport 的元件顯示 / 隱藏切換</li>
<li>元件相對位置（A 在 B 上方 / 下方 / 左右）</li>
<li>元件順序（type 在 tag 前）</li>
<li>互動狀態下的位置不變（scope 在三狀態下都在 input 與 results 之間）</li>
</ul>
<h3 id="不適合用-playwright-測">不適合用 playwright 測</h3>
<ul>
<li>純視覺差異（顏色微差、圓角 1px 差） — 用 visual regression 工具</li>
<li>動畫過程 — 不穩定、容易 flaky</li>
<li>字型 rendering 細節 — 跨 OS / 瀏覽器差異大</li>
</ul>
<p>選擇原則：<strong>測「結構性契約」、不測「畫素」</strong>。畫素級檢查交給 visual regression。</p>
<hr>
<h2 id="設計取捨版型驗證機制的選擇">設計取捨：版型驗證機制的選擇</h2>
<p>四種做法、各自機會成本不同。這個專案在版型 debug ≥ 2 次後選 A（結構斷言測試）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「驗證機制升級」這個面向的應用。</p></blockquote>
<h3 id="aplaywright-結構斷言測試這個專案的預設">A：Playwright 結構斷言測試（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：寫 <code>expect(scopeY &gt; inputY)</code> 這類斷言、自動跑、跨字型 / 主題都對</li>
<li><strong>選 A 的理由</strong>：規範化（測試本身是文件）、跨環境穩定、回歸自動偵測</li>
<li><strong>適合</strong>：debug ≥ 2 次的版型場景、需要長期保護的 layout 契約</li>
<li><strong>代價</strong>：寫測試的初始成本、需要 playwright runtime</li>
</ul>
<h3 id="b手動截圖檢查">B：手動截圖檢查</h3>
<ul>
<li><strong>機制</strong>：開頁面、看截圖、人眼確認</li>
<li><strong>跟 A 的取捨</strong>：B 起步成本 0、A 起步成本中；但 B 重複成本高（每次回歸都要看）</li>
<li><strong>B 比 A 好的情境</strong>：第 1 次驗證（debug 過 1 次、不確定值不值得寫測試）、純探索期</li>
</ul>
<h3 id="cvisual-regression-snapshot">C：Visual regression snapshot</h3>
<ul>
<li><strong>機制</strong>：截整頁圖跟 baseline 比對、像素級差異</li>
<li><strong>跟 A 的取捨</strong>：C 涵蓋率廣（整頁所有變動都偵測）、A 只測指定契約；但 C false positive 多（字型微調 / theme 換色都觸發）</li>
<li><strong>C 比 A 好的情境</strong>：純視覺驗證（marketing page）、設計穩定不常改</li>
</ul>
<h3 id="d不寫測試">D：不寫測試</h3>
<ul>
<li><strong>機制</strong>：純信任手動驗證</li>
<li><strong>跟 A 的取捨</strong>：D 0 維護成本、A 有測試維護；但 D 在版型反覆壞時累積「腦中知識」、新人接手不知道</li>
<li><strong>D 才合理的情境</strong>：純探索期 / prototype、確定不上 production</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該寫測試的時機</th>
          <th>第一個該寫的 expect</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同一個版型 bug 出現第 2 次</td>
          <td>立刻寫</td>
          <td>把當時的 fix 寫成 expect</td>
      </tr>
      <tr>
          <td>改 token / theme 時不確定哪些頁面會壞</td>
          <td>把對 token 敏感的頁面寫測試</td>
          <td>元件相對位置、寬度比例</td>
      </tr>
      <tr>
          <td>跨 viewport 的響應式邏輯複雜</td>
          <td>寫 viewport 切換測試</td>
          <td>不同寬度下元件顯示 / 位置</td>
      </tr>
      <tr>
          <td>互動狀態下版型不穩定</td>
          <td>寫狀態切換測試</td>
          <td>各狀態下關鍵元素的位置關係</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：版型契約用測試固定 — 測試紅了表示契約被打破、不是測試壞了。每個紅色測試都是有人改了不該改的東西的訊號。</p>
<p>跟 <a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a> 的關係：layout test 是 Checkpoint 3「Ship 前」的具體做法 — 跨 viewport / 跨狀態 / 跨資料規模驗收、catch 開發中 checkpoint 看不到的整合錯。沒寫 layout test = 把 ship 前 checkpoint 跳過、所有版型回歸都進 ship 後（使用者反映才修）。</p>
<p>寫完 layout test 必須在「未修版型」跑 RED 確認測試會抓到該抓的、再在「修後版型」跑 GREEN 確認修對了 — 兩個訊號都看到、測試才被驗證。詳見 <a href="../test-first-red-before-green/">#69 Test-First：先看到 RED 才相信 GREEN</a>。</p>
]]></content:encoded></item><item><title>開發測試實務指南</title><link>https://tarrragon.github.io/blog/testing/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/</guid><description>&lt;p>開發測試教材的核心目標是教讀者理解「測試通過」和「產品正確」之間的差距如何產生、如何消除。Unit test 用 mock 遮蔽了協議差異、integration test 名為整合實為 fake、widget test 不覆蓋導航路徑 — 這些是測試策略的結構性盲區，來自設計取捨而非疏忽。本教材把品質驗證拆成可分層理解、可分步落地的知識路線。&lt;/p>
&lt;h2 id="教學出發點">教學出發點&lt;/h2>
&lt;p>這個系列從一個具體事件出發：一個 Flutter app 有 192 個 unit test 全部通過，但部署到真實 iOS 裝置後，WebSocket 連線、認證握手、終端機渲染三個核心功能全部失敗。根因是所有 test 都用同一個 &lt;code>FakeWebSocketChannel&lt;/code>，永遠不觸碰真實 WebSocket 協議 — text vs binary frame 差異、auth token handshake、ANSI 控制序列多樣性，全部被 mock 完美遮蔽。&lt;/p>
&lt;p>這個事件揭示的是一個跨語言、跨框架的結構性問題：&lt;strong>當被測元件的正確性取決於與外部服務的協議契約時，mock 從結構上就無法驗證這件事。&lt;/strong>&lt;/p>
&lt;h2 id="教學範圍">教學範圍&lt;/h2>
&lt;p>本系列聚焦「開發團隊能自己建立的品質驗證體系」，不討論 QA 組織或測試管理流程。&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 / protocol integration / screen state）&lt;/td>
 &lt;td>特定語言的測試框架語法（放語言教材）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客戶端可觀測性（連線生命週期 log、protocol 訊息 log、錯誤回報）&lt;/td>
 &lt;td>伺服器端可觀測性平台（放 &lt;a href="https://tarrragon.github.io/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自架 log 收集（同區網、自有伺服器、開發期用途）&lt;/td>
 &lt;td>商業 APM / crash reporting 產品評測&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>協議整合測試（WS、gRPC、MQTT 等對真實服務驗證）&lt;/td>
 &lt;td>負載測試、壓力測試（放 &lt;a href="https://tarrragon.github.io/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">Backend 09&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自動化 UI 驗證（widget test、Playwright、螢幕狀態覆蓋）&lt;/td>
 &lt;td>手動 QA 流程、測試案例管理工具&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試設計判斷（mock 邊界、assertion 設計、flaky 診斷）&lt;/td>
 &lt;td>CI pipeline 設定（放 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend 06&lt;/a>）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="與-backend-的關係">與 Backend 的關係&lt;/h2>
&lt;p>Backend 教材的 &lt;a href="https://tarrragon.github.io/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">模組六：可靠性驗證&lt;/a> 聚焦「CI pipeline、load test、fuzz、chaos testing」— 伺服器端的品質閘門。本系列聚焦客戶端和協議層的驗證，兩者互補：&lt;/p>
&lt;ul>
&lt;li>Backend 告訴你「伺服器怎麼確保自己沒壞」&lt;/li>
&lt;li>本系列告訴你「客戶端怎麼確保跟伺服器的互動沒壞」&lt;/li>
&lt;/ul>
&lt;p>交叉點是 contract test 和 integration test — Backend 從伺服器端看、本系列從客戶端看，同一個介面的兩面。&lt;/p>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;h3 id="模組一測試策略分層">模組一：測試策略分層&lt;/h3>
&lt;p>回答「什麼測試抓什麼問題」。把測試分為三層，每層有明確的職責和盲區：&lt;/p>
&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>Unit（mock）&lt;/td>
 &lt;td>內部邏輯正確性&lt;/td>
 &lt;td>狀態轉換、錯誤處理、資料轉換&lt;/td>
 &lt;td>協議差異、真實服務行為、環境特異性&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Protocol integration&lt;/td>
 &lt;td>協議契約正確性&lt;/td>
 &lt;td>frame type、auth handshake、序列完整性&lt;/td>
 &lt;td>UI 互動、畫面渲染、用戶體驗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Screen state&lt;/td>
 &lt;td>UI 行為正確性&lt;/td>
 &lt;td>狀態轉換 UI、導航、用戶操作&lt;/td>
 &lt;td>底層協議、網路行為&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>判斷原則：被測元件直接對接外部協議（WS、gRPC、SMTP）→ 需要 protocol integration test。外部服務可在本機啟動 → 成本低，強烈建議。Mock 和真實服務之間有協議語意差異 → 必須。&lt;/p>
&lt;blockquote>
&lt;p>案例入口：&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> — 三個被 mock 遮蔽的真實問題&lt;/p>&lt;/blockquote>
&lt;h3 id="模組二客戶端可觀測性">模組二：客戶端可觀測性&lt;/h3>
&lt;p>回答「使用者的裝置上發生了什麼事」。開發者不在使用者旁邊，需要系統性地收集執行時資訊。&lt;/p>
&lt;p>&lt;strong>三層 log 設計&lt;/strong>：&lt;/p>
&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>connect / auth / handshake / data / disconnect 每步&lt;/td>
 &lt;td>開發者（debug）&lt;/td>
 &lt;td>企劃階段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Protocol 訊息&lt;/td>
 &lt;td>frame type、payload 前綴、auth 結果&lt;/td>
 &lt;td>開發者（協議 debug）&lt;/td>
 &lt;td>企劃階段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者行為&lt;/td>
 &lt;td>畫面切換、按鈕點擊、錯誤遭遇&lt;/td>
 &lt;td>產品團隊（UX 改善）&lt;/td>
 &lt;td>企劃階段&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>自架 vs 商業方案的取捨&lt;/strong>：&lt;/p></description><content:encoded><![CDATA[<p>開發測試教材的核心目標是教讀者理解「測試通過」和「產品正確」之間的差距如何產生、如何消除。Unit test 用 mock 遮蔽了協議差異、integration test 名為整合實為 fake、widget test 不覆蓋導航路徑 — 這些是測試策略的結構性盲區，來自設計取捨而非疏忽。本教材把品質驗證拆成可分層理解、可分步落地的知識路線。</p>
<h2 id="教學出發點">教學出發點</h2>
<p>這個系列從一個具體事件出發：一個 Flutter app 有 192 個 unit test 全部通過，但部署到真實 iOS 裝置後，WebSocket 連線、認證握手、終端機渲染三個核心功能全部失敗。根因是所有 test 都用同一個 <code>FakeWebSocketChannel</code>，永遠不觸碰真實 WebSocket 協議 — text vs binary frame 差異、auth token handshake、ANSI 控制序列多樣性，全部被 mock 完美遮蔽。</p>
<p>這個事件揭示的是一個跨語言、跨框架的結構性問題：<strong>當被測元件的正確性取決於與外部服務的協議契約時，mock 從結構上就無法驗證這件事。</strong></p>
<h2 id="教學範圍">教學範圍</h2>
<p>本系列聚焦「開發團隊能自己建立的品質驗證體系」，不討論 QA 組織或測試管理流程。</p>
<table>
  <thead>
      <tr>
          <th>放在本系列</th>
          <th>放在其他系列</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>測試策略分層（unit / protocol integration / screen state）</td>
          <td>特定語言的測試框架語法（放語言教材）</td>
      </tr>
      <tr>
          <td>客戶端可觀測性（連線生命週期 log、protocol 訊息 log、錯誤回報）</td>
          <td>伺服器端可觀測性平台（放 <a href="/blog/backend/04-observability/" data-link-title="模組四：可觀測性平台" data-link-desc="整理 log、metric、trace、dashboard 與 alert 的後端操作實務">Backend 04</a>）</td>
      </tr>
      <tr>
          <td>自架 log 收集（同區網、自有伺服器、開發期用途）</td>
          <td>商業 APM / crash reporting 產品評測</td>
      </tr>
      <tr>
          <td>協議整合測試（WS、gRPC、MQTT 等對真實服務驗證）</td>
          <td>負載測試、壓力測試（放 <a href="/blog/backend/09-performance-capacity/" data-link-title="模組九：效能工程與容量規劃" data-link-desc="把『目前配置能撐多少、要加多少機器』變成可量化、可驗證、可改進的工程流程">Backend 09</a>）</td>
      </tr>
      <tr>
          <td>自動化 UI 驗證（widget test、Playwright、螢幕狀態覆蓋）</td>
          <td>手動 QA 流程、測試案例管理工具</td>
      </tr>
      <tr>
          <td>測試設計判斷（mock 邊界、assertion 設計、flaky 診斷）</td>
          <td>CI pipeline 設定（放 <a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">Backend 06</a>）</td>
      </tr>
  </tbody>
</table>
<h2 id="與-backend-的關係">與 Backend 的關係</h2>
<p>Backend 教材的 <a href="/blog/backend/06-reliability/" data-link-title="模組六：可靠性驗證流程" data-link-desc="用 SRE 領域詞彙建問題節點、以服務級案例庫累積驗證脈絡，先建概念與案例庫再進實作交接">模組六：可靠性驗證</a> 聚焦「CI pipeline、load test、fuzz、chaos testing」— 伺服器端的品質閘門。本系列聚焦客戶端和協議層的驗證，兩者互補：</p>
<ul>
<li>Backend 告訴你「伺服器怎麼確保自己沒壞」</li>
<li>本系列告訴你「客戶端怎麼確保跟伺服器的互動沒壞」</li>
</ul>
<p>交叉點是 contract test 和 integration test — Backend 從伺服器端看、本系列從客戶端看，同一個介面的兩面。</p>
<h2 id="教學模組">教學模組</h2>
<h3 id="模組一測試策略分層">模組一：測試策略分層</h3>
<p>回答「什麼測試抓什麼問題」。把測試分為三層，每層有明確的職責和盲區：</p>
<table>
  <thead>
      <tr>
          <th>層</th>
          <th>職責</th>
          <th>驗證什麼</th>
          <th>抓不到什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Unit（mock）</td>
          <td>內部邏輯正確性</td>
          <td>狀態轉換、錯誤處理、資料轉換</td>
          <td>協議差異、真實服務行為、環境特異性</td>
      </tr>
      <tr>
          <td>Protocol integration</td>
          <td>協議契約正確性</td>
          <td>frame type、auth handshake、序列完整性</td>
          <td>UI 互動、畫面渲染、用戶體驗</td>
      </tr>
      <tr>
          <td>Screen state</td>
          <td>UI 行為正確性</td>
          <td>狀態轉換 UI、導航、用戶操作</td>
          <td>底層協議、網路行為</td>
      </tr>
  </tbody>
</table>
<p>判斷原則：被測元件直接對接外部協議（WS、gRPC、SMTP）→ 需要 protocol integration test。外部服務可在本機啟動 → 成本低，強烈建議。Mock 和真實服務之間有協議語意差異 → 必須。</p>
<blockquote>
<p>案例入口：<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> — 三個被 mock 遮蔽的真實問題</p></blockquote>
<h3 id="模組二客戶端可觀測性">模組二：客戶端可觀測性</h3>
<p>回答「使用者的裝置上發生了什麼事」。開發者不在使用者旁邊，需要系統性地收集執行時資訊。</p>
<p><strong>三層 log 設計</strong>：</p>
<table>
  <thead>
      <tr>
          <th>層級</th>
          <th>記錄什麼</th>
          <th>誰需要</th>
          <th>設計時機</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>連線生命週期</td>
          <td>connect / auth / handshake / data / disconnect 每步</td>
          <td>開發者（debug）</td>
          <td>企劃階段</td>
      </tr>
      <tr>
          <td>Protocol 訊息</td>
          <td>frame type、payload 前綴、auth 結果</td>
          <td>開發者（協議 debug）</td>
          <td>企劃階段</td>
      </tr>
      <tr>
          <td>使用者行為</td>
          <td>畫面切換、按鈕點擊、錯誤遭遇</td>
          <td>產品團隊（UX 改善）</td>
          <td>企劃階段</td>
      </tr>
  </tbody>
</table>
<p><strong>自架 vs 商業方案的取捨</strong>：</p>
<p>市面上有成熟的監控服務（Sentry、Firebase Crashlytics、Datadog RUM）可以埋在 app 或網頁中收集使用者行為和錯誤資訊。但：</p>
<ul>
<li>早期開發、開發者即使用者、同區網環境 → <strong>自架 log endpoint 就夠</strong>（打 HTTP POST 到自有伺服器、JSON 結構化 log、本機 grep 查詢）</li>
<li>多使用者、外部網路、需要 dashboard → 考慮商業方案或自架 ELK / Loki</li>
</ul>
<p><strong>設計原則</strong>：log 收集是開發需求的一部分，不是上線後才想的事後工程。連線生命週期的每一步該記什麼 log，應該在功能設計階段就確定 — 跟 API 規格和資料庫 schema 一樣是設計產物。</p>
<blockquote>
<p>後續章節預定：自架 log endpoint 實作、結構化 log schema 設計、log 分級策略、開發期 vs 上線期 log 切換</p></blockquote>
<h3 id="模組三協議整合測試">模組三：協議整合測試</h3>
<p>回答「我的 client 跟真實服務的互動是否正確」。這是 unit test（mock）和 E2E test（全棧）之間的一層，專注驗證協議契約。</p>
<p>適用場景：</p>
<table>
  <thead>
      <tr>
          <th>協議</th>
          <th>測試重點</th>
          <th>成本判斷</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>WebSocket</td>
          <td>frame type（text/binary）、子協議握手、auth 機制</td>
          <td>本機啟動 server → 低成本</td>
      </tr>
      <tr>
          <td>gRPC</td>
          <td>protobuf 版本相容、stream lifecycle</td>
          <td>本機 mock server → 中成本</td>
      </tr>
      <tr>
          <td>MQTT</td>
          <td>QoS level、retain、will message</td>
          <td>本機 broker → 低成本</td>
      </tr>
      <tr>
          <td>HTTP API</td>
          <td>status code 語意、header 契約、error format</td>
          <td>本機 stub → 低成本</td>
      </tr>
  </tbody>
</table>
<p><strong>自用工具的特殊優勢</strong>：server 和 client 都在同一台機器上時，protocol integration test 的成本極低 — 啟動真實服務然後跑 test，不需要模擬器或真實裝置。</p>
<blockquote>
<p>後續章節預定：WebSocket 協議測試實作、HTTP contract test 設計、CI 中的服務 fixture 管理</p></blockquote>
<h3 id="模組四自動化-ui-驗證">模組四：自動化 UI 驗證</h3>
<p>回答「畫面上的東西是否如設計工作」。Widget test、Playwright、screen state coverage。</p>
<blockquote>
<p>後續章節預定：widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕截圖比對</p></blockquote>
<h3 id="模組五測試設計判斷">模組五：測試設計判斷</h3>
<p>回答「這個斷言該怎麼寫」。Mock 邊界判斷、assertion 設計（計時依賴、浮點精度、快取驗證）、flaky test 診斷。</p>
<blockquote>
<p>後續章節預定：mock 邊界判斷決策表、斷言品質三問、flaky test 根因分類</p></blockquote>
<h2 id="學習路線">學習路線</h2>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>適合讀者</th>
          <th>建議順序</th>
          <th>讀完能做什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>測試策略入門</td>
          <td>想理解測試為什麼會漏掉真實問題</td>
          <td>模組一 → 模組三 → 模組二</td>
          <td>能判斷哪些行為需要 protocol test、哪些 mock 就夠</td>
      </tr>
      <tr>
          <td>客戶端品質閉環</td>
          <td>想在開發期就收集到 runtime 資訊</td>
          <td>模組二 → 模組三 → 模組四</td>
          <td>能設計 log 收集方案並在 CI 中驗證協議正確性</td>
      </tr>
      <tr>
          <td>測試設計精進</td>
          <td>已有測試但常遇 flaky 或 false positive</td>
          <td>模組五 → 模組一（重新審視分層）</td>
          <td>能診斷 flaky 根因、改善 assertion 設計</td>
      </tr>
  </tbody>
</table>
<h2 id="教學寫作方向">教學寫作方向</h2>
<p>本系列的寫作原則與 Backend 一致：先回答「這個能力解決什麼問題」，再展開判讀訊號、風險擴散、決策順序。</p>
<p>具體到測試教材的補充：</p>
<ol>
<li><strong>每個測試層級都要說明「抓不到什麼」</strong> — 知道盲區比知道能力更重要</li>
<li><strong>自架方案先於商業方案</strong> — 本系列的讀者多數是小團隊或個人開發者，先教能自己建的，再說什麼時候該引入商業方案</li>
<li><strong>Log 設計是需求，不是 debug 工具</strong> — 連線生命週期 log 應該在功能規格階段就確定，跟 API schema 一樣</li>
</ol>
<hr>
<p><em>文件版本：v0.1.0</em>
<em>最後更新：2026-06-19</em>
<em>系列狀態：分類索引建立中</em></p>
]]></content:encoded></item><item><title>開發測試案例庫</title><link>https://tarrragon.github.io/blog/testing/cases/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/</guid><description>&lt;p>這個資料夾收錄測試策略的實戰案例 — 重點不在「測試怎麼寫」，而在「測試為什麼沒抓到問題」。每個案例記錄一個真實的測試盲區、分析遮蔽機制、提出可重用的防護策略。&lt;/p>
&lt;p>案例來源分兩類：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>自有案例&lt;/strong>：app_tunnel 專案的實機測試教訓（first-party，有完整程式碼和 commit 歷史）&lt;/li>
&lt;li>&lt;strong>外部案例&lt;/strong>：開源專案和社群的已知測試陷阱（third-party，引用公開來源）&lt;/li>
&lt;/ul>
&lt;h2 id="案例覆蓋缺口">案例覆蓋缺口&lt;/h2>
&lt;p>以下章節目前公開 case 稀薄，Stage 0 採集後視覆蓋情況補強：&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>模組二（客戶端可觀測性）&lt;/td>
 &lt;td>自架 log endpoint 的實戰案例&lt;/td>
 &lt;td>多數公開案例偏商業方案（Sentry/Datadog）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>模組四（自動化 UI 驗證）&lt;/td>
 &lt;td>widget test 狀態覆蓋的 false negative 案例&lt;/td>
 &lt;td>待採集 Flutter/React 社群案例&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>模組五（測試設計判斷）&lt;/td>
 &lt;td>flaky test 根因分類的量化案例&lt;/td>
 &lt;td>CI 平台有公開統計但散落&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&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;th>遮蔽機制&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &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>WebSocket text/binary frame 被 mock 遮蔽&lt;/td>
 &lt;td>app_tunnel&lt;/td>
 &lt;td>protocol-integration&lt;/td>
 &lt;td>mock 不區分 frame type&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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>Auth handshake 邏輯缺失被 mock 遮蔽&lt;/td>
 &lt;td>app_tunnel&lt;/td>
 &lt;td>protocol-integration&lt;/td>
 &lt;td>mock 不需認證&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>| &lt;a href="https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3&lt;/a> | ANSI parser 測試資料不覆蓋真實 shell output | app_tunnel | unit-test-data | 手寫測試字串是乾淨子集 |
| &lt;a href="https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4&lt;/a> | Client-side log 缺失導致 debug 只能靠實機 | app_tunnel | observability | 企劃階段未設計 log 點 |&lt;/p></description><content:encoded><![CDATA[<p>這個資料夾收錄測試策略的實戰案例 — 重點不在「測試怎麼寫」，而在「測試為什麼沒抓到問題」。每個案例記錄一個真實的測試盲區、分析遮蔽機制、提出可重用的防護策略。</p>
<p>案例來源分兩類：</p>
<ul>
<li><strong>自有案例</strong>：app_tunnel 專案的實機測試教訓（first-party，有完整程式碼和 commit 歷史）</li>
<li><strong>外部案例</strong>：開源專案和社群的已知測試陷阱（third-party，引用公開來源）</li>
</ul>
<h2 id="案例覆蓋缺口">案例覆蓋缺口</h2>
<p>以下章節目前公開 case 稀薄，Stage 0 採集後視覆蓋情況補強：</p>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>缺口</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模組二（客戶端可觀測性）</td>
          <td>自架 log endpoint 的實戰案例</td>
          <td>多數公開案例偏商業方案（Sentry/Datadog）</td>
      </tr>
      <tr>
          <td>模組四（自動化 UI 驗證）</td>
          <td>widget test 狀態覆蓋的 false negative 案例</td>
          <td>待採集 Flutter/React 社群案例</td>
      </tr>
      <tr>
          <td>模組五（測試設計判斷）</td>
          <td>flaky test 根因分類的量化案例</td>
          <td>CI 平台有公開統計但散落</td>
      </tr>
  </tbody>
</table>
<h2 id="案例列表">案例列表</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主題</th>
          <th>來源</th>
          <th>測試層</th>
          <th>遮蔽機制</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <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>WebSocket text/binary frame 被 mock 遮蔽</td>
          <td>app_tunnel</td>
          <td>protocol-integration</td>
          <td>mock 不區分 frame type</td>
      </tr>
      <tr>
          <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>Auth handshake 邏輯缺失被 mock 遮蔽</td>
          <td>app_tunnel</td>
          <td>protocol-integration</td>
          <td>mock 不需認證</td>
      </tr>
  </tbody>
</table>
<p>| <a href="/blog/testing/cases/ansi-parser-test-data-blindspot/" data-link-title="T.C3 ANSI parser 測試資料不覆蓋真實 shell output" data-link-desc="ANSI parser 只處理基本 SGR 色彩碼、unit test 用手寫乾淨字串驗證 — 真實 zsh prompt 送出 OSC 標題設定、CSI private mode 游標隱藏、括號貼上模式等數十種控制序列，全部殘留為亂碼">T.C3</a> | ANSI parser 測試資料不覆蓋真實 shell output | app_tunnel | unit-test-data | 手寫測試字串是乾淨子集 |
| <a href="/blog/testing/cases/client-log-absent-debug-cost/" data-link-title="T.C4 Client-side log 缺失導致 debug 只能靠實機盲測" data-link-desc="Flutter app 六個核心元件中只有兩個有 log（且全是 W2 hotfix 補的），連線失敗時開發者無法從任何 log 判斷失敗發生在哪一步 — 被迫用最昂貴的 debug 方式：插拔裝置反覆測試">T.C4</a>    | Client-side log 缺失導致 debug 只能靠實機   | app_tunnel | observability  | 企劃階段未設計 log 點  |</p>
]]></content:encoded></item><item><title>新增欄位忘記同步 reset — 跨測試狀態洩漏的系統性根因</title><link>https://tarrragon.github.io/blog/work-log/%E6%96%B0%E5%A2%9E%E6%AC%84%E4%BD%8D%E5%BF%98%E8%A8%98%E5%90%8C%E6%AD%A5-reset-%E8%B7%A8%E6%B8%AC%E8%A9%A6%E7%8B%80%E6%85%8B%E6%B4%A9%E6%BC%8F%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%A0%B9%E5%9B%A0/</link><pubDate>Thu, 25 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E6%96%B0%E5%A2%9E%E6%AC%84%E4%BD%8D%E5%BF%98%E8%A8%98%E5%90%8C%E6%AD%A5-reset-%E8%B7%A8%E6%B8%AC%E8%A9%A6%E7%8B%80%E6%85%8B%E6%B4%A9%E6%BC%8F%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%A0%B9%E5%9B%A0/</guid><description>&lt;h2 id="事件">事件&lt;/h2>
&lt;p>JS SDK 的 Monitor class 在一輪並行開發中，三個開發者各自新增了 private 欄位：&lt;code>flushing&lt;/code>（flush 併發 guard）、&lt;code>retryCount&lt;/code>（重試計數）、&lt;code>lastHeartbeat&lt;/code>（心跳時間戳）。三個欄位各自在功能邏輯中被正確使用，但都沒有加進 &lt;code>__reset()&lt;/code> 方法。&lt;/p>
&lt;p>測試框架在每個 test case 之間呼叫 &lt;code>__reset()&lt;/code> 清理狀態。因為 &lt;code>retryCount&lt;/code> 沒被重置，第一個 test case 把 retryCount 遞增到 1，第二個 test case 繼承了這個值，retry 邏輯提前觸發，測試失敗。&lt;/p>
&lt;p>失敗的測試看起來像是 retry 邏輯有 bug，但實際上 retry 邏輯完全正確——問題出在測試隔離。&lt;/p>
&lt;h2 id="根因隱含契約沒有顯性化">根因：隱含契約沒有顯性化&lt;/h2>
&lt;p>Class 的每個 private 欄位都有一個隱含契約：「所有生命週期路徑都知道你的存在。」這包括初始化（constructor / init）、重置（reset / dispose）、序列化（toJSON，如適用）。&lt;/p>
&lt;p>新增欄位時，開發者通常會先在功能邏輯中使用這個欄位——因為那是他加欄位的目的。但「同步到 reset」不是功能邏輯的一部分，它是一個跨切面的維護動作。遺漏的機率隨欄位數和開發者數增加而上升。&lt;/p>
&lt;p>多人（或多 AI agent）並行開發時問題更嚴重——每個人只看自己加的欄位，沒有人有動機去檢查 reset 的完整性。並行修改同一檔案的協調問題見 &lt;a href="https://tarrragon.github.io/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/" data-link-title="並行 AI Agent 修改同一檔案的衝突模式與協調策略" data-link-desc="並行派多個開發者或 AI agent 同一批 ticket，反覆修改同一個檔案、卡在 branch protection 與 file-modified-since-read。問題在派發策略沒考慮檔案層級的衝突。">parallel_agent_same_file_conflict&lt;/a>。&lt;/p>
&lt;h2 id="防護state-registry-pattern">防護：State Registry Pattern&lt;/h2>
&lt;p>將所有 private 欄位的初始值集中宣告一次：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">initialState() {&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="k">return&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">config&lt;/span>: &lt;span class="kt">null&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="nx">buffer&lt;/span>&lt;span class="o">:&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="nx">flushing&lt;/span>: &lt;span class="kt">false&lt;/span>&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="nx">retryCount&lt;/span>: &lt;span class="kt">0&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="nx">lastHeartbeat&lt;/span>: &lt;span class="kt">0&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="c1">// 新增欄位加在這裡——init 和 reset 自動包含
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&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">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>reset 改用 &lt;code>Object.assign(this, initialState())&lt;/code>。新增欄位只改一處，init 和 reset 自動同步。&lt;/p>
&lt;p>配合一個 reset 完整性測試：reset 後 snapshot 比對 initialState 的所有 key——新增欄位但忘記加到 initialState 會因型別或 key 不一致而紅燈。&lt;/p>
&lt;h2 id="適用場景">適用場景&lt;/h2>
&lt;p>任何有「重置到初始狀態」需求的 class：測試框架的 setUp/tearDown、物件池的回收、singleton 的 reinit。問題在「新增欄位」和「同步 reset」是兩個分開的動作（TypeScript、Go、Dart 都會遇到）——只要是分開的，就有遺漏的可能。State Registry 把兩者合併成一個動作。&lt;/p></description><content:encoded><![CDATA[<h2 id="事件">事件</h2>
<p>JS SDK 的 Monitor class 在一輪並行開發中，三個開發者各自新增了 private 欄位：<code>flushing</code>（flush 併發 guard）、<code>retryCount</code>（重試計數）、<code>lastHeartbeat</code>（心跳時間戳）。三個欄位各自在功能邏輯中被正確使用，但都沒有加進 <code>__reset()</code> 方法。</p>
<p>測試框架在每個 test case 之間呼叫 <code>__reset()</code> 清理狀態。因為 <code>retryCount</code> 沒被重置，第一個 test case 把 retryCount 遞增到 1，第二個 test case 繼承了這個值，retry 邏輯提前觸發，測試失敗。</p>
<p>失敗的測試看起來像是 retry 邏輯有 bug，但實際上 retry 邏輯完全正確——問題出在測試隔離。</p>
<h2 id="根因隱含契約沒有顯性化">根因：隱含契約沒有顯性化</h2>
<p>Class 的每個 private 欄位都有一個隱含契約：「所有生命週期路徑都知道你的存在。」這包括初始化（constructor / init）、重置（reset / dispose）、序列化（toJSON，如適用）。</p>
<p>新增欄位時，開發者通常會先在功能邏輯中使用這個欄位——因為那是他加欄位的目的。但「同步到 reset」不是功能邏輯的一部分，它是一個跨切面的維護動作。遺漏的機率隨欄位數和開發者數增加而上升。</p>
<p>多人（或多 AI agent）並行開發時問題更嚴重——每個人只看自己加的欄位，沒有人有動機去檢查 reset 的完整性。並行修改同一檔案的協調問題見 <a href="/blog/work-log/%E4%B8%A6%E8%A1%8C-ai-agent-%E4%BF%AE%E6%94%B9%E5%90%8C%E4%B8%80%E6%AA%94%E6%A1%88%E7%9A%84%E8%A1%9D%E7%AA%81%E6%A8%A1%E5%BC%8F%E8%88%87%E5%8D%94%E8%AA%BF%E7%AD%96%E7%95%A5/" data-link-title="並行 AI Agent 修改同一檔案的衝突模式與協調策略" data-link-desc="並行派多個開發者或 AI agent 同一批 ticket，反覆修改同一個檔案、卡在 branch protection 與 file-modified-since-read。問題在派發策略沒考慮檔案層級的衝突。">parallel_agent_same_file_conflict</a>。</p>
<h2 id="防護state-registry-pattern">防護：State Registry Pattern</h2>
<p>將所有 private 欄位的初始值集中宣告一次：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">initialState() {</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="nx">config</span>: <span class="kt">null</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">buffer</span><span class="o">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">flushing</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">retryCount</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">lastHeartbeat</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="c1">// 新增欄位加在這裡——init 和 reset 自動包含
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <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>reset 改用 <code>Object.assign(this, initialState())</code>。新增欄位只改一處，init 和 reset 自動同步。</p>
<p>配合一個 reset 完整性測試：reset 後 snapshot 比對 initialState 的所有 key——新增欄位但忘記加到 initialState 會因型別或 key 不一致而紅燈。</p>
<h2 id="適用場景">適用場景</h2>
<p>任何有「重置到初始狀態」需求的 class：測試框架的 setUp/tearDown、物件池的回收、singleton 的 reinit。問題在「新增欄位」和「同步 reset」是兩個分開的動作（TypeScript、Go、Dart 都會遇到）——只要是分開的，就有遺漏的可能。State Registry 把兩者合併成一個動作。</p>
]]></content:encoded></item><item><title>10 個 Ticket、57 個綠燈、0 條追溯：從需求文件到測試的銜接檢討</title><link>https://tarrragon.github.io/blog/work-log/10-%E5%80%8B-ticket57-%E5%80%8B%E7%B6%A0%E7%87%880-%E6%A2%9D%E8%BF%BD%E6%BA%AF%E5%BE%9E%E9%9C%80%E6%B1%82%E6%96%87%E4%BB%B6%E5%88%B0%E6%B8%AC%E8%A9%A6%E7%9A%84%E9%8A%9C%E6%8E%A5%E6%AA%A2%E8%A8%8E/</link><pubDate>Tue, 23 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/10-%E5%80%8B-ticket57-%E5%80%8B%E7%B6%A0%E7%87%880-%E6%A2%9D%E8%BF%BD%E6%BA%AF%E5%BE%9E%E9%9C%80%E6%B1%82%E6%96%87%E4%BB%B6%E5%88%B0%E6%B8%AC%E8%A9%A6%E7%9A%84%E9%8A%9C%E6%8E%A5%E6%AA%A2%E8%A8%8E/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>57 個 unit test 全綠，但沒有任何機制能回答「這些測試覆蓋了哪些 UseCase 場景」。&lt;/p>&lt;/blockquote>
&lt;p>monitor 專案 v0.1.0 從需求文件系統（Proposal → Spec → UseCase）一路走到 Collector 實作，中間經過 BDD 測試設計、紅燈測試撰寫、骨架實作讓綠。流程表面上順暢——10 個根 Ticket 全部完成、Collector 可啟動、所有 unit test 通過。但回頭檢視發現：需求→測試的銜接是單向管道，沒有反向追溯，也沒有邊界回補流程。&lt;/p>
&lt;p>本文記錄 v0.1.0 的完整流程、發現的五個結構性差異、和落地的解決方案。&lt;/p>
&lt;hr>
&lt;h2 id="實際走過的流程">實際走過的流程&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">saas 選型訪談
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> → Proposal（MVP 範圍界定）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> → Spec（14 份，涵蓋 schema/ingestion/query/storage/rule-engine/SDK）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> → UseCase（5 個，UC-01 端到端事件流 ~ UC-05 Web 監控）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> → BDD 測試設計 ANA（全專案 26 個行為場景 → 整合/單元/協議測試清單）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> → 紅燈測試（9 個 Ticket 並行，72 個測試 FAIL）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> → 骨架實作（1 個 Ticket，57 個 unit test GREEN）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個箭頭都有對應的框架機制：saas→doc 有 Stage 6 銜接、doc→TDD 有 doc-handoff 映射表。但箭頭只往右——沒有任何箭頭往左。&lt;/p>
&lt;hr>
&lt;h2 id="五個結構性差異">五個結構性差異&lt;/h2>
&lt;h3 id="差異-1全專案-bdd-設計不在-tdd-phase-模型中">差異 1：「全專案 BDD 設計」不在 TDD Phase 模型中&lt;/h3>
&lt;p>TDD Skill 定義 Phase 0→1→2→3→4 的逐功能流程。v0.1.0 做的是「全專案 UseCase 一次性展開為 BDD 測試設計」，跨越 Phase 1 和 Phase 2 的邊界，粒度是專案級不是功能級。&lt;/p>
&lt;p>這不是 Phase 設計的錯——Phase 模型適合增量開發（每次加一個功能）。新專案起手是不同的工作模式：批量設計、模組群組粒度。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：在 doc-handoff 新增「新專案起手模式」章節，描述批量 BDD 設計流程、Phase 0 豁免條件、模組群組粒度。&lt;/p>
&lt;h3 id="差異-2紅燈測試需要存根stub">差異 2：紅燈測試需要存根（stub）&lt;/h3>
&lt;p>Go 是靜態語言，&lt;code>go test&lt;/code> 必須編譯通過才能執行。紅燈測試引用的 type/interface 不存在時直接編譯失敗，不是「測試 FAIL」。&lt;/p>
&lt;p>TDD Skill 的 Phase 2 說「設計測試」、Phase 3b 說「讓測試綠」，但中間的「建存根讓測試可紅」沒有定義。&lt;/p>
&lt;p>&lt;strong>實作驗證&lt;/strong>：v0.1.0 的每個紅燈 Ticket 都自帶建立存根（空 function return nil / 空 struct / 回 501 的 HTTP handler），存根讓 &lt;code>go test&lt;/code> 編譯通過，合法測試 PASS、非法測試 FAIL = 紅燈狀態。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：Phase 3 rules 新增「存根策略」章節，涵蓋靜態語言（Go/Dart）和動態語言（Python/JS）的不同處理。&lt;/p>
&lt;h3 id="差異-3測試usecase-沒有反向追溯">差異 3：測試→UseCase 沒有反向追溯&lt;/h3>
&lt;p>寫完 57 個 unit test 後，問「UC-01 的替代場景 01a（批次部分失敗 → 207）被哪些測試覆蓋？」——沒有任何機制能回答。&lt;/p>
&lt;p>&lt;code>doc test-map UC-01&lt;/code> 工具存在但回傳 0 個測試——因為它搜尋 UC frontmatter 的 &lt;code>ticket_refs&lt;/code>，和測試檔案沒有連結。Spec 的「三方交叉比對」是建 Ticket 時的一次性動作，不是持續追溯。&lt;/p>
&lt;p>&lt;strong>解法&lt;/strong>：建立 &lt;code>docs/traceability.yaml&lt;/code> 追溯矩陣，三層追溯（UC 場景 → 整合測試 IT-* → 單元測試 UT-* → Spec FR）。每個 entry 標記 &lt;code>covered&lt;/code> / &lt;code>gap&lt;/code> / &lt;code>deferred&lt;/code>。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>57 個 unit test 全綠，但沒有任何機制能回答「這些測試覆蓋了哪些 UseCase 場景」。</p></blockquote>
<p>monitor 專案 v0.1.0 從需求文件系統（Proposal → Spec → UseCase）一路走到 Collector 實作，中間經過 BDD 測試設計、紅燈測試撰寫、骨架實作讓綠。流程表面上順暢——10 個根 Ticket 全部完成、Collector 可啟動、所有 unit test 通過。但回頭檢視發現：需求→測試的銜接是單向管道，沒有反向追溯，也沒有邊界回補流程。</p>
<p>本文記錄 v0.1.0 的完整流程、發現的五個結構性差異、和落地的解決方案。</p>
<hr>
<h2 id="實際走過的流程">實際走過的流程</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">saas 選型訪談
</span></span><span class="line"><span class="ln">2</span><span class="cl">  → Proposal（MVP 範圍界定）
</span></span><span class="line"><span class="ln">3</span><span class="cl">    → Spec（14 份，涵蓋 schema/ingestion/query/storage/rule-engine/SDK）
</span></span><span class="line"><span class="ln">4</span><span class="cl">      → UseCase（5 個，UC-01 端到端事件流 ~ UC-05 Web 監控）
</span></span><span class="line"><span class="ln">5</span><span class="cl">        → BDD 測試設計 ANA（全專案 26 個行為場景 → 整合/單元/協議測試清單）
</span></span><span class="line"><span class="ln">6</span><span class="cl">          → 紅燈測試（9 個 Ticket 並行，72 個測試 FAIL）
</span></span><span class="line"><span class="ln">7</span><span class="cl">            → 骨架實作（1 個 Ticket，57 個 unit test GREEN）</span></span></code></pre></div><p>每個箭頭都有對應的框架機制：saas→doc 有 Stage 6 銜接、doc→TDD 有 doc-handoff 映射表。但箭頭只往右——沒有任何箭頭往左。</p>
<hr>
<h2 id="五個結構性差異">五個結構性差異</h2>
<h3 id="差異-1全專案-bdd-設計不在-tdd-phase-模型中">差異 1：「全專案 BDD 設計」不在 TDD Phase 模型中</h3>
<p>TDD Skill 定義 Phase 0→1→2→3→4 的逐功能流程。v0.1.0 做的是「全專案 UseCase 一次性展開為 BDD 測試設計」，跨越 Phase 1 和 Phase 2 的邊界，粒度是專案級不是功能級。</p>
<p>這不是 Phase 設計的錯——Phase 模型適合增量開發（每次加一個功能）。新專案起手是不同的工作模式：批量設計、模組群組粒度。</p>
<p><strong>解法</strong>：在 doc-handoff 新增「新專案起手模式」章節，描述批量 BDD 設計流程、Phase 0 豁免條件、模組群組粒度。</p>
<h3 id="差異-2紅燈測試需要存根stub">差異 2：紅燈測試需要存根（stub）</h3>
<p>Go 是靜態語言，<code>go test</code> 必須編譯通過才能執行。紅燈測試引用的 type/interface 不存在時直接編譯失敗，不是「測試 FAIL」。</p>
<p>TDD Skill 的 Phase 2 說「設計測試」、Phase 3b 說「讓測試綠」，但中間的「建存根讓測試可紅」沒有定義。</p>
<p><strong>實作驗證</strong>：v0.1.0 的每個紅燈 Ticket 都自帶建立存根（空 function return nil / 空 struct / 回 501 的 HTTP handler），存根讓 <code>go test</code> 編譯通過，合法測試 PASS、非法測試 FAIL = 紅燈狀態。</p>
<p><strong>解法</strong>：Phase 3 rules 新增「存根策略」章節，涵蓋靜態語言（Go/Dart）和動態語言（Python/JS）的不同處理。</p>
<h3 id="差異-3測試usecase-沒有反向追溯">差異 3：測試→UseCase 沒有反向追溯</h3>
<p>寫完 57 個 unit test 後，問「UC-01 的替代場景 01a（批次部分失敗 → 207）被哪些測試覆蓋？」——沒有任何機制能回答。</p>
<p><code>doc test-map UC-01</code> 工具存在但回傳 0 個測試——因為它搜尋 UC frontmatter 的 <code>ticket_refs</code>，和測試檔案沒有連結。Spec 的「三方交叉比對」是建 Ticket 時的一次性動作，不是持續追溯。</p>
<p><strong>解法</strong>：建立 <code>docs/traceability.yaml</code> 追溯矩陣，三層追溯（UC 場景 → 整合測試 IT-* → 單元測試 UT-* → Spec FR）。每個 entry 標記 <code>covered</code> / <code>gap</code> / <code>deferred</code>。</p>
<h3 id="差異-4邊界條件發現後沒有回補-uc-的流程">差異 4：邊界條件發現後沒有回補 UC 的流程</h3>
<p>寫 Ingest Handler 測試時發現：「如果 POST body 不是 JSON 怎麼辦？」「如果 Content-Type 是 text/plain（sendBeacon）怎麼辦？」這些邊界在 UC-01 的場景描述中不存在。</p>
<p>測試設計的 BDD ANA 有涵蓋這些邊界場景，但 UC 文件本身沒有更新。邊界條件「住」在測試設計文件而非 UseCase——下次有人讀 UC 不會知道這些邊界存在。</p>
<p><strong>解法</strong>：追溯矩陣增加 <code>boundaries:</code> 區段，測試撰寫者發現新邊界時加 gap entry，PM 建 DOC Ticket 回補 UC/Spec。Phase 4d 掃描所有 gap 確認無遺漏。</p>
<h3 id="差異-5ticket-拆分邊界未對齊測試變綠驗收點">差異 5：Ticket 拆分邊界未對齊測試變綠驗收點</h3>
<p>Collector 實作被拆為 4 個 Ticket：骨架（interface 定義）/ Storage / Ingestion Handler / Query Handler。骨架 Ticket 指派做「main.go + Config + Storage interface」，代理人完成了所有模組實作——57 個 unit test 從紅全部變綠，其餘 3 個 Ticket 的 acceptance 全被涵蓋。</p>
<p>初看像是「代理人超額完成」，回頭用判讀三問檢查骨架 Ticket：完成後有測試變綠嗎？→ 沒有（只定義 interface）。能獨立跑測試嗎？→ 不能（其他模組引用骨架的 type）。共用 type？→ 是。三問全部指向「不應獨立拆」。<strong>根因是 Ticket 拆分設計</strong>，不是代理人行為——按 Spec FR 拆（輸入驅動）導致骨架 Ticket 完成後 0 個測試狀態改變，不是有意義的驗收點。</p>
<p><strong>判讀規則</strong>：實作 Ticket 的拆分邊界必須對齊「測試從紅變綠」的驗收點。一個 Ticket 完成後若沒有任何測試狀態改變，它不應該是獨立 Ticket。</p>
<p>判讀三問：</p>
<ol>
<li>這個 Ticket 完成後，有測試從 FAIL 變 PASS 嗎？</li>
<li>拆出的各部分能獨立跑測試嗎？</li>
<li>不同部分共用同一組 type/error/constant 嗎？</li>
</ol>
<p><strong>反模式</strong>：按 Spec FR 拆（輸入驅動）。<strong>正確做法</strong>：按「哪組測試變綠」拆（輸出驅動）。</p>
<hr>
<h2 id="追溯矩陣的設計">追溯矩陣的設計</h2>
<p>追溯矩陣是三個問題（向上追溯 + 覆蓋驗證 + 邊界回補）的統一解法。</p>
<h3 id="結構">結構</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="nt">UC-01</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="w">  </span><span class="nt">title</span><span class="p">:</span><span class="w"> </span><span class="l">端到端事件流</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="w">  </span><span class="nt">scenarios</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="w">    </span><span class="nt">main</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="w">      </span><span class="nt">integration_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">IT-01-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="w">      </span><span class="nt">unit_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">UT-COL-01-01, UT-COL-02-01, UT-COL-04-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="w">      </span><span class="nt">spec_frs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">SPEC-002-FR-01, SPEC-003-FR-01]</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="w">      </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">covered</span><span class="w">
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="w">    </span><span class="nt">alt-01a</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="w">      </span><span class="nt">integration_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">IT-01-02]</span><span class="w">
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="w">      </span><span class="nt">unit_tests</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">UT-COL-01-03, UT-COL-02-03]</span><span class="w">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="w">      </span><span class="nt">spec_frs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">SPEC-002-FR-02]</span><span class="w">
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="w">      </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">covered</span><span class="w">
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="w"></span><span class="nt">boundaries</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="w">  </span><span class="nt">batch-limit</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="w">    </span><span class="nt">discovered_during</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;ingestion-handler-red-tests&#34;</span><span class="w">
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="w">    </span><span class="nt">status</span><span class="p">:</span><span class="w"> </span><span class="l">gap </span><span class="w"> </span><span class="c"># 需回補 UC/Spec</span></span></span></code></pre></div><h3 id="三個問題的對應">三個問題的對應</h3>
<table>
  <thead>
      <tr>
          <th>問題</th>
          <th>矩陣欄位</th>
          <th>查法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>這個 UT 為了哪個 UC？</td>
          <td><code>unit_tests</code></td>
          <td>搜尋 UT ID → 找到歸屬的 scenario</td>
      </tr>
      <tr>
          <td>UC 場景都有測試嗎？</td>
          <td><code>status</code></td>
          <td>掃描 <code>gap</code> entry</td>
      </tr>
      <tr>
          <td>新邊界怎麼回補 UC？</td>
          <td><code>boundaries</code></td>
          <td>gap entry → DOC Ticket → 回補 → covered</td>
      </tr>
  </tbody>
</table>
<h3 id="整合點">整合點</h3>
<table>
  <thead>
      <tr>
          <th>機制</th>
          <th>時機</th>
          <th>動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>doc-handoff</td>
          <td>銜接時</td>
          <td>初始化矩陣骨架（UC scenario 空映射）</td>
      </tr>
      <tr>
          <td>紅燈測試撰寫</td>
          <td>Phase 2→3</td>
          <td>填入 unit_tests 映射</td>
      </tr>
      <tr>
          <td>邊界發現</td>
          <td>實作中</td>
          <td>加 boundary gap entry</td>
      </tr>
      <tr>
          <td>Phase 4d</td>
          <td>重構評估</td>
          <td>掃描所有 gap，建 DOC Ticket</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="附帶發現並行派發的-git-隔離問題">附帶發現：並行派發的 Git 隔離問題</h2>
<p>5 個代理人以 worktree 並行派發時，commit 內容交叉混入——A 代理人的 commit 包含 B 代理人的檔案。根因：主 repo 不在 main 分支，多個 worktree 共用同一分支 ref，<code>git add + commit</code> race condition。</p>
<p><strong>防護</strong>：派發前確保主 repo 在 main + 已 push。單一代理人和正確條件下的多代理人都驗證通過。</p>
<hr>
<h2 id="結論">結論</h2>
<p>v0.1.0 的流程不是失敗——Collector 可用、57 個 test GREEN。問題在於「走到終點後沒有辦法回頭驗證起點」。需求→測試的管道是單向的：Proposal 說了什麼、Spec 定了什麼 FR、UC 描述了什麼場景，和最終的測試之間沒有結構化連結。</p>
<p>追溯矩陣不增加任何程式碼——它是一個 YAML 檔案，記錄「每個測試為什麼存在」。維護成本是每次寫測試多填一行映射。回報是：任何時候都能回答「這個 UC 場景有沒有被測試保護」。</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><item><title>Testing 知識卡片</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/</guid><description>&lt;p>測試策略教學中出現的關鍵術語卡片。每張卡片說明一個語意責任，跨情境變義的概念拆成獨立卡片。&lt;/p></description><content:encoded>&lt;p>測試策略教學中出現的關鍵術語卡片。每張卡片說明一個語意責任，跨情境變義的概念拆成獨立卡片。&lt;/p>
</content:encoded></item><item><title>Firestore Security Rules Test Lab</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/</link><pubDate>Tue, 16 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/security-rules-test-lab/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線&lt;/a> 的 lab，實作 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模&lt;/a> deep article 的測試方法。前置環境見 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">Local emulator quickstart&lt;/a>。測試 API 以 &lt;a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing 文件&lt;/a> 為準、最後檢查日 2026-06-16。&lt;/p>&lt;/blockquote>
&lt;p>Firestore Security Rules test lab 的核心責任是把授權規則變成可自動驗證的測試。規則是 client 直連模型的整個控制面，改一條就要證明沒開新洞——這個 lab 用 &lt;code>@firebase/rules-unit-testing&lt;/code> 在 emulator 上對規則跑斷言，產出可接進 CI 與 release gate 的測試 evidence。&lt;/p>
&lt;p>本文的驗收標準是：你能對一組規則寫出「放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕」四類斷言、用 &lt;code>firebase emulators:exec&lt;/code> 一鍵跑完、並看到 &lt;code>assertFails&lt;/code> 確實證明該擋的有擋住。&lt;/p>
&lt;h2 id="lab-環境與依賴">Lab 環境與依賴&lt;/h2>
&lt;p>沿用 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">quickstart&lt;/a> 的工作區與 &lt;code>firebase.json&lt;/code> / &lt;code>firestore.rules&lt;/code>。再裝測試依賴。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nb">cd&lt;/span> /tmp/firestore-lab
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">npm install --save-dev @firebase/rules-unit-testing firebase jest&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>驗收前置是 &lt;code>firestore.rules&lt;/code> 存在（quickstart 已建立 owner-scoped 規則）與 &lt;code>firebase.json&lt;/code> 宣告了 Firestore emulator。&lt;/p>
&lt;h2 id="升級規則加入欄位竄改防護">升級規則：加入欄位竄改防護&lt;/h2>
&lt;p>quickstart 的規則擋了越權讀寫，但還沒擋「owner 改自己 note 時偷改 &lt;code>ownerId&lt;/code> 把資料轉走」。先把規則升級到帶欄位白名單，讓測試有更多面向可驗。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">cat &amp;gt; firestore.rules &lt;span class="s">&amp;lt;&amp;lt;&amp;#39;RULES&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="s">rules_version = &amp;#39;2&amp;#39;;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="s">service cloud.firestore {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="s"> match /databases/{database}/documents {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">&lt;span class="s"> function isSignedIn() { return request.auth != null; }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">&lt;span class="s"> function ownsExisting() {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="s"> return isSignedIn() &amp;amp;&amp;amp; resource.data.ownerId == request.auth.uid;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="s"> function onlyChanges(fields) {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="s"> return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="s">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&lt;span class="cl">&lt;span class="s"> match /notes/{noteId} {
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl">&lt;span class="s"> allow read: if ownsExisting();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="s"> allow create: if isSignedIn()
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl">&lt;span class="s"> &amp;amp;&amp;amp; request.resource.data.ownerId == request.auth.uid;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">&lt;span class="s"> allow update: if ownsExisting() &amp;amp;&amp;amp; onlyChanges([&amp;#39;text&amp;#39;, &amp;#39;updatedAt&amp;#39;]);
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl">&lt;span class="s"> allow delete: if ownsExisting();
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl">&lt;span class="s"> }
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl">&lt;span class="s">}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl">&lt;span class="s">RULES&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>onlyChanges(['text', 'updatedAt'])&lt;/code> 是這版的重點：update 只准動 &lt;code>text&lt;/code> 與 &lt;code>updatedAt&lt;/code>，碰 &lt;code>ownerId&lt;/code> 直接拒絕。下面的測試會驗證它。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a> 的 lab，實作 <a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模</a> deep article 的測試方法。前置環境見 <a href="/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">Local emulator quickstart</a>。測試 API 以 <a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing 文件</a> 為準、最後檢查日 2026-06-16。</p></blockquote>
<p>Firestore Security Rules test lab 的核心責任是把授權規則變成可自動驗證的測試。規則是 client 直連模型的整個控制面，改一條就要證明沒開新洞——這個 lab 用 <code>@firebase/rules-unit-testing</code> 在 emulator 上對規則跑斷言，產出可接進 CI 與 release gate 的測試 evidence。</p>
<p>本文的驗收標準是：你能對一組規則寫出「放行 / 越權拒絕 / 未登入拒絕 / 欄位竄改拒絕」四類斷言、用 <code>firebase emulators:exec</code> 一鍵跑完、並看到 <code>assertFails</code> 確實證明該擋的有擋住。</p>
<h2 id="lab-環境與依賴">Lab 環境與依賴</h2>
<p>沿用 <a href="/blog/backend/01-database/vendors/firestore/hands-on/local-emulator-quickstart/" data-link-title="Firestore Local Emulator Quickstart" data-link-desc="用 Firebase CLI 啟動 Firestore emulator、寫 firestore.rules、用 admin SDK seed 資料、跑 query baseline 與 cleanup，建立後續 Security Rules 與 distributed counter lab 共用的本地環境">quickstart</a> 的工作區與 <code>firebase.json</code> / <code>firestore.rules</code>。再裝測試依賴。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">cd</span> /tmp/firestore-lab
</span></span><span class="line"><span class="ln">2</span><span class="cl">npm install --save-dev @firebase/rules-unit-testing firebase jest</span></span></code></pre></div><p>驗收前置是 <code>firestore.rules</code> 存在（quickstart 已建立 owner-scoped 規則）與 <code>firebase.json</code> 宣告了 Firestore emulator。</p>
<h2 id="升級規則加入欄位竄改防護">升級規則：加入欄位竄改防護</h2>
<p>quickstart 的規則擋了越權讀寫，但還沒擋「owner 改自己 note 時偷改 <code>ownerId</code> 把資料轉走」。先把規則升級到帶欄位白名單，讓測試有更多面向可驗。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; firestore.rules <span class="s">&lt;&lt;&#39;RULES&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">rules_version = &#39;2&#39;;
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">service cloud.firestore {
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">  match /databases/{database}/documents {
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">    function isSignedIn() { return request.auth != null; }
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">    function ownsExisting() {
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">      return isSignedIn() &amp;&amp; resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    function onlyChanges(fields) {
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">      return request.resource.data.diff(resource.data).affectedKeys().hasOnly(fields);
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">    match /notes/{noteId} {
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">      allow read: if ownsExisting();
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">      allow create: if isSignedIn()
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">                    &amp;&amp; request.resource.data.ownerId == request.auth.uid;
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">      allow update: if ownsExisting() &amp;&amp; onlyChanges([&#39;text&#39;, &#39;updatedAt&#39;]);
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">      allow delete: if ownsExisting();
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">    }
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">  }
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">}
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">RULES</span></span></span></code></pre></div><p><code>onlyChanges(['text', 'updatedAt'])</code> 是這版的重點：update 只准動 <code>text</code> 與 <code>updatedAt</code>，碰 <code>ownerId</code> 直接拒絕。下面的測試會驗證它。</p>
<h2 id="寫測試四類斷言">寫測試：四類斷言</h2>
<p>測試的核心責任是覆蓋「該放行的放行、該拒絕的拒絕」。<code>initializeTestEnvironment</code> 載入規則、<code>authenticatedContext</code> 模擬登入身分、<code>assertSucceeds</code> / <code>assertFails</code> 對操作斷言。預先種資料用 <code>withSecurityRulesDisabled</code> 繞過規則。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl">cat &gt; rules.test.js <span class="s">&lt;&lt;&#39;JS&#39;
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="s">const {
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="s">  initializeTestEnvironment, assertFails, assertSucceeds,
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="s">} = require(&#39;@firebase/rules-unit-testing&#39;);
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="s">const { doc, getDoc, setDoc, updateDoc } = require(&#39;firebase/firestore&#39;);
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="s">const fs = require(&#39;fs&#39;);
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="s">let testEnv;
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="s">beforeAll(async () =&gt; {
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="s">  testEnv = await initializeTestEnvironment({
</span></span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="s">    projectId: &#39;demo-firestore-lab&#39;,
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="s">    firestore: { rules: fs.readFileSync(&#39;firestore.rules&#39;, &#39;utf8&#39;) },
</span></span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="s">  });
</span></span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">16</span><span class="cl"><span class="s">afterAll(async () =&gt; { await testEnv.cleanup(); });
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="s">beforeEach(async () =&gt; {
</span></span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="s">  await testEnv.clearFirestore();
</span></span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="s">  await testEnv.withSecurityRulesDisabled(async (ctx) =&gt; {
</span></span></span><span class="line"><span class="ln">20</span><span class="cl"><span class="s">    await setDoc(doc(ctx.firestore(), &#39;notes/n1&#39;),
</span></span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="s">      { ownerId: &#39;alice&#39;, text: &#39;hi&#39;, updatedAt: 0 });
</span></span></span><span class="line"><span class="ln">22</span><span class="cl"><span class="s">  });
</span></span></span><span class="line"><span class="ln">23</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="s">// 1. 放行：owner 讀自己的
</span></span></span><span class="line"><span class="ln">26</span><span class="cl"><span class="s">test(&#39;owner reads own note&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">27</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;alice&#39;).firestore();
</span></span></span><span class="line"><span class="ln">28</span><span class="cl"><span class="s">  await assertSucceeds(getDoc(doc(db, &#39;notes/n1&#39;)));
</span></span></span><span class="line"><span class="ln">29</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">30</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">31</span><span class="cl"><span class="s">// 2. 越權拒絕：非 owner 讀別人的
</span></span></span><span class="line"><span class="ln">32</span><span class="cl"><span class="s">test(&#39;non-owner cannot read&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">33</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;bob&#39;).firestore();
</span></span></span><span class="line"><span class="ln">34</span><span class="cl"><span class="s">  await assertFails(getDoc(doc(db, &#39;notes/n1&#39;)));
</span></span></span><span class="line"><span class="ln">35</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">36</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">37</span><span class="cl"><span class="s">// 3. 未登入拒絕
</span></span></span><span class="line"><span class="ln">38</span><span class="cl"><span class="s">test(&#39;unauthenticated denied&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">39</span><span class="cl"><span class="s">  const db = testEnv.unauthenticatedContext().firestore();
</span></span></span><span class="line"><span class="ln">40</span><span class="cl"><span class="s">  await assertFails(getDoc(doc(db, &#39;notes/n1&#39;)));
</span></span></span><span class="line"><span class="ln">41</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">43</span><span class="cl"><span class="s">// 4. 欄位竄改拒絕：owner 偷改 ownerId
</span></span></span><span class="line"><span class="ln">44</span><span class="cl"><span class="s">test(&#39;owner cannot change ownerId&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">45</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;alice&#39;).firestore();
</span></span></span><span class="line"><span class="ln">46</span><span class="cl"><span class="s">  await assertFails(updateDoc(doc(db, &#39;notes/n1&#39;), { ownerId: &#39;bob&#39; }));
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">48</span><span class="cl"><span class="s">
</span></span></span><span class="line"><span class="ln">49</span><span class="cl"><span class="s">// 4b. 正當 update 放行
</span></span></span><span class="line"><span class="ln">50</span><span class="cl"><span class="s">test(&#39;owner can edit text&#39;, async () =&gt; {
</span></span></span><span class="line"><span class="ln">51</span><span class="cl"><span class="s">  const db = testEnv.authenticatedContext(&#39;alice&#39;).firestore();
</span></span></span><span class="line"><span class="ln">52</span><span class="cl"><span class="s">  await assertSucceeds(updateDoc(doc(db, &#39;notes/n1&#39;), { text: &#39;edited&#39;, updatedAt: 1 }));
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="s">});
</span></span></span><span class="line"><span class="ln">54</span><span class="cl"><span class="s">JS</span></span></span></code></pre></div><p>四類斷言裡 <code>assertFails</code> 比 <code>assertSucceeds</code> 更重要——它證明的是攻擊路徑被擋住，正是滲透測試會打的點。每條規則至少要有「正向放行 + 至少一條拒絕」配對，光測 happy path 證明不了授權安全。</p>
<h2 id="一鍵跑emulatorsexec">一鍵跑：emulators:exec</h2>
<p>跑測試的核心責任是讓它在乾淨 emulator 上自動化執行。<code>firebase emulators:exec</code> 啟動 emulator、跑指定命令、結束後關閉——適合 CI，不需要手動開關 emulator。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">cat &gt; package.json.test <span class="s">&lt;&lt;&#39;JSON&#39;
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="s">{ &#34;scripts&#34;: { &#34;test:rules&#34;: &#34;jest rules.test.js&#34; } }
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="s">JSON</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 把 test:rules script 併進既有 package.json 後執行：</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">firebase emulators:exec --only firestore --project demo-firestore-lab <span class="s2">&#34;npx jest rules.test.js&#34;</span></span></span></code></pre></div><p>預期輸出五個測試全 pass：</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">PASS  ./rules.test.js
</span></span><span class="line"><span class="ln">2</span><span class="cl">  owner reads own note (passed)
</span></span><span class="line"><span class="ln">3</span><span class="cl">  non-owner cannot read (passed)
</span></span><span class="line"><span class="ln">4</span><span class="cl">  unauthenticated denied (passed)
</span></span><span class="line"><span class="ln">5</span><span class="cl">  owner cannot change ownerId (passed)
</span></span><span class="line"><span class="ln">6</span><span class="cl">  owner can edit text (passed)
</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">Test Suites: 1 passed, 1 total
</span></span><span class="line"><span class="ln">9</span><span class="cl">Tests:       5 passed, 5 total</span></span></code></pre></div><p>（Jest 預設 reporter 每行會印一個通過標記、此處以 <code>(passed)</code> 文字呈現，實際終端輸出為工具自身格式。）</p>
<h2 id="故意改壞驗證測試有效">故意改壞驗證測試有效</h2>
<p>測試的價值在於它會抓到回歸。把規則改回 <code>allow read, write: if true</code> 再跑，應看到「越權拒絕」「未登入拒絕」「欄位竄改拒絕」三個測試 fail——這證明測試確實守在攻擊路徑上，而不是恆綠的假測試。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 暫時把規則改成全放行</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nb">printf</span> <span class="s2">&#34;rules_version=&#39;2&#39;;\nservice cloud.firestore{match /databases/{db}/documents{match /{d=**}{allow read,write:if true;}}}&#34;</span> &gt; firestore.rules
</span></span><span class="line"><span class="ln">3</span><span class="cl">firebase emulators:exec --only firestore --project demo-firestore-lab <span class="s2">&#34;npx jest rules.test.js&#34;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 預期：3 個 assertFails 測試 fail（該擋的沒擋）</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"># 驗證完改回上面的正確規則</span></span></span></code></pre></div><h2 id="artifact-與驗收">Artifact 與驗收</h2>
<table>
  <thead>
      <tr>
          <th>Artifact</th>
          <th>來源</th>
          <th>驗收</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>規則測試檔</td>
          <td><code>rules.test.js</code></td>
          <td>四類斷言 + 正向 update</td>
      </tr>
      <tr>
          <td>測試結果</td>
          <td><code>emulators:exec</code> 輸出</td>
          <td>正確規則下全 pass</td>
      </tr>
      <tr>
          <td>回歸證明</td>
          <td>改壞後重跑</td>
          <td>3 個 assertFails 測試轉 fail</td>
      </tr>
  </tbody>
</table>
<h2 id="接進-release-gate">接進 release gate</h2>
<p>規則測試的下游責任是成為發布證據。把 <code>firebase emulators:exec ... jest</code> 接進 CI pipeline，規則變更的 PR 必須通過才能 merge——這把「規則改動沒開新洞」從人工推敲變成 gate 條件，對齊 <a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a> 的 <code>Gate decision / Checks / Stop condition</code>。授權翻譯的正確性是安全邊界，這個 gate 比一般功能測試更該設為硬性 stop condition。</p>
<h2 id="cleanup">Cleanup</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># emulators:exec 跑完會自動關 emulator；清依賴與工作區</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">rm -rf /tmp/firestore-lab</span></span></code></pre></div><h2 id="引用路徑">引用路徑</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/vendors/firestore/hands-on/" data-link-title="Firestore Hands-on 操作路線" data-link-desc="用 Firebase Emulator Suite 在本地演練 Firestore：emulator quickstart、Security Rules 單元測試、distributed counter 分片計數，全程零雲端成本、可重跑、產出可驗證 artifact">Firestore Hands-on 操作路線</a></li>
<li>Deep article：<a href="/blog/backend/01-database/vendors/firestore/security-rules-authz-modeling/" data-link-title="Firestore Security Rules 授權建模與可測試化：把規則當程式碼治理" data-link-desc="Firestore client 直連模型把整個授權控制面壓在 Security Rules 這套 DSL 裡；本文展開規則的求值模型、把授權拆成可組合 function、用 emulator 寫單元測試、五個把規則寫成資安漏洞的 production 踩坑，以及規則複雜度撞牆時把授權拉回後端的邊界">Security Rules 授權建模與可測試化</a></li>
<li>安全驗證：<a href="/blog/backend/01-database/red-team-data-layer/" data-link-title="1.5 攻擊者視角（紅隊）：資料層弱點判讀" data-link-desc="從資料存取邊界、外洩路徑與修復代價、盤點 database 的主要弱點">1.5 資料層紅隊</a></li>
<li>發布證據：<a href="/blog/backend/06-reliability/release-gate/" data-link-title="6.8 Release Gate 與變更節奏" data-link-desc="把驗證、migration、相容性納入放行判準">6.8 release gate</a></li>
<li>官方：<a href="https://firebase.google.com/docs/rules/unit-tests">Rules unit testing</a>、<a href="https://firebase.google.com/docs/emulator-suite/install_and_configure">emulators:exec</a></li>
</ul>
]]></content:encoded></item><item><title>SQLite Test Fixture Best Practice</title><link>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/test-fixture-best-practice/</guid><description>&lt;blockquote>
&lt;p>本文是 &lt;a href="https://tarrragon.github.io/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite&lt;/a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合作為 test fixture；本文聚焦 &lt;em>如何用 SQLite 加速測試，同時保留 production database 的語意邊界&lt;/em>。&lt;/p>&lt;/blockquote>
&lt;p>SQLite test fixture 的核心責任是讓 repository / adapter 測試快速、可重複、可攜帶。SQLite 的單檔特性讓 CI 可以快速建立 DB、載入 seed、跑 contract test；但它的 type affinity、SQL dialect、locking 與 constraint behavior 和 PostgreSQL / MySQL 不完全相同，因此 fixture 要被定位為一層測試工具，而非 production equivalence。&lt;/p>
&lt;p>本文的判讀錨點是：SQLite fixture 適合驗證 application contract，不適合取代 production database compatibility test。若測試目標是 repository error mapping、domain invariant、migration fixture 或 deterministic seed，SQLite 很划算；若測試目標是 PostgreSQL extension、MySQL lock、query planner 或 SQL dialect，應使用 production-like container。&lt;/p>
&lt;h2 id="test-fixture-的位置">Test fixture 的位置&lt;/h2>
&lt;p>SQLite fixture 的服務責任是提供快、穩定、可重建的本地資料狀態。它通常位於 unit test 與 full integration test 之間，承擔 repository adapter 的 contract test。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>測試層級&lt;/th>
 &lt;th>SQLite 適合度&lt;/th>
 &lt;th>判讀&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pure unit test&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>fake / in-memory object 通常更快&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Repository contract&lt;/td>
 &lt;td>高&lt;/td>
 &lt;td>驗證 CRUD、constraint mapping、transaction behavior&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Service integration&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>適合簡單流程，不覆蓋 production-specific SQL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Production compatibility&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>用 PostgreSQL / MySQL container 或 staging DB&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Migration smoke&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>適合 fixture migration，不代表 production DDL&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>這張表的重點是把測試目的說清楚。SQLite fixture 讓語言教材與 backend 教材接起來；語言端測 interface / adapter，backend 端保留 production database 的深度文章與 migration playbook。&lt;/p>
&lt;h2 id="fixture-lifecycle">Fixture lifecycle&lt;/h2>
&lt;p>Fixture lifecycle 的核心責任是讓每次測試拿到已知資料狀態。常見策略有三種：每 test 建新 in-memory DB、每 suite 複製 template file、每 CI job 產生 versioned fixture。&lt;/p>
&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;code>:memory:&lt;/code> per test&lt;/td>
 &lt;td>小 schema、快速 unit-like contract&lt;/td>
 &lt;td>隔離最好、清理簡單&lt;/td>
 &lt;td>跨 connection / WAL 行為不同&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>template file copy&lt;/td>
 &lt;td>中等 seed、需要真實檔案行為&lt;/td>
 &lt;td>快速、可測 file lifecycle&lt;/td>
 &lt;td>要避免多 test 共用同一檔案&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>generated fixture&lt;/td>
 &lt;td>migration / seed 驗證&lt;/td>
 &lt;td>和 migration 同步&lt;/td>
 &lt;td>CI 時間較長&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>read-only fixture&lt;/td>
 &lt;td>查詢 / report 測試&lt;/td>
 &lt;td>避免 writer collision&lt;/td>
 &lt;td>不測 mutation&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Fixture file 應和 schema version 綁定。檔名、metadata 或 &lt;code>user_version&lt;/code> 要能回答「這個 fixture 對應哪個 migration 版本」，避免測試資料在多次 schema 變更後變成隱性技術債。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>本文是 <a href="/blog/backend/01-database/vendors/sqlite/" data-link-title="SQLite" data-link-desc="embedded、單檔案、test / CLI / edge 場景的標準選擇、近年因 Cloudflare D1 / Turso 等服務復興">SQLite</a> overview 的 implementation-layer deep article。Overview 已說明 SQLite 適合作為 test fixture；本文聚焦 <em>如何用 SQLite 加速測試，同時保留 production database 的語意邊界</em>。</p></blockquote>
<p>SQLite test fixture 的核心責任是讓 repository / adapter 測試快速、可重複、可攜帶。SQLite 的單檔特性讓 CI 可以快速建立 DB、載入 seed、跑 contract test；但它的 type affinity、SQL dialect、locking 與 constraint behavior 和 PostgreSQL / MySQL 不完全相同，因此 fixture 要被定位為一層測試工具，而非 production equivalence。</p>
<p>本文的判讀錨點是：SQLite fixture 適合驗證 application contract，不適合取代 production database compatibility test。若測試目標是 repository error mapping、domain invariant、migration fixture 或 deterministic seed，SQLite 很划算；若測試目標是 PostgreSQL extension、MySQL lock、query planner 或 SQL dialect，應使用 production-like container。</p>
<h2 id="test-fixture-的位置">Test fixture 的位置</h2>
<p>SQLite fixture 的服務責任是提供快、穩定、可重建的本地資料狀態。它通常位於 unit test 與 full integration test 之間，承擔 repository adapter 的 contract test。</p>
<table>
  <thead>
      <tr>
          <th>測試層級</th>
          <th>SQLite 適合度</th>
          <th>判讀</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pure unit test</td>
          <td>低</td>
          <td>fake / in-memory object 通常更快</td>
      </tr>
      <tr>
          <td>Repository contract</td>
          <td>高</td>
          <td>驗證 CRUD、constraint mapping、transaction behavior</td>
      </tr>
      <tr>
          <td>Service integration</td>
          <td>中</td>
          <td>適合簡單流程，不覆蓋 production-specific SQL</td>
      </tr>
      <tr>
          <td>Production compatibility</td>
          <td>低</td>
          <td>用 PostgreSQL / MySQL container 或 staging DB</td>
      </tr>
      <tr>
          <td>Migration smoke</td>
          <td>中</td>
          <td>適合 fixture migration，不代表 production DDL</td>
      </tr>
  </tbody>
</table>
<p>這張表的重點是把測試目的說清楚。SQLite fixture 讓語言教材與 backend 教材接起來；語言端測 interface / adapter，backend 端保留 production database 的深度文章與 migration playbook。</p>
<h2 id="fixture-lifecycle">Fixture lifecycle</h2>
<p>Fixture lifecycle 的核心責任是讓每次測試拿到已知資料狀態。常見策略有三種：每 test 建新 in-memory DB、每 suite 複製 template file、每 CI job 產生 versioned fixture。</p>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適合情境</th>
          <th>優點</th>
          <th>邊界</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>:memory:</code> per test</td>
          <td>小 schema、快速 unit-like contract</td>
          <td>隔離最好、清理簡單</td>
          <td>跨 connection / WAL 行為不同</td>
      </tr>
      <tr>
          <td>template file copy</td>
          <td>中等 seed、需要真實檔案行為</td>
          <td>快速、可測 file lifecycle</td>
          <td>要避免多 test 共用同一檔案</td>
      </tr>
      <tr>
          <td>generated fixture</td>
          <td>migration / seed 驗證</td>
          <td>和 migration 同步</td>
          <td>CI 時間較長</td>
      </tr>
      <tr>
          <td>read-only fixture</td>
          <td>查詢 / report 測試</td>
          <td>避免 writer collision</td>
          <td>不測 mutation</td>
      </tr>
  </tbody>
</table>
<p>Fixture file 應和 schema version 綁定。檔名、metadata 或 <code>user_version</code> 要能回答「這個 fixture 對應哪個 migration 版本」，避免測試資料在多次 schema 變更後變成隱性技術債。</p>
<h2 id="production-dialect-gap">Production dialect gap</h2>
<p>Production dialect gap 的核心責任是避免 SQLite 測試通過後，PostgreSQL / MySQL production 出現不同語意。SQLite 的 dynamic typing、date / time representation、foreign key pragma、ALTER TABLE 支援與 lock model 都會影響測試可信度。</p>
<table>
  <thead>
      <tr>
          <th>Gap 類型</th>
          <th>SQLite 行為</th>
          <th>Production 風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Type affinity</td>
          <td>欄位有 affinity，值本身仍有 storage class</td>
          <td>PostgreSQL / MySQL type error 沒被測到</td>
      </tr>
      <tr>
          <td>Date / time</td>
          <td>常以 TEXT / REAL / INTEGER 表示</td>
          <td>timezone、precision、function 差異</td>
      </tr>
      <tr>
          <td>Foreign key</td>
          <td>需要 <code>PRAGMA foreign_keys=ON</code></td>
          <td>fixture 忘記開 FK，constraint bug 漏掉</td>
      </tr>
      <tr>
          <td>ALTER TABLE</td>
          <td>支援 subset，複雜變更需 rebuild</td>
          <td>production migration 工具行為不同</td>
      </tr>
      <tr>
          <td>Locking</td>
          <td>single-file lock / single writer</td>
          <td>server DB connection / lock model 不同</td>
      </tr>
      <tr>
          <td>SQL feature</td>
          <td>extension / JSON / index 差異</td>
          <td>vendor-specific query 需要 production evidence</td>
      </tr>
  </tbody>
</table>
<p>這張表的用法是決定哪些測試留在 SQLite，哪些要升級到 production-like DB。Repository contract 可用 SQLite；query optimization、vendor SQL、online schema change、CDC、replication、pooling 都應回到 PostgreSQL / MySQL 章節。</p>
<h2 id="contract-test-設計">Contract test 設計</h2>
<p>Contract test 的核心責任是讓不同 DB adapter 對 application 呈現同一組語意。SQLite fixture 測的是 application port 的行為，例如 duplicate key、not found、transaction rollback、pagination、domain invariant，而非底層 engine 的所有細節。</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">Repository contract
</span></span><span class="line"><span class="ln">2</span><span class="cl">├── Create / read / update / delete
</span></span><span class="line"><span class="ln">3</span><span class="cl">├── Unique conflict → ErrAlreadyExists
</span></span><span class="line"><span class="ln">4</span><span class="cl">├── Missing row → ErrNotFound
</span></span><span class="line"><span class="ln">5</span><span class="cl">├── Transaction rollback restores domain invariant
</span></span><span class="line"><span class="ln">6</span><span class="cl">├── Pagination order stable
</span></span><span class="line"><span class="ln">7</span><span class="cl">└── Migration version matches fixture</span></span></code></pre></div><p>如果 production adapter 是 PostgreSQL / MySQL，contract test 應至少在 nightly 或 CI matrix 裡跑一輪 production-like database。SQLite 提供快速回饋，production-like test 提供 dialect confidence。</p>
<h2 id="ci-evidence">CI evidence</h2>
<p>SQLite fixture 的 CI evidence 要證明資料狀態和 schema version 一致。測試失敗時，讀者要能知道是 application contract 失效、fixture 過期、migration 漏跑，還是 SQLite / production dialect gap。</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>fixture version</td>
          <td>對齊 migration / app release</td>
      </tr>
      <tr>
          <td>seed checksum</td>
          <td>確認測試資料穩定</td>
      </tr>
      <tr>
          <td>migration log</td>
          <td>確認 fixture 可由 migration 重建</td>
      </tr>
      <tr>
          <td>contract test output</td>
          <td>確認 repository behavior</td>
      </tr>
      <tr>
          <td>dialect gap note</td>
          <td>標示未覆蓋 production behavior</td>
      </tr>
  </tbody>
</table>
<p>CI 產物不一定要很複雜，但要能被下一個維護者重建。SQLite fixture 的優勢是可攜帶；若 fixture 只能靠某個人的本機狀態生成，就失去教學與維護價值。</p>
<h2 id="production-踩雷">Production 踩雷</h2>
<h3 id="case-1共用同一個-db-檔跑平行測試">Case 1：共用同一個 <code>.db</code> 檔跑平行測試</h3>
<p>平行測試共用檔案的核心風險是 test runner 製造和 production 不同的 writer collision。測試偶發 <code>SQLITE_BUSY</code>，團隊可能以為 application 有 race；實際上是測試隔離不足。</p>
<p>修正方向是 per-test temp DB 或 read-only template copy。需要測 WAL / busy 行為時，用專門 hands-on lab，讓一般 contract test 專注在 repository contract。</p>
<h3 id="case-2忘記開-foreign-keys">Case 2：忘記開 foreign keys</h3>
<p>Foreign key pragma 漏開的核心風險是 constraint bug 被 fixture 隱藏。SQLite foreign key enforcement 需要明確啟用；若 production DB 一定 enforce FK，fixture 也要在 connection initialization 中開啟。</p>
<p>修正方向是 baseline PRAGMA 和 startup assertion。每個 test DB open 後都跑 <code>PRAGMA foreign_keys</code> 並驗證結果。</p>
<h3 id="case-3sqlite-fixture-掩蓋-vendor-specific-sql">Case 3：SQLite fixture 掩蓋 vendor-specific SQL</h3>
<p>Vendor-specific SQL 被 SQLite 掩蓋的核心風險是 query 到 production 才失敗。例如 PostgreSQL JSONB、partial index、full-text search 或 MySQL generated column、optimizer hint 都應在 vendor DB 測。</p>
<p>修正方向是把 SQL 分層。Portable repository contract 可以用 SQLite；vendor-specific query 要有 PostgreSQL / MySQL test container。</p>
<h2 id="操作檢查清單">操作檢查清單</h2>
<p>SQLite fixture 設計前要回答：</p>
<ol>
<li>這個測試驗證 application contract 還是 production dialect。</li>
<li>Fixture 是 in-memory、template copy、generated file 還是 read-only。</li>
<li><code>PRAGMA foreign_keys</code>、<code>journal_mode</code>、<code>busy_timeout</code> 是否固定。</li>
<li>Fixture version 如何對齊 migration version。</li>
<li>Parallel test 是否每個 worker 有獨立 DB file。</li>
<li>哪些 query 必須在 PostgreSQL / MySQL container 再跑。</li>
<li>CI artifact 是否保留 migration log 與 dialect gap note。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>上游：<a href="/blog/backend/01-database/repository-adapter/" data-link-title="1.4 Repository Adapter 實作" data-link-desc="Port / Adapter 邊界、row mapping、error translation、ORM vs query builder 選型、contract test 設計">Repository Adapter</a></li>
<li>Sibling：<a href="/blog/backend/01-database/vendors/sqlite/schema-migration-versioning/" data-link-title="SQLite Schema Migration and Versioning" data-link-desc="SQLite schema migration、user_version、table rebuild、ALTER TABLE 限制、app release compatibility 與 migration evidence">Schema Migration / Versioning</a>、<a href="/blog/backend/01-database/vendors/sqlite/sql-dialect-index-limits/" data-link-title="SQLite SQL Dialect and Index Limits" data-link-desc="SQLite type affinity、NULL / date handling、constraint、index、query planner 與 PostgreSQL / MySQL 差異">SQL Dialect and Index Limits</a></li>
<li>操作：<a href="/blog/backend/01-database/vendors/sqlite/hands-on/migration-fixture-lab/" data-link-title="SQLite Migration Fixture Lab" data-link-desc="SQLite user_version、table rebuild migration、fixture snapshot、rollback note 與 CI evidence 的操作說明">Migration Fixture Lab</a></li>
<li>平行：<a href="/blog/backend/01-database/vendors/postgresql/" data-link-title="PostgreSQL" data-link-desc="多用途 OLTP 主流關聯式資料庫、MVCC、豐富 SQL 特性、是 Aurora / Cosmos DB / Spanner / CockroachDB / Aurora DSQL 的相容目標">PostgreSQL</a>、<a href="/blog/backend/01-database/vendors/mysql/" data-link-title="MySQL" data-link-desc="高併發網路服務常用關聯式資料庫、Vitess / PlanetScale 分片生態、GitHub / Shopify / Facebook 規模驗證">MySQL</a></li>
<li>官方：<a href="https://www.sqlite.org/datatype3.html">SQLite Datatypes</a>、<a href="https://www.sqlite.org/stricttables.html">SQLite STRICT Tables</a>、<a href="https://www.sqlite.org/pragma.html">SQLite PRAGMA</a></li>
</ul>
]]></content:encoded></item><item><title>測試命名作為文件：可執行的規格說明</title><link>https://tarrragon.github.io/blog/record/%E6%B8%AC%E8%A9%A6%E5%91%BD%E5%90%8D%E4%BD%9C%E7%82%BA%E6%96%87%E4%BB%B6%E5%8F%AF%E5%9F%B7%E8%A1%8C%E7%9A%84%E8%A6%8F%E6%A0%BC%E8%AA%AA%E6%98%8E/</link><pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E6%B8%AC%E8%A9%A6%E5%91%BD%E5%90%8D%E4%BD%9C%E7%82%BA%E6%96%87%E4%BB%B6%E5%8F%AF%E5%9F%B7%E8%A1%8C%E7%9A%84%E8%A6%8F%E6%A0%BC%E8%AA%AA%E6%98%8E/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心命題&lt;/strong>：測試是少數&lt;strong>會自我驗證&lt;/strong>的文件——名稱說的事如果跟實際行為不符，CI 會炸。
&lt;strong>設計原則&lt;/strong>：測試命名應該讓「跳到測試檔讀名字」就能取代讀 doc。&lt;/p>&lt;/blockquote>
&lt;blockquote>
&lt;p>本篇是 &lt;a href="../function-doc-layered-design/">函式文件分層設計&lt;/a> 的 Layer 4（範例與測試）展開——把「測試命名作為可執行 spec」這個職責拉成獨立主題討論。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="起點被-ci-強制同步的-doc">起點：被 CI 強制同步的 doc&lt;/h2>
&lt;p>source code 的 doc comment 有個結構性缺陷：&lt;strong>寫得再好，code 改了 doc 沒改，doc 就在說謊&lt;/strong>。沒有任何工具強制 doc 跟 code 同步。&lt;/p>
&lt;p>測試是少數例外。一個命名為 &lt;code>removes_item_when_quantity_reaches_zero&lt;/code> 的測試，如果實際上 quantity 到 0 時沒移除，&lt;strong>測試會失敗、CI 會擋下 commit&lt;/strong>。測試名稱跟實際行為的一致性是被 CI 強制的——這讓測試成為&lt;strong>會自我驗證的文件&lt;/strong>。&lt;/p>
&lt;p>當你把這個性質有意識地利用起來，測試就不只是 regression 工具，而是&lt;strong>可執行的 API 規格&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="測試命名的三種主要模式">測試命名的三種主要模式&lt;/h2>
&lt;p>被測單元的契約大致分三類：「&lt;strong>在某狀態下回傳什麼&lt;/strong>」「&lt;strong>某操作會做什麼&lt;/strong>」「&lt;strong>何時 throw / 失敗&lt;/strong>」——對應到測試命名也分三類 pattern。每類 pattern 的命名格式不同、負責驗證契約的不同切面。&lt;/p>
&lt;h3 id="模式-1state-based狀態描述">模式 1：state-based（狀態描述）&lt;/h3>
&lt;p>「在某個狀態下，呼叫 X 會回傳 / 變成什麼」。&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;returns_null_when_user_not_found&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;returns_empty_list_when_no_items_match&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;returns_cached_value_on_second_call&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合：query / read-only 操作。&lt;/p>
&lt;h3 id="模式-2scenario-based情境描述">模式 2：scenario-based（情境描述）&lt;/h3>
&lt;p>「當某條件成立時，操作會做什麼」。&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;removes_item_when_quantity_reaches_zero&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;decreases_quantity_when_item_exists_with_quantity_above_one&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;updates_lastChangedItem_on_addItem&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">4&lt;/span>&lt;span class="cl">&lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;does_not_update_lastChangedItem_on_removeItem&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合：command / mutation 操作。注意 &lt;code>does_not_X&lt;/code> 形式——&lt;strong>negative assertion 也該寫進名字&lt;/strong>，這正是契約的一部分。&lt;/p>
&lt;h3 id="模式-3failure-mode失敗模式描述">模式 3：failure-mode（失敗模式描述）&lt;/h3>
&lt;p>「在某輸入 / 狀態下，會 throw / error / 失敗」。&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;throws_NotFoundException_when_id_does_not_exist&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;throws_StateError_when_called_after_dispose&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;returns_error_when_network_unavailable&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>適合：error path、edge case。&lt;strong>失敗模式是 doc 最容易漏寫的部分&lt;/strong>，但對 caller 最關鍵。&lt;/p>
&lt;hr>
&lt;h2 id="group-結構作為命名空間">Group 結構作為命名空間&lt;/h2>
&lt;p>巢狀 group 提供了「主題 → 操作 → 情境」的階層命名空間，比扁平命名更易讀：&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="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;CartService&amp;#39;&lt;/span>&lt;span class="p">,&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="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;addItem&amp;#39;&lt;/span>&lt;span class="p">,&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">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;appends_when_item_not_in_cart&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;increments_quantity_when_same_item_exists&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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"> 5&lt;/span>&lt;span class="cl"> &lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;updates_lastChangedItem&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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"> 6&lt;/span>&lt;span class="cl"> &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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;removeItem&amp;#39;&lt;/span>&lt;span class="p">,&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">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;removes_when_item_exists&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">10&lt;/span>&lt;span class="cl"> &lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;does_nothing_when_item_not_found&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">11&lt;/span>&lt;span class="cl"> &lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;does_not_update_lastChangedItem&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="n">group&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;decreaseQuantity&amp;#39;&lt;/span>&lt;span class="p">,&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">15&lt;/span>&lt;span class="cl"> &lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;decreases_when_quantity_above_one&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">16&lt;/span>&lt;span class="cl"> &lt;span class="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;removes_item_when_quantity_reaches_zero&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">{&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">17&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>讀者掃過 group 結構，立刻知道 &lt;code>CartService&lt;/code> 對外提供哪些操作、每個操作有哪些行為承諾——&lt;strong>這是這個 service 的 readable spec&lt;/strong>。&lt;/p>
&lt;p>工具支援：好的 IDE / test runner 會把 group 結構顯示為樹狀，跑測試時的輸出也帶階層。把這個視覺結構利用好，測試 console 本身就是 doc 瀏覽器。&lt;/p>
&lt;hr>
&lt;h2 id="把-tests-當-readable-spec-的閱讀流程">把 tests 當 readable spec 的閱讀流程&lt;/h2>
&lt;p>當你不確定一個 function 的行為時，閱讀順序通常是：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>看簽章&lt;/strong> → 知道 what / takes / returns&lt;/li>
&lt;li>&lt;strong>讀 doc&lt;/strong> → 知道契約、edge case&lt;/li>
&lt;li>&lt;strong>看實作&lt;/strong> → 知道 how&lt;/li>
&lt;li>&lt;strong>找測試&lt;/strong> → 看具體 case&lt;/li>
&lt;/ol>
&lt;p>但如果測試命名做得好，&lt;strong>順序可以對調&lt;/strong>：&lt;/p>
&lt;ol>
&lt;li>看簽章&lt;/li>
&lt;li>&lt;strong>跳到對應 test file，掃 group + test names&lt;/strong> → 看 API 支援哪些 case、各 case 的承諾&lt;/li>
&lt;li>不夠才回去讀 doc / 實作&lt;/li>
&lt;/ol>
&lt;p>這個順序的優勢：&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心命題</strong>：測試是少數<strong>會自我驗證</strong>的文件——名稱說的事如果跟實際行為不符，CI 會炸。
<strong>設計原則</strong>：測試命名應該讓「跳到測試檔讀名字」就能取代讀 doc。</p></blockquote>
<blockquote>
<p>本篇是 <a href="../function-doc-layered-design/">函式文件分層設計</a> 的 Layer 4（範例與測試）展開——把「測試命名作為可執行 spec」這個職責拉成獨立主題討論。</p></blockquote>
<hr>
<h2 id="起點被-ci-強制同步的-doc">起點：被 CI 強制同步的 doc</h2>
<p>source code 的 doc comment 有個結構性缺陷：<strong>寫得再好，code 改了 doc 沒改，doc 就在說謊</strong>。沒有任何工具強制 doc 跟 code 同步。</p>
<p>測試是少數例外。一個命名為 <code>removes_item_when_quantity_reaches_zero</code> 的測試，如果實際上 quantity 到 0 時沒移除，<strong>測試會失敗、CI 會擋下 commit</strong>。測試名稱跟實際行為的一致性是被 CI 強制的——這讓測試成為<strong>會自我驗證的文件</strong>。</p>
<p>當你把這個性質有意識地利用起來，測試就不只是 regression 工具，而是<strong>可執行的 API 規格</strong>。</p>
<hr>
<h2 id="測試命名的三種主要模式">測試命名的三種主要模式</h2>
<p>被測單元的契約大致分三類：「<strong>在某狀態下回傳什麼</strong>」「<strong>某操作會做什麼</strong>」「<strong>何時 throw / 失敗</strong>」——對應到測試命名也分三類 pattern。每類 pattern 的命名格式不同、負責驗證契約的不同切面。</p>
<h3 id="模式-1state-based狀態描述">模式 1：state-based（狀態描述）</h3>
<p>「在某個狀態下，呼叫 X 會回傳 / 變成什麼」。</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="n">test</span><span class="p">(</span><span class="s1">&#39;returns_null_when_user_not_found&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;returns_empty_list_when_no_items_match&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;returns_cached_value_on_second_call&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>適合：query / read-only 操作。</p>
<h3 id="模式-2scenario-based情境描述">模式 2：scenario-based（情境描述）</h3>
<p>「當某條件成立時，操作會做什麼」。</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="n">test</span><span class="p">(</span><span class="s1">&#39;removes_item_when_quantity_reaches_zero&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;decreases_quantity_when_item_exists_with_quantity_above_one&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;updates_lastChangedItem_on_addItem&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;does_not_update_lastChangedItem_on_removeItem&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>適合：command / mutation 操作。注意 <code>does_not_X</code> 形式——<strong>negative assertion 也該寫進名字</strong>，這正是契約的一部分。</p>
<h3 id="模式-3failure-mode失敗模式描述">模式 3：failure-mode（失敗模式描述）</h3>
<p>「在某輸入 / 狀態下，會 throw / error / 失敗」。</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="n">test</span><span class="p">(</span><span class="s1">&#39;throws_NotFoundException_when_id_does_not_exist&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;throws_StateError_when_called_after_dispose&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;returns_error_when_network_unavailable&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>適合：error path、edge case。<strong>失敗模式是 doc 最容易漏寫的部分</strong>，但對 caller 最關鍵。</p>
<hr>
<h2 id="group-結構作為命名空間">Group 結構作為命名空間</h2>
<p>巢狀 group 提供了「主題 → 操作 → 情境」的階層命名空間，比扁平命名更易讀：</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="n">group</span><span class="p">(</span><span class="s1">&#39;CartService&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">group</span><span class="p">(</span><span class="s1">&#39;addItem&#39;</span><span class="p">,</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">test</span><span class="p">(</span><span class="s1">&#39;appends_when_item_not_in_cart&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;increments_quantity_when_same_item_exists&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;updates_lastChangedItem&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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="n">group</span><span class="p">(</span><span class="s1">&#39;removeItem&#39;</span><span class="p">,</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">test</span><span class="p">(</span><span class="s1">&#39;removes_when_item_exists&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;does_nothing_when_item_not_found&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;does_not_update_lastChangedItem&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="n">group</span><span class="p">(</span><span class="s1">&#39;decreaseQuantity&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;decreases_when_quantity_above_one&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;removes_item_when_quantity_reaches_zero&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>讀者掃過 group 結構，立刻知道 <code>CartService</code> 對外提供哪些操作、每個操作有哪些行為承諾——<strong>這是這個 service 的 readable spec</strong>。</p>
<p>工具支援：好的 IDE / test runner 會把 group 結構顯示為樹狀，跑測試時的輸出也帶階層。把這個視覺結構利用好，測試 console 本身就是 doc 瀏覽器。</p>
<hr>
<h2 id="把-tests-當-readable-spec-的閱讀流程">把 tests 當 readable spec 的閱讀流程</h2>
<p>當你不確定一個 function 的行為時，閱讀順序通常是：</p>
<ol>
<li><strong>看簽章</strong> → 知道 what / takes / returns</li>
<li><strong>讀 doc</strong> → 知道契約、edge case</li>
<li><strong>看實作</strong> → 知道 how</li>
<li><strong>找測試</strong> → 看具體 case</li>
</ol>
<p>但如果測試命名做得好，<strong>順序可以對調</strong>：</p>
<ol>
<li>看簽章</li>
<li><strong>跳到對應 test file，掃 group + test names</strong> → 看 API 支援哪些 case、各 case 的承諾</li>
<li>不夠才回去讀 doc / 實作</li>
</ol>
<p>這個順序的優勢：</p>
<ul>
<li><strong>測試名是被驗證過的事實</strong>，doc 是聲明（可能 outdated）</li>
<li><strong>測試名涵蓋 edge case</strong>，比 doc 完整</li>
<li><strong>跳到測試只要一個快捷鍵</strong>（多數 IDE 有 &ldquo;Go to Test&rdquo; 命令）</li>
</ul>
<p>當團隊習慣這個閱讀順序，<strong>doc 寫多寫少的壓力就會減輕</strong>——很多 edge case 直接讓測試說明，doc 留給「測試也表達不了」的部分（業務動機、隱性需求）。</p>
<hr>
<h2 id="反模式">反模式</h2>
<h3 id="反模式-1test_-前綴--模糊主題">反模式 1：<code>test_</code> 前綴 + 模糊主題</h3>
<p><strong>正向概念</strong>：測試名字的每個 token 都該承載資訊——前綴或主題詞如果讀者一眼推不出「在驗什麼」、就是浪費 token budget。</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">// 反：純 noise
</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;test_user&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;test_user_2&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;test_user_creation&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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">// 正：說明具體行為
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;creates_user_with_default_role_when_role_omitted&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p><code>test_</code> 前綴是工具年代留下的習慣（早期某些 framework 靠它識別測試 method）；現代 framework 用 annotation / 函式簽章識別、前綴變成純 noise。模糊的主題（<code>test_user</code>、<code>test_creation</code>）等於沒命名——讀者必須跳進 body 才能分辨兩個 test 在驗什麼、命名的 doc 價值消失。</p>
<h3 id="反模式-2實作洩漏的命名">反模式 2：實作洩漏的命名</h3>
<p><strong>正向概念</strong>：測試驗的是<strong>對外可觀察的契約</strong>——換實作而契約沒變、測試應該繼續通過、命名也不該需要改。</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">// 反：洩漏實作（用 hashmap、用 cache）
</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;uses_hashmap_for_lookup&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;caches_result_after_first_call&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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="c1">// 正：描述對外可觀察行為
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;returns_value_in_O_1_for_existing_key&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;subsequent_calls_return_same_instance&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>命名洩漏實作後、重構（換 hashmap 為 trie、移除 cache 改用 lazy init）會逼迫測試一起改名——但對外行為其實沒變。一個良好的契約測試、應該在 codebase 大改造後仍能驗證「行為是否還是當初承諾的樣子」、命名洩漏實作會破壞這個性質。</p>
<h3 id="反模式-3描述怎麼做而非做什麼">反模式 3：描述「怎麼做」而非「做什麼」</h3>
<p><strong>正向概念</strong>：測試名描述「被測單元的契約」、test body 描述「測試怎麼寫」——分配給對應的位置、讀者跳到名字看契約、跳到 body 看細節。</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">// 反：描述測試怎麼跑（過程）
</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;mocks_db_and_calls_findUser_then_asserts_result&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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="c1">// 正：描述被測 function 的行為
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;returns_null_when_user_not_found&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>把「mocks_db_and_calls_X」寫進名字、讀者拿到的是「測試怎麼寫的過程」、不是「被測單元承諾什麼」——但讀 spec 想知道的是後者。「怎麼寫」放 test body、「驗證什麼契約」放名字、兩種讀者都得益。</p>
<h3 id="反模式-4assertion-style-命名">反模式 4：assertion-style 命名</h3>
<p><strong>正向概念</strong>：測試名是業務語義的入口、不是 assertion 框架的字面映射——讀者讀名字想推「業務上發生什麼」、不是「assert 用了哪個動詞」。</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">// 反：assertion 寫在名字
</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;isFalse_when_disabled&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;equal_when_same_input&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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="c1">// 正：描述行為
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;returns_false_when_feature_disabled&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;returns_same_result_for_equivalent_inputs&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p><code>isTrue</code>、<code>equal</code>、<code>isNotEmpty</code> 是 assertion 動詞、不是行為描述。讀者讀 <code>isFalse_when_disabled</code> 不知道「false」對應什麼業務語義（feature 關掉？user 不存在？status 失效？）——把業務語義寫進名字、讀者一眼就能 map 到實際情境。</p>
<h3 id="反模式-5用-numbering-取代命名">反模式 5：用 numbering 取代命名</h3>
<p><strong>正向概念</strong>：每個 test case 都有獨特的「驗什麼情境」、命名就是把那個情境寫出來。編號只負責「不重複」、不負責「能識別」——失去命名最關鍵的功能。</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">// 反：靠編號區分
</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;addItem_case_1&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;addItem_case_2&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="n">test</span><span class="p">(</span><span class="s1">&#39;addItem_case_3&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</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">// 正：編號變描述
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;addItem_appends_when_cart_empty&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;addItem_increments_when_same_item_exists&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</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">test</span><span class="p">(</span><span class="s1">&#39;addItem_handles_null_customization&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span> <span class="p">...</span> <span class="p">});</span></span></span></code></pre></div><p>編號是「我懶得想名字」的訊號。讀者要跳進 test body 才能區分 case 1 跟 case 2 是什麼差別——失去測試命名的全部 doc 價值；CI 報告看到「<code>addItem_case_2</code> 失敗」也無從直接判斷哪個情境壞了。</p>
<hr>
<h2 id="邊界什麼時候測試名不適合當-spec">邊界：什麼時候測試名不適合當 spec</h2>
<p>「測試名是 spec 條目」是預設、<strong>但有些情境測試命名無法獨自承擔 doc 責任</strong>：</p>
<ul>
<li><strong>大量參數化 / property-based test</strong>：「對任意輸入 N、結果都 ≥ N」這類 invariant、命名只能寫概念名（<code>preserves_minimum</code>）、具體 input 範圍要靠 doc 或 generator 描述</li>
<li><strong>整合 / e2e test</strong>：跨多個系統的行為、命名常壓不下完整流程（「user_can_complete_checkout_with_loyalty_points_and_split_payment」）、要靠 setup / scenario doc 補上下文</li>
<li><strong>測試本身是業務動機的二次表達</strong>：例如 GDPR 合規規則、業務動機的詳細條款仍要寫在介面 doc / spec 文件、命名只負責「驗證點」</li>
<li><strong>內部行為對齊 vs 對外契約</strong>：私有 helper / internal worker 的測試命名不必當公開 spec、可以直接用實作詞彙（這時候命名價值是「regression 防護」而非「對外文件」）</li>
</ul>
<p>判斷標準：「讀者只看名字、能不能拿到他要的資訊？」答「能」就讓命名當 spec 用、答「不能」就把詳細上下文寫進 doc / scenario file、命名只當「定位錨點」。</p>
<hr>
<h2 id="給測試寫作的-checklist">給測試寫作的 checklist</h2>
<p>寫一個 test 之前，跑這個 checklist：</p>
<ul>
<li><input disabled="" type="checkbox"> <strong>名字能不能讓讀者不看 body 就知道驗證什麼？</strong> 不能 → 重命名</li>
<li><input disabled="" type="checkbox"> <strong>名字描述的是被測 function 的契約嗎？</strong> 不是（描述測試過程）→ 重寫</li>
<li><input disabled="" type="checkbox"> <strong>名字有沒有業務面詞彙？</strong> 沒有（只有 assertion 動詞）→ 加業務詞彙</li>
<li><input disabled="" type="checkbox"> <strong>同 group 下這個名字跟其他 test 有區辨度嗎？</strong> 沒有（靠編號）→ 加情境描述</li>
<li><input disabled="" type="checkbox"> <strong>這個行為契約是 doc 沒寫但這個 test 在驗的嗎？</strong> 是 → 太好了，這個 test 補了 doc 漏洞</li>
<li><input disabled="" type="checkbox"> <strong>這個 test 在驗實作細節嗎？</strong> 是 → 改成驗對外可觀察行為，否則重構必折斷</li>
</ul>
<hr>
<h2 id="trade-off測試名變長的代價">Trade-off：測試名變長的代價</h2>
<p>把測試當 doc 寫，名字會變長——<code>addItem_increments_quantity_when_same_item_exists_with_identical_customizations</code> 比 <code>test_add</code> 長 5 倍。</p>
<p>值得嗎？看你怎麼讀測試：</p>
<ul>
<li><strong>只看綠紅燈、不讀名字</strong> → 短名字便利</li>
<li><strong>把測試當 spec 讀</strong> → 長名字回收成本</li>
</ul>
<p>多數團隊低估「把測試當 spec 讀」的價值，因為這個習慣需要團隊一致才有效——一個人寫好命名，其他人不讀，回收不到。<strong>這是團隊習慣問題，不是個人偏好問題</strong>。要建立這個習慣，最好的切入點是：</p>
<ol>
<li><strong>新功能 PR 直接讀新 test 的名字判斷契約是否合理</strong>——把命名變成 review 的一環</li>
<li><strong>修 bug 時要求新增的 regression test 名字描述 bug 行為</strong>（例如 <code>does_not_double_charge_on_retry</code>）——這些名字本身是 incident 紀錄</li>
<li><strong>重構 PR 不允許改 test 名</strong>（除非是改名抓 bug 暴露的契約變動）——避免重構順手「整理」掉重要命名</li>
</ol>
<hr>
<h2 id="一句話-heuristic">一句話 heuristic</h2>
<p>把整個討論濃縮：</p>
<blockquote>
<p>測試名是「<strong>讀者跳到測試檔、不看 body 就能讀懂的 spec 條目</strong>」。</p></blockquote>
<p>寫測試名時想像一個讀者只會看到名字，他要能從名字推得：</p>
<ul>
<li>在驗哪個操作？</li>
<li>在哪個情境下？</li>
<li>期待什麼結果？</li>
</ul>
<p>三件事缺一不可。寫到名字過長覺得難寫——通常是被測 function 同時在做多件事，<strong>測試名長是設計訊號</strong>，先別急著縮名字，先想能不能拆 function。</p>
<hr>
<h2 id="收束測試命名是文件設計的一環">收束：測試命名是文件設計的一環</h2>
<p>回到開頭——測試是少數會自我驗證的文件。但這個性質<strong>只在你有意識利用時才有價值</strong>。把測試名寫成 <code>test_1</code>、<code>test_2</code>，你寫的是 regression 網，不是 doc。</p>
<p>把測試名寫成可讀 spec 條目，你寫的是同時包辦兩件事的東西：<strong>驗證 + 文件</strong>。這兩件事用同一份成本同時做完，是測試這個工具的最高槓桿用法。</p>
<p>把「<strong>這份 test file 是這個模組唯一的 doc、讀者夠不夠用？</strong>」當成命名的品質門檻——通過這個門檻的命名、自然就具備可讀 spec 的特性。</p>
]]></content:encoded></item><item><title>Playwright in the Development Loop — 開發循環的三個位置</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/</guid><description>&lt;p>Playwright 在前端開發循環的三個位置：假設驗證（寫 CSS 前）、行為驗證（規則寫完後）、互動驗證（dispatch event 後）。第 2 次同個版型 bug 出現 → 寫成測試固化。&lt;/p>
&lt;p>適用：CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。
不適用：純 unit test（function input/output、無 DOM）— 那用 Vitest / Jest 即可。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="何時參閱本文件">何時參閱本文件&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>該做的第一件事&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>即將寫 CSS 規則、想先確認 DOM 結構&lt;/td>
 &lt;td>位置 1：假設驗證 — 量 ancestor chain&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規則寫完、想確認實際 layout 對&lt;/td>
 &lt;td>位置 2：行為驗證 — 量 bounding rect&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想驗證使用者互動後的狀態（filter / search / click）&lt;/td>
 &lt;td>位置 3：互動驗證 — dispatch event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同個 layout bug 第 2 次出現&lt;/td>
 &lt;td>寫 layout test、CI 防回歸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 server 怎麼起 / 怎麼接 playwright&lt;/td>
 &lt;td>看下方「最低門檻 setup」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-playwright-是前端開發的核心驗證工具">為什麼 playwright 是前端開發的核心驗證工具&lt;/h2>
&lt;p>CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。&lt;/p>
&lt;p>Playwright &lt;code>browser_evaluate&lt;/code> 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — &lt;strong>把假設變成量測值&lt;/strong>。寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。&lt;/p>
&lt;hr>
&lt;h2 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）&lt;/h2>
&lt;h3 id="量-ancestor-chain">量 ancestor chain&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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="kr">const&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&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">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&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="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&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">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&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="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&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="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">return&lt;/span> &lt;span class="nx">chain&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;/code>&lt;/pre>&lt;/div>&lt;h3 id="量子節點與-sibling">量子節點與 sibling&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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="kr">const&lt;/span> &lt;span class="nx">parent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&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="k">return&lt;/span> &lt;span class="nb">Array&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">from&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">children&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="量元素是否存在--數量">量元素是否存在 / 數量&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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">count&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">length&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">first&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">outerHTML&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">200&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="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。&lt;/p>
&lt;hr>
&lt;h2 id="位置-2行為驗證規則寫完後">位置 2：行為驗證（規則寫完後）&lt;/h2>
&lt;h3 id="量-bounding-rect">量 bounding rect&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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">form&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__form&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&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">scope&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.scope&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&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="nx">results&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.results&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&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;/code>&lt;/pre>&lt;/div>&lt;p>返回 &lt;code>{x, y, width, height, top, right, bottom, left}&lt;/code> 的純物件、能直接 assert 順序與位置。&lt;/p>
&lt;h3 id="量-computed-style">量 computed style&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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="kr">const&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&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="kr">const&lt;/span> &lt;span class="nx">cs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&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="k">return&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="nx">display&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">display&lt;/span>&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="nx">position&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">position&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="nx">gridRow&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">gridRow&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="nx">color&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">color&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">zIndex&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">zIndex&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="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="量實際贏的-css-rule">量「實際贏的 CSS rule」&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="p">=&amp;gt;&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="kr">const&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&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="c1">// CSSOM 沒提供標準 getMatchedCSSRules；用 computed style 加 inspect
&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="k">return&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">cssText&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 全部 computed properties
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。&lt;/p></description><content:encoded><![CDATA[<p>Playwright 在前端開發循環的三個位置：假設驗證（寫 CSS 前）、行為驗證（規則寫完後）、互動驗證（dispatch event 後）。第 2 次同個版型 bug 出現 → 寫成測試固化。</p>
<p>適用：CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。
不適用：純 unit test（function input/output、無 DOM）— 那用 Vitest / Jest 即可。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 CSS 規則、想先確認 DOM 結構</td>
          <td>位置 1：假設驗證 — 量 ancestor chain</td>
      </tr>
      <tr>
          <td>規則寫完、想確認實際 layout 對</td>
          <td>位置 2：行為驗證 — 量 bounding rect</td>
      </tr>
      <tr>
          <td>想驗證使用者互動後的狀態（filter / search / click）</td>
          <td>位置 3：互動驗證 — dispatch event</td>
      </tr>
      <tr>
          <td>同個 layout bug 第 2 次出現</td>
          <td>寫 layout test、CI 防回歸</td>
      </tr>
      <tr>
          <td>不確定 server 怎麼起 / 怎麼接 playwright</td>
          <td>看下方「最低門檻 setup」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-playwright-是前端開發的核心驗證工具">為什麼 playwright 是前端開發的核心驗證工具</h2>
<p>CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。</p>
<p>Playwright <code>browser_evaluate</code> 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — <strong>把假設變成量測值</strong>。寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。</p>
<hr>
<h2 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）</h2>
<h3 id="量-ancestor-chain">量 ancestor chain</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</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="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="量子節點與-sibling">量子節點與 sibling</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">parent</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">parent</span><span class="p">.</span><span class="nx">children</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">c</span> <span class="p">=&gt;</span> <span class="sb">`</span><span class="si">${</span><span class="nx">c</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">c</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="量元素是否存在--數量">量元素是否存在 / 數量</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">count</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">length</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">first</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">)</span><span class="o">?</span><span class="p">.</span><span class="nx">outerHTML</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">200</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。</p>
<hr>
<h2 id="位置-2行為驗證規則寫完後">位置 2：行為驗證（規則寫完後）</h2>
<h3 id="量-bounding-rect">量 bounding rect</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">form</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">scope</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">results</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.results&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>返回 <code>{x, y, width, height, top, right, bottom, left}</code> 的純物件、能直接 assert 順序與位置。</p>
<h3 id="量-computed-style">量 computed style</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">cs</span> <span class="o">=</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">display</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">display</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">position</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">position</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">gridRow</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">gridRow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">color</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">color</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">zIndex</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">zIndex</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="p">}</span></span></span></code></pre></div><h3 id="量實際贏的-css-rule">量「實際贏的 CSS rule」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// CSSOM 沒提供標準 getMatchedCSSRules；用 computed style 加 inspect
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">cssText</span><span class="p">;</span>  <span class="c1">// 全部 computed properties
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。</p>
<hr>
<h2 id="位置-3互動驗證dispatch-event-後讀-state">位置 3：互動驗證（dispatch event 後讀 state）</h2>
<h3 id="模擬-input">模擬 input</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;pre&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>  <span class="c1">// 等 debounce / async render
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">50</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="模擬-click">模擬 click</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-toggle button[data-scope=&#34;title&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">500</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">activeScope</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-toggle [aria-pressed=&#34;true&#34;]&#39;</span><span class="p">)</span><span class="o">?</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">scope</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">visibleResults</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">).</span><span class="nx">length</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="p">}</span></span></span></code></pre></div><h3 id="模擬-viewport-resize透過-playwright-api不在-evaluate-內">模擬 viewport resize（透過 playwright API、不在 evaluate 內）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">375</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">667</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kr">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">layout</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.layout&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">sidebarVisible</span><span class="o">:</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#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></code></pre></div><hr>
<h2 id="第-2-次同個-bug--寫成-layout-測試固化">第 2 次同個 bug → 寫成 layout 測試固化</h2>
<p>第 1 次 debug 完成後、bug 修好。第 2 次同個版型問題（不同 commit / viewport / 內容狀態）再出現 → <strong>debug 完後把 query 寫成 playwright 測試</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;@playwright/test&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;search scope is between form and results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.result&#39;</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="kr">const</span> <span class="nx">formRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kr">const</span> <span class="nx">scopeRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kr">const</span> <span class="nx">resultsRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.results&#39;</span><span class="p">).</span><span class="nx">boundingBox</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="nx">expect</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">formRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">formRect</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">resultsRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">scopeRect</span><span class="p">.</span><span class="nx">height</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">test</span><span class="p">(</span><span class="s1">&#39;sidebar visible at 1400px+&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1400</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="kr">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;sidebar hidden at &lt; 1400px&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1399</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="kr">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">toBeHidden</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>未來 layout 改動觸發 regression、CI 立刻發現。</p>
<hr>
<h2 id="寫-layout-test-的優先順序">寫 layout test 的優先順序</h2>
<p>不要每個 layout 都寫測試 — 寫測試的 ROI 條件：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>該寫測試嗎</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 第 1 次出現</td>
          <td>否（修了就好）</td>
      </tr>
      <tr>
          <td>Bug 第 2 次出現</td>
          <td><strong>是</strong>（防回歸）</td>
      </tr>
      <tr>
          <td>Layout 跟 viewport 強相關（breakpoint）</td>
          <td>是（容易壞）</td>
      </tr>
      <tr>
          <td>Layout 跟 framework 重渲染相關</td>
          <td>是（升級時需要驗證）</td>
      </tr>
      <tr>
          <td>純視覺風格（顏色 / 字型）</td>
          <td>否（用視覺 review 即可）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="最低門檻-setup">最低門檻 setup</h2>
<h3 id="server">Server</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 任何方式起本地 server</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">hugo server                                       <span class="c1"># Hugo</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">python3 -m http.server <span class="m">8000</span> --directory public    <span class="c1"># 純靜態</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">npm run dev                                        <span class="c1"># framework dev server</span></span></span></code></pre></div><h3 id="playwright-mcp給-claude-用">Playwright MCP（給 Claude 用）</h3>
<p>Claude 透過 MCP 提供的 tool：</p>
<ul>
<li><code>browser_navigate(url)</code> — 開頁</li>
<li><code>browser_evaluate(fn)</code> — 執行 JS 拿結果</li>
<li><code>browser_take_screenshot()</code> — 截圖</li>
<li><code>browser_snapshot()</code> — accessibility tree</li>
<li><code>browser_click(selector)</code> / <code>browser_type(selector, text)</code> — 互動</li>
</ul>
<h3 id="playwright-測試給-ci-用">Playwright 測試（給 CI 用）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">npm i -D @playwright/test
</span></span><span class="line"><span class="ln">2</span><span class="cl">npx playwright install
</span></span><span class="line"><span class="ln">3</span><span class="cl">npx playwright test</span></span></code></pre></div><p><code>playwright.config.ts</code> 設 baseURL 指向 <code>http://localhost:1313</code>（Hugo 預設）或自訂 port。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1css-不生效">範例 1：CSS 不生效</h3>
<p><strong>錯</strong>：靜態推理 + 截圖溝通 4 次失敗。</p>
<p><strong>對</strong>：第 2 次失敗 → 切 playwright：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 1. 確認 ancestor chain
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span><span class="p">)</span> <span class="p">{</span> <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</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">return</span> <span class="nx">chain</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="c1">// → 看到目標元素是 form 的 child、不是 .pagefind-ui 的直接 child
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 2. 確認 computed style 誰贏
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">color</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// → &#34;rgb(0,0,255)&#34; — vendor 的藍色贏了
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">// 3. 換方向：用 @layer 把 vendor 包起來
</span></span></span></code></pre></div><h3 id="範例-2layout-第-2-次出現一樣的-bug">範例 2：Layout 第 2 次出現一樣的 bug</h3>
<p><strong>錯</strong>：手動在不同 viewport 下視覺驗證、commit、過幾週又壞、又手動驗證。</p>
<p><strong>對</strong>：第 2 次出現後寫成測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;layout golden path: form → scope → results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">width</span> <span class="k">of</span> <span class="p">[</span><span class="mi">375</span><span class="p">,</span> <span class="mi">768</span><span class="p">,</span> <span class="mi">1024</span><span class="p">,</span> <span class="mi">1400</span><span class="p">,</span> <span class="mi">1920</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="kr">const</span> <span class="nx">form</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="kr">const</span> <span class="nx">scope</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scope</span><span class="p">.</span><span class="nx">y</span><span class="p">,</span> <span class="sb">`at width=</span><span class="si">${</span><span class="nx">width</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">toBeGreaterThanOrEqual</span><span class="p">(</span><span class="nx">form</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">form</span><span class="p">.</span><span class="nx">height</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></code></pre></div><p>未來改 CSS、CI 直接告訴你哪個 viewport 壞了。</p>
<hr>
<h2 id="red-green-順序先看到-red-才相信-green">RED-GREEN 順序：先看到 RED 才相信 GREEN</h2>
<p>寫完 playwright test 後、必須先在「buggy code」跑出 RED 才能相信「fixed code」的 GREEN。詳見 <a href="/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN</a>。</p>
<p>修 bug 的順序：</p>
<ol>
<li><strong>先寫測試 + 跑 → RED</strong>（在 buggy code 上 fail、證明測試會 catch + bug 真的存在）</li>
<li><strong>修 code</strong></li>
<li><strong>跑測試 → GREEN</strong>（證明修對了 + 測試會抓回歸）</li>
</ol>
<p>跳過 step 1 的 retrospective 補救（修完才補測試）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Stash 修復、checkout 修前 commit</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git stash <span class="o">&amp;&amp;</span> git checkout &lt;pre-fix-commit&gt;
</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="c1"># Cherry-pick 測試 commit、build、跑</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">git cherry-pick &lt;test-commit&gt;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">make site <span class="o">&amp;&amp;</span> npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 預期：RED</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="c1"># 切回修後版本</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">git checkout main <span class="o">&amp;&amp;</span> git stash pop
</span></span><span class="line"><span class="ln">11</span><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 預期：GREEN</span></span></span></code></pre></div><p>兩個訊號都看到 + 順序對、測試才被驗證。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>debug / 驗證 layout 時：</p>
<ul>
<li><input disabled="" type="checkbox"> 寫 CSS 規則前、有沒有用 playwright 量過 ancestor chain？</li>
<li><input disabled="" type="checkbox"> 規則寫完後、有沒有用 playwright 量過 bounding rect / computed style 確認？</li>
<li><input disabled="" type="checkbox"> 互動行為（filter / click）有沒有用 playwright 模擬 + 量化驗證？</li>
<li><input disabled="" type="checkbox"> 同個 layout bug 第 2 次出現時、有沒有寫成測試？</li>
<li><input disabled="" type="checkbox"> 推理失敗 ≥ 2 次時、有沒有主動切換到 playwright（不等到第 5 次）？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/playwright-early-in-loop/" data-link-title="在開發循環裡早一點用 playwright 看真實結果" data-link-desc="靜態 CSS 推理跟視覺截圖溝通有極限 — 當行為與預期不符 ≥ 2 次，stop 推理、改用 playwright browser_evaluate 直接讀 live DOM。本文說明工具引入時機。">playwright-early-in-loop</a> — 在開發循環裡早一點用 playwright 看真實結果</li>
<li><a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">layout-tests-with-playwright</a> — 用前端測試把排版問題自動化</li>
<li><a href="/blog/report/verification-method-timing/" data-link-title="驗證方法的選擇時機" data-link-desc="靜態 CSS 推理 ≥ 2 次失敗就主動提『啟個 server、用 playwright 看 live DOM 比較快』、不要繼續猜。本文展開驗證工具的引入時機。">verification-method-timing</a> — 驗證方法的選擇時機</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item></channel></rss>