<?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>Bdd on Tarragon</title><link>https://tarrragon.github.io/blog/tags/bdd/</link><description>Recent content in Bdd on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Tue, 23 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/bdd/index.xml" rel="self" type="application/rss+xml"/><item><title>從 BDD 操作盤點展開到狀態矩陣</title><link>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/</guid><description>&lt;p>BDD 操作盤點描述使用者操作的情境和預期結果，但操作盤點的格式（Given / When / Then）聚焦在「什麼情境下做什麼得到什麼」，容易漏掉畫面層級的兩個面向：每個狀態下使用者能執行哪些操作，以及如何離開當前狀態。&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>BDD 操作盤點通常包含：&lt;/p>
&lt;ul>
&lt;li>使用者操作（When）：「使用者點擊連線按鈕」&lt;/li>
&lt;li>前端引導（Then）：「顯示連線進度指示」&lt;/li>
&lt;li>後端回應：「WebSocket 連線建立」&lt;/li>
&lt;/ul>
&lt;p>「前端引導」描述的是畫面的顯示內容 — 對應狀態矩陣的「顯示」欄。但操作盤點通常不會展開：連線中的畫面除了顯示進度指示，使用者能做什麼？如果連線失敗，使用者怎麼離開失敗畫面？&lt;/p>
&lt;p>app_tunnel 的操作盤點在「前端引導」欄寫了「連線失敗顯示無法連線」，覆蓋了 error 狀態的顯示。但是「顯示無法連線之後使用者能做什麼」和「使用者怎麼離開這個畫面」都沒有描述。實作出來的 error 狀態有重連按鈕但沒有 back 按鈕 — 重連失敗時使用者被困在 error → retry → error 循環裡（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。&lt;/p>
&lt;h2 id="展開步驟">展開步驟&lt;/h2>
&lt;h3 id="步驟一從操作盤點抽出所有畫面">步驟一：從操作盤點抽出所有畫面&lt;/h3>
&lt;p>每個 BDD 情境至少涉及一個畫面。列出所有情境提到的畫面名稱，去重後得到畫面清單。&lt;/p>
&lt;p>例：app_tunnel 的操作盤點涉及三個畫面 — 首頁、配對畫面、終端機畫面。&lt;/p>
&lt;h3 id="步驟二每個畫面列出狀態">步驟二：每個畫面列出狀態&lt;/h3>
&lt;p>從操作盤點的 Given / When / Then 條件中抽出狀態。「Given 連線已建立」暗示有 connected 狀態；「Then 顯示無法連線」暗示有 error 狀態。&lt;/p>
&lt;p>同時檢查程式碼中的狀態 enum — 操作盤點可能遺漏了某些狀態（如 idle、disconnected），程式碼裡有但操作盤點沒提到的狀態同樣需要設計 UI。&lt;/p>
&lt;h3 id="步驟三每個狀態填顯示欄">步驟三：每個狀態填「顯示」欄&lt;/h3>
&lt;p>從操作盤點的「前端引導」直接填入。這一步通常不缺資料，因為操作盤點的強項就是描述顯示內容。&lt;/p>
&lt;h3 id="步驟四每個狀態填可用操作和退出路徑欄">步驟四：每個狀態填「可用操作」和「退出路徑」欄&lt;/h3>
&lt;p>這一步是關鍵 — 操作盤點通常不提供這些資訊，需要主動補上。&lt;/p>
&lt;p>對每個狀態問兩個問題：&lt;/p>
&lt;ul>
&lt;li>使用者在這個狀態下想做什麼？（可用操作）&lt;/li>
&lt;li>使用者怎麼離開這個狀態？（退出路徑）&lt;/li>
&lt;/ul>
&lt;p>開發者容易假設 connected 狀態下使用者只想打字，不會想返回首頁。但使用者可能想切換到配對畫面重新配對、想暫時離開做其他事、想結束當前操作。把這些可能性列出來，判斷哪些需要提供操作按鈕。&lt;/p>
&lt;h3 id="步驟五檢查矩陣的空白格">步驟五：檢查矩陣的空白格&lt;/h3>
&lt;p>退出路徑欄為空的狀態是 UX 死胡同，需要補上退出路徑。可用操作欄為空的狀態需要判斷是否合理 — loading 狀態操作欄為空可能合理，但建議至少提供取消操作。&lt;/p>
&lt;h2 id="操作盤點的描述顯示偏差">操作盤點的「描述顯示」偏差&lt;/h2>
&lt;p>操作盤點的「前端引導」傾向描述顯示（What the user sees）而非描述互動（What the user can do）。這個偏差的根源在 BDD 的 Then 語法 — Then 通常描述可觀察的結果，而「畫面顯示 X」比「使用者可以做 Y」更容易寫成可觀察的斷言。&lt;/p>
&lt;p>app_tunnel 的操作盤點就是這個模式。四個操作情境的「前端引導」都寫了顯示內容（「顯示終端機畫面」「顯示連線中」「顯示無法連線」），沒有一個寫了操作（「使用者可以取消」「使用者可以返回」）或退出路徑（「使用者可以回到首頁」）（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。&lt;/p>
&lt;p>畫面狀態矩陣的四欄結構強制補上這兩個面向。從 BDD 操作盤點到畫面狀態矩陣的展開步驟，就是把「只描述顯示」擴展成「顯示 + 操作 + 退出」的過程。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&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 死胡同">畫面狀態矩陣的定義與填寫方法&lt;/a>&lt;/li>
&lt;li>路由可達性檢查 → &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 版本">路由可達性檢查&lt;/a>&lt;/li>
&lt;li>想知道什麼是「假設只走 happy path」的反模式 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/anti-pattern-happy-path-only/" data-link-title="反模式：假設使用者只走 happy path" data-link-desc="為什麼開發者容易只設計 happy path 的 UI、使用者在非 happy path 狀態下被困住的機制分析、以及用狀態矩陣系統性地防止這個問題">反模式：假設使用者只走 happy path&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>BDD 操作盤點描述使用者操作的情境和預期結果，但操作盤點的格式（Given / When / Then）聚焦在「什麼情境下做什麼得到什麼」，容易漏掉畫面層級的兩個面向：每個狀態下使用者能執行哪些操作，以及如何離開當前狀態。<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>補上這兩個面向，讓導航缺口在實作前浮現。</p>
<h2 id="操作盤點的覆蓋範圍">操作盤點的覆蓋範圍</h2>
<p>BDD 操作盤點通常包含：</p>
<ul>
<li>使用者操作（When）：「使用者點擊連線按鈕」</li>
<li>前端引導（Then）：「顯示連線進度指示」</li>
<li>後端回應：「WebSocket 連線建立」</li>
</ul>
<p>「前端引導」描述的是畫面的顯示內容 — 對應狀態矩陣的「顯示」欄。但操作盤點通常不會展開：連線中的畫面除了顯示進度指示，使用者能做什麼？如果連線失敗，使用者怎麼離開失敗畫面？</p>
<p>app_tunnel 的操作盤點在「前端引導」欄寫了「連線失敗顯示無法連線」，覆蓋了 error 狀態的顯示。但是「顯示無法連線之後使用者能做什麼」和「使用者怎麼離開這個畫面」都沒有描述。實作出來的 error 狀態有重連按鈕但沒有 back 按鈕 — 重連失敗時使用者被困在 error → retry → error 循環裡（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。</p>
<h2 id="展開步驟">展開步驟</h2>
<h3 id="步驟一從操作盤點抽出所有畫面">步驟一：從操作盤點抽出所有畫面</h3>
<p>每個 BDD 情境至少涉及一個畫面。列出所有情境提到的畫面名稱，去重後得到畫面清單。</p>
<p>例：app_tunnel 的操作盤點涉及三個畫面 — 首頁、配對畫面、終端機畫面。</p>
<h3 id="步驟二每個畫面列出狀態">步驟二：每個畫面列出狀態</h3>
<p>從操作盤點的 Given / When / Then 條件中抽出狀態。「Given 連線已建立」暗示有 connected 狀態；「Then 顯示無法連線」暗示有 error 狀態。</p>
<p>同時檢查程式碼中的狀態 enum — 操作盤點可能遺漏了某些狀態（如 idle、disconnected），程式碼裡有但操作盤點沒提到的狀態同樣需要設計 UI。</p>
<h3 id="步驟三每個狀態填顯示欄">步驟三：每個狀態填「顯示」欄</h3>
<p>從操作盤點的「前端引導」直接填入。這一步通常不缺資料，因為操作盤點的強項就是描述顯示內容。</p>
<h3 id="步驟四每個狀態填可用操作和退出路徑欄">步驟四：每個狀態填「可用操作」和「退出路徑」欄</h3>
<p>這一步是關鍵 — 操作盤點通常不提供這些資訊，需要主動補上。</p>
<p>對每個狀態問兩個問題：</p>
<ul>
<li>使用者在這個狀態下想做什麼？（可用操作）</li>
<li>使用者怎麼離開這個狀態？（退出路徑）</li>
</ul>
<p>開發者容易假設 connected 狀態下使用者只想打字，不會想返回首頁。但使用者可能想切換到配對畫面重新配對、想暫時離開做其他事、想結束當前操作。把這些可能性列出來，判斷哪些需要提供操作按鈕。</p>
<h3 id="步驟五檢查矩陣的空白格">步驟五：檢查矩陣的空白格</h3>
<p>退出路徑欄為空的狀態是 UX 死胡同，需要補上退出路徑。可用操作欄為空的狀態需要判斷是否合理 — loading 狀態操作欄為空可能合理，但建議至少提供取消操作。</p>
<h2 id="操作盤點的描述顯示偏差">操作盤點的「描述顯示」偏差</h2>
<p>操作盤點的「前端引導」傾向描述顯示（What the user sees）而非描述互動（What the user can do）。這個偏差的根源在 BDD 的 Then 語法 — Then 通常描述可觀察的結果，而「畫面顯示 X」比「使用者可以做 Y」更容易寫成可觀察的斷言。</p>
<p>app_tunnel 的操作盤點就是這個模式。四個操作情境的「前端引導」都寫了顯示內容（「顯示終端機畫面」「顯示連線中」「顯示無法連線」），沒有一個寫了操作（「使用者可以取消」「使用者可以返回」）或退出路徑（「使用者可以回到首頁」）（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。</p>
<p>畫面狀態矩陣的四欄結構強制補上這兩個面向。從 BDD 操作盤點到畫面狀態矩陣的展開步驟，就是把「只描述顯示」擴展成「顯示 + 操作 + 退出」的過程。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>畫面狀態矩陣的完整定義 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</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 版本">路由可達性檢查</a></li>
<li>想知道什麼是「假設只走 happy path」的反模式 → <a href="/blog/ux-design/01-screen-state-machine/anti-pattern-happy-path-only/" data-link-title="反模式：假設使用者只走 happy path" data-link-desc="為什麼開發者容易只設計 happy path 的 UI、使用者在非 happy path 狀態下被困住的機制分析、以及用狀態矩陣系統性地防止這個問題">反模式：假設使用者只走 happy path</a></li>
</ul>
]]></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>SaaS 選型訪談方法論 - 從使用者操作推導到技術選型</title><link>https://tarrragon.github.io/blog/record/saas-selection-interview-methodology/</link><pubDate>Thu, 11 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/saas-selection-interview-methodology/</guid><description>&lt;p>專案初始化的選型決策，常見的起手式是直接列工具名單：選哪個資料庫、要上 k8s 嗎、queue 用哪家。這個順序跳過了選型真正的依據——產品要提供使用者哪些操作、領域邊界怎麼切、哪種失敗的代價最高。SaaS 選型訪談方法論把順序反過來：從使用者操作推導功能、從功能推導領域結構、最後才到技術選型，並把整段推導包裝成一份可重複執行的結構化訪談協議。&lt;/p></description><content:encoded><![CDATA[<p>專案初始化的選型決策，常見的起手式是直接列工具名單：選哪個資料庫、要上 k8s 嗎、queue 用哪家。這個順序跳過了選型真正的依據——產品要提供使用者哪些操作、領域邊界怎麼切、哪種失敗的代價最高。SaaS 選型訪談方法論把順序反過來：從使用者操作推導功能、從功能推導領域結構、最後才到技術選型，並把整段推導包裝成一份可重複執行的結構化訪談協議。</p>
<h2 id="方法論要解的問題">方法論要解的問題</h2>
<p>這套方法論的核心命題是成本不對稱：設計問題拖到開發中途才浮現，修正的代價遠高於在訪談階段多問十題。沒想清楚的操作、沒切清楚的領域邊界、沒被告知的防護缺口，最後都會在開發中途以重工的形式收費。訪談協議的責任是逼這些設計問題在寫第一行程式之前、於已知需求的範圍內浮現——因此訪談問題的數量刻意不設上限，問漏才是成本；節奏上每輪三到五題讓受訪者好消化，輪數跟著覆蓋率走。</p>
<p>協議同時內建「產品名攔截」：受訪者開口指定產品（「我要用 MongoDB」「直接上 k8s」）時，先回到需求確認背後動機（schema 變動頻繁？團隊熟悉度？），需求成立後產品可以直接採納。產品名是選型的輸出，由需求與領域模型推導出來，而非訪談的輸入。</p>
<h2 id="推導鏈從定錨到決策記錄">推導鏈：從定錨到決策記錄</h2>
<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">定錨 → 交付形態 gate → 操作盤點（BDD）→ domain / event 切分（DDD）
</span></span><span class="line"><span class="ln">2</span><span class="cl">    → 核心問題與維度展開（每個維度就地過防護底線）→ 決策記錄 + scaffold</span></span></code></pre></div><p><strong>定錨</strong>。訪談從建立規模假設開始，定錨問題覆蓋七個面向：</p>
<ul>
<li>產品形態（B2B / B2C / 內部工具）——決定合規壓力與流量不確定性的預設方向</li>
<li>動機（營收、學習、解決自己的營運問題）——決策記錄理由欄的錨點，「自建換學習」「託管換速度」的權重都從這裡來</li>
<li>租戶模型——多租戶把租戶隔離升級成儲存與安全維度的第一級問題</li>
<li>預期規模（首年用戶與資料量級）——決定容量維度是否展開</li>
<li>團隊能力（人數、production on-call 經驗）——維運經驗低時預設 managed 服務，每個自管元件都要回答「誰半夜處理它」</li>
<li>每週可投入時數——一週十小時跟全職的選型分母不同</li>
<li>上線時程與迭代節奏——時程緊時選型偏保守，團隊熟悉度的權重高於技術新穎度</li>
</ul>
<p>定錨答案是後續每個推薦的合法依據——「因為團隊兩人且無維運經驗，推薦 managed 資料庫」這類理由都要能回指到定錨。</p>
<p><strong>交付形態 gate</strong>。定錨之後、進入任何技術討論之前，先誠實回答「這個產品現在值得自建嗎」。需求落在現成平台的標準域時，訪談走縮減流程：完整推導跳過，但仍產出託管縮減記錄——平台選擇與理由、可遷出保險狀態、升級自建的 tripwire、防護底線總表的適用項，託管結論同樣留下可追溯的決策記錄。這一站的判讀邏輯單獨成節，見「交付形態 gate」。</p>
<p><strong>操作盤點（BDD）</strong>。自建成立後，枚舉所有操作主體（含管理者、客服、訪客、機器角色）與其全部操作，每個操作寫行為情境——Given / When / Then 至少一條主情境加一條失敗情境。誤操作風險成對設計：前端引導（確認對話框、預設值、防呆）與後端防護（驗證、權限、idempotency）一一對應，從介面一路串到伺服器側。行為情境寫不出來，代表那個操作的需求還沒成形——在盤點階段攔下，比開發中途才發現便宜得多。</p>
<p><strong>Domain / event 切分（DDD）</strong>。操作清單沿固定方向轉成領域骨架：operation → command → 唯一歸屬 domain → event。推導從操作出發而非資料表出發——從資料表出發會切出 CRUD 式的偽領域（UserManager、OrderManager），從操作出發才會浮現真正的業務領域與領域間需要交換的事實。切分只用兩個原則：</p>
<ul>
<li><strong>SRP</strong>：一個 domain 一個變更理由、一個 event 一個事實。判讀問題包括「這兩個概念會因為同一個業務原因一起改嗎」「這個規則改變時誰說了算」——組織的決策邊界是領域邊界最誠實的線索。</li>
<li><strong>OCP</strong>：每個 domain 區分公開面（別的 domain 需要知道的：event schema、查詢介面）與內部面（表結構、狀態機、內部規則）。公開面是 contract、變更要盤點訂閱者；內部面可自由改。判讀問題是「Order domain 的表加一個欄位，要通知誰」——理想答案是「不用通知任何人」。</li>
</ul>
<p>LSP / ISP / DIP（里氏替換、介面隔離、依賴反轉）三個原則刻意留給實作階段：初始化階段還沒有 class 階層、沒有 interface 簇、沒有依賴注入結構，提前套用會把訪談拖進實作細節，模糊掉「邊界在哪」這個此階段的核心問題。</p>
<p><strong>核心問題與維度展開</strong>。領域骨架立好後，依序確認需求類型、流量形狀、資料生命週期、失敗代價、成本模型、定位與備援、安全邊界。每題附答案路由：訊號決定哪些技術維度要展開（快取、非同步佇列、容量），哪些維度任何 production 服務都逃不掉（儲存、部署、安全、觀測、可靠性）。</p>
<p>展開時問題錨在領域骨架上——「Order domain 的不可丟 event 用什麼機制送」，而非抽象的「要不要 queue」。每站也帶反向問，因為受訪者描述的是想要的功能，沒想到的東西藏在失敗面：「使用者做完馬上後悔怎麼辦」「凌晨三點服務掛了誰會知道」。</p>
<p><strong>防護底線</strong>。每個維度附一份防護底線清單——底線的定義是「缺了它、第一次事故的代價會遠超過 day one 建立它的成本」的項目，例如 secret 管理、備份加至少一次還原驗證、物件層級授權、部署可回滾。底線跟選型的差別在答案空間：選型問題容許多個合理答案並存，底線項目只有「已納入」「已告知後延後」「不適用」三種合法狀態，每一項的狀態都要寫進決策記錄。延後必須轉成記錄：告知代價、記下延後理由、附具體的重評條件（「上線前」「第一個付費客戶前」），「之後再說」這種無期限的口頭妥協會被協議擋下。目的是讓六個月後接手的人能回答「當初為什麼沒做」，把口頭妥協變成可追溯的決策。底線在訪談中經過兩次——維度展開時就地逐項過，決策記錄的總表再核對一次；雙重核對是刻意設計，防止任何一條在長流程中被沉默跳過。</p>
<p><strong>決策記錄</strong>。訪談收斂成一份決策記錄：操作風險表、domain map、event catalog、每項技術選型的理由 / 防護狀態 / <a href="/blog/backend/knowledge-cards/tripwire/" data-link-title="Tripwire" data-link-desc="說明風險決策在條件變化時如何自動回到評估流程">tripwire</a> 三欄齊備、防護底線總表、規模成長 tripwire 總表。被淘汰的次選項留名字——淘汰原因消失（例如團隊長出維運能力）就是重評入口。scaffold 建議是決策記錄的下游：先確認決策、再產生檔案，決策改了 scaffold 跟著重生。</p>
<h2 id="交付形態-gate先勸退不必要的自建">交付形態 gate：先勸退不必要的自建</h2>
<p>一套為自建選型而生的訪談協議，第一個正式任務是勸退不必要的自建。自建的前提是差異化在軟體本身；差異化在商品、內容或服務品質的業務，託管平台、<a href="/blog/backend/knowledge-cards/baas/" data-link-title="BaaS（Backend as a Service）" data-link-desc="說明把認證、資料庫、檔案儲存、推播打包成現成模組、由前端 SDK 直連的後端交付形態">BaaS</a>、垂直 SaaS 可能是成本上更誠實的起點。跳過這個判斷，等於用整套自建流程回答一個本來就該用現成平台解的需求。交付形態光譜的完整論述（各形態的能力邊界、遷出代價、可遷出保險）見 <a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型：託管平台、BaaS 與自建的邊界</a>，訪談協議把它操作化成 gate。</p>
<p>gate 的第一個澄清問是身分問題：這個軟體是要賣的產品，還是經營業務的工具？「給健身教練用的課表系統」有兩種身分——要賣給眾多教練的產品（市場上的垂直 SaaS 是競爭對手，走自建），或教練管理自己學員的工具（同一批垂直 SaaS 正是該優先評估的託管候選）。同一句需求描述，兩種身分的結論相反。</p>
<p>回答「先自用、之後想賣」的雙重身分，用時間判準裁決：<strong>付費外部用戶離現在多遠</strong>。協議把裁決寫成兩條規則：外部買家已有承諾或近期有交付時程的，按「要賣的產品」走完整訪談，自家使用記為首租戶（dogfooding）；外部用戶還停在想像、目前唯一用戶是自己的，託管先行，平台野心轉成升級自建的 tripwire——協議給的觸發例是第一個付費外部用戶出現，或客製需求超出託管平台 API 的承載範圍。</p>
<p>gate 的收尾原則是尊重知情決定：受訪者聽完託管對照後仍選自建時，訪談照常繼續，勸退只做一次；選自建的動機（練手、控制權、長期成本）原樣記進決策記錄，讓未來重評者能還原當時的權重。</p>
<h2 id="用合成-dry-run-驗證協議">用合成 dry-run 驗證協議</h2>
<p>方法論本身也需要驗證。這套協議以一組結構化文件存在——訪談流程、各站的判讀表、決策記錄模板都是 agent 可直接執行的文件，驗證因此可以採用合成 dry-run：讓 agent 同時扮演訪談者與 persona 受訪者，從定錨到決策記錄完整走一遍協議，過程中記錄機械性斷點。斷點分五類：</p>
<ul>
<li><strong>路由斷裂</strong>：協議指向的段落不存在，或前後站接不上</li>
<li><strong>gate 矛盾</strong>：兩條判讀規則同時命中且結論相反，協議沒給裁決方式</li>
<li><strong>重複提問</strong>：同一風險在不同階段被當成新問題重新開放問</li>
<li><strong>資訊斷流</strong>：前站的產出（操作清單、event catalog）沒被後站引用</li>
<li><strong>判準缺席</strong>：協議要求做判讀，但沒給可操作的判讀條件</li>
</ul>
<p>dry-run 收尾時實際試填決策記錄模板，量測每個欄位填得出來的比例——填不出來的欄位就是協議在某一站漏問了。</p>
<p>第一輪驗證跑了兩個 persona：一個雙重身分的健身房管理 SaaS（自用兼想賣），一個 solo 開發者的付費電子報（最該被勸退自建的案例）。合計記錄 13 個斷點，在兩個 persona 覆蓋的路徑上路由斷裂為零——協議的檔案結構與觸發路由都接得起來；決策記錄試填率約 85-90%，缺口集中在交付形態相關欄位：gate 判讀依據寫不出來、混合形態的記錄格式沒有規格、自建動機沒有欄位可放。影響最大的斷點是兩個 persona 從不同方向撞上同一個 gate 矛盾：判讀表兩列規則同時命中且方向相反——健身房的「都是」讓垂直 SaaS 列與自建列並立，付費電子報既符合「託管平台標準域」也符合「產品本身是軟體」——協議當時沒有 tie-breaker，在最該被勸退的案例上這個斷點被評為阻斷級；前文的時間判準正是這次修復補上的。矛盾發生在協議最核心的判斷上，且在真實使用者撞到之前被攔下。兩場 dry-run 最終收斂出 8 項修復回寫協議，包括雙重身分的時間判準、混合形態的決策記錄規格、訪談問句去重、跨 domain 寫入的一致性判讀條件。</p>
<p>合成 dry-run 的邊界也要誠實：它抓得到機械性斷點，抓不到真實受訪者的不耐與理解斷層。agent 扮演的 persona 永遠有耐心答完所有問題、永遠聽得懂術語；真實使用者在第四輪提問時的疲乏、對「event catalog」一詞的困惑，要等真人訪談才會浮現。另一層盲點是同源：persona 由產生協議的同一個模型扮演，回答分布偏向協議作者想像得到的案例，超出這個想像的需求形態，結構上不會出現在合成驗證裡。合成驗證的定位是上線前的結構檢查，取代不了真實使用回饋。</p>
<h2 id="適用邊界與下一步">適用邊界與下一步</h2>
<p>完整協議的固定成本是數輪訪談加一份決策記錄，值得跑的情境有明確輪廓：</p>
<ul>
<li>產品會進 production，有真實使用者與失敗代價——失敗代價是核心問題階段的主要輸入，沒有它整條推導鏈失去分水嶺</li>
<li>團隊規模或交接需求讓「當初為什麼這樣選」值得被記錄——決策記錄的三種讀者（確認結論的現在使用者、回溯理由的未來維護者、等訊號的重評者）至少存在兩種</li>
<li>領域邊界有切分價值——操作主體超過一種、跨領域協作存在，domain / event 切分的產出才有承載對象</li>
</ul>
<p>反過來，快速原型與純內部腳本跑完整協議是過度設計——原型的目的是丟棄式驗證假設，設計決策活不過原型本身；這類情境取協議的最小子集就夠：定錨（確認規模假設）加防護底線裡的 secret 管理一條。另一類邊界是需求不可知：產品會進 production、但要提供哪些操作得靠市場驗證才知道的探索型產品，協議能整理的只有已知部分——它解「已知需求沒想清楚」的成本，解不了「需求本身未知」；這類情境先走薄切片迭代，需求穩定後再回來補完整訪談。</p>
<p>判讀方式可以收成一個問題：這個專案的設計決策，六個月後有人需要回答「當初為什麼這樣選」嗎？需要，完整協議的固定成本就划算；無此需求，取子集。</p>
<p>下一步路由：</p>
<ul>
<li>交付形態光譜與各形態的遷出代價：<a href="/blog/backend/00-service-selection/delivery-mode-selection/" data-link-title="0.21 交付形態選型：從全託管到自建的光譜與邊界" data-link-desc="在進入資料庫、快取與部署選型之前、先判斷服務該用託管平台（Wix / Shopify / Google Sites）、辦公生態自動化（Apps Script）、BaaS（Firebase）、半託管 CMS（WordPress）還是自建、並為日後遷往自建保留可遷出路徑">交付形態選型</a></li>
<li>判定自建後的服務選型順序：<a href="/blog/backend/00-service-selection/backend-demand-taxonomy/" data-link-title="0.0 後端需求分類地圖" data-link-desc="先從需求形狀辨識狀態、讀取、非同步、即時、診斷、交付與可靠性問題">後端需求分類地圖</a></li>
<li>不可丟 event 的執行層機制：<a href="/blog/backend/knowledge-cards/idempotency/" data-link-title="Idempotency" data-link-desc="說明同一操作執行多次時如何保持結果一致">idempotency</a> 與 <a href="/blog/backend/knowledge-cards/outbox-pattern/" data-link-title="Outbox Pattern" data-link-desc="說明資料庫狀態變更與事件發布如何透過 outbox 維持一致">outbox pattern</a> 知識卡</li>
</ul>]]></content:encoded></item><item><title>BDD 測試方法論</title><link>https://tarrragon.github.io/blog/record/bdd-%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95%E8%AB%96/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/bdd-%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95%E8%AB%96/</guid><description>&lt;p>三個月的重構週期結束後，我們檢視了測試套件，發現一個令人沮喪的問題：每次修改內部實作，即使業務邏輯完全沒變，也需要跟著修改大量測試。一個 Repository 實作替換，導致二十幾個測試需要逐一調整。&lt;/p>
&lt;p>這不是測試該有的樣子。問題根源在於測試耦合了實作細節，而非行為。&lt;/p></description><content:encoded><![CDATA[<p>三個月的重構週期結束後，我們檢視了測試套件，發現一個令人沮喪的問題：每次修改內部實作，即使業務邏輯完全沒變，也需要跟著修改大量測試。一個 Repository 實作替換，導致二十幾個測試需要逐一調整。</p>
<p>這不是測試該有的樣子。問題根源在於測試耦合了實作細節，而非行為。</p>
<h2 id="bdd-的核心定位">BDD 的核心定位</h2>
<p>BDD 是 TDD 的演進，它要求測試描述系統的「行為」而非「實作」。</p>
<p>行為是使用者視角觀察到的系統反應；實作是程式內部的技術細節。這個區別看起來簡單，實際撰寫測試時卻很容易模糊。</p>
<p>BDD 解決三個問題：</p>
<p><strong>測試維護成本高</strong>。傳統單元測試緊密耦合實作細節，重構時即使行為沒變，測試仍需大量修改。BDD 讓重構時測試保持穩定。</p>
<p><strong>需求追溯困難</strong>。測試充滿技術細節，無法對應業務需求。Given-When-Then 場景即是需求文件，測試即規格。</p>
<p><strong>溝通成本高</strong>。開發、測試和業務人員用不同語言描述系統行為。BDD 統一使用業務語言，建立共通溝通基礎。</p>
<p>我們的分工是：Clean Architecture 定義架構分層，TDD 四階段流程定義開發節奏，BDD 定義測試內容和撰寫規範。</p>
<h2 id="given-when-then-結構">Given-When-Then 結構</h2>
<p>Given 描述系統的初始狀態，必須明確完整，只包含與此場景相關的資料。常見錯誤是前置條件模糊，或包含大量無關測試資料。</p>
<p>When 描述使用者執行的操作，必須是單一動作，使用業務語言。「呼叫 Repository 的 save 方法」是技術術語；「使用者提交訂單」是業務語言。一個 When 不能包含多個動作。</p>
<p>Then 描述執行後的狀態變化或結果，必須是可觀察的行為。「Repository 的 save 方法被呼叫一次」是實作細節；「訂單成功儲存並回傳訂單編號」是可觀察的行為。</p>
<p>判斷行為還是實作的方法很簡單：使用者能否觀察到？改變實作會影響這個結果嗎？產品經理需要關心嗎？都是「能觀察、不影響、需要關心」就是行為，反之是實作細節。</p>
<h2 id="行為測試和實作測試的差異">行為測試和實作測試的差異</h2>
<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;OrderRepository.save should call database.insert&#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">repository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">verify</span><span class="p">(</span><span class="n">database</span><span class="p">.</span><span class="n">insert</span><span class="p">(</span><span class="s1">&#39;orders&#39;</span><span class="p">,</span> <span class="n">order</span><span class="p">.</span><span class="n">toJson</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>
<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;使用者提交訂單 - 訂單成功儲存&#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"> 2</span><span class="cl">  <span class="c1">// Given: 使用者已選擇商品並填寫完整資訊
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">validOrder</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">// When: 使用者提交訂單
</span></span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">submitOrderUseCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</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="c1">// Then: 系統確認訂單已儲存
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</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="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="n">isNotEmpty</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><p>這個測試關注「訂單是否成功儲存」，重構儲存機制不會影響結果。</p>
<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;當 Repository 回傳 null 時 UseCase 拋出例外&#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>從使用者視角：</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;使用者提交訂單失敗 - 商品庫存不足&#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="c1">// Given: 商品庫存為 0
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="c1">// When: 使用者嘗試提交訂單
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="c1">// Then: 系統回應「庫存不足」錯誤
</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><h2 id="分層測試策略">分層測試策略</h2>
<p>BDD 不適用所有架構層級，每層特性不同，測試策略也不同。</p>
<p><strong>UseCase 層</strong>是 BDD 的核心應用層，代表完整的使用者操作流程，必須使用 Given-When-Then 結構，涵蓋所有業務場景。</p>
<p><strong>Domain 層</strong>包含核心業務規則、值物件驗證和實體不變量，需要細緻的邊界條件測試，單元測試更適合。</p>
<p><strong>Behavior 層</strong>負責 ViewModel 轉換和事件處理，只有複雜轉換邏輯需要獨立測試，簡單轉換由 UseCase 層覆蓋即可。</p>
<p><strong>UI 層</strong>測試成本高，只測試關鍵互動路徑，使用整合測試。</p>
<p><strong>Interface 層</strong>只定義契約，沒有實作邏輯，不需要測試。</p>
<h2 id="mock-策略">Mock 策略</h2>
<p>核心原則：只 Mock 外層依賴，不 Mock 內層邏輯。</p>
<p>外層依賴（Repository、Service、Event Publisher）透過 Interface 進行 Mock，隔離外部系統。內層邏輯（Domain Entity、Value Object）必須使用真實物件，確保測試涵蓋真實業務邏輯。</p>
<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;使用者提交訂單成功&#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"> 2</span><span class="cl">  <span class="c1">// Mock Repository（外層依賴）
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">mockRepository</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">      <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#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="c1">// 使用真實的 Domain Entity（內層邏輯）
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</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></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="kd">final</span> <span class="n">useCase</span> <span class="o">=</span> <span class="n">SubmitOrderUseCase</span><span class="p">(</span><span class="nl">repository:</span> <span class="n">mockRepository</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><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="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="s1">&#39;order-123&#39;</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>錯誤寫法是 Mock Domain Entity：</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;使用者提交訂單成功&#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="kd">final</span> <span class="n">mockOrder</span> <span class="o">=</span> <span class="n">MockOrder</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">when</span><span class="p">(</span><span class="n">mockOrder</span><span class="p">.</span><span class="n">validate</span><span class="p">()).</span><span class="n">thenReturn</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="c1">// 沒有測試到任何真實業務邏輯
</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><h2 id="與-tdd-階段整合">與 TDD 階段整合</h2>
<p><strong>階段一（功能設計）</strong>：從需求識別使用者行為場景。「使用者可以提交訂單」需要提取多個場景：成功提交、庫存不足失敗、金額無效失敗等，每個場景涵蓋正常流程、異常流程和邊界條件。</p>
<p><strong>階段二（測試設計）</strong>：將行為場景轉換為可執行的測試程式碼，先建立結構，設置 Mock，再依 Given-When-Then 填入邏輯。</p>
<p><strong>階段三（實作策略）</strong>：測試先行。先完成所有測試場景並確認失敗（Red），才開始實作 UseCase 讓測試通過（Green）。</p>
<p><strong>階段四（重構優化）</strong>：重構時，行為測試必須保持穩定。重構導致測試需要修改，代表測試耦合了實作。</p>
<p>判斷重構品質的標準很清楚：替換 Repository 實作、改變演算法，不應讓測試失敗；改變業務規則、調整可觀察的錯誤訊息，才應讓測試失敗。</p>
<h2 id="常見挑戰">常見挑戰</h2>
<h3 id="測試覆蓋率盲點">測試覆蓋率盲點</h3>
<p>BDD 強調測試「重要行為」，可能讓某些程式碼未被覆蓋。混合策略解決這個問題：UseCase 層 100% BDD 測試，Domain 層複雜邏輯 100% 單元測試，整體維持 80% 程式碼覆蓋率目標。</p>
<h3 id="學習曲線">學習曲線</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;[業務場景描述] - 成功&#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"> 2</span><span class="cl">  <span class="c1">// Given: [前置條件]
</span></span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">input</span> <span class="o">=</span> <span class="p">[</span><span class="err">準備測試資料</span><span class="p">];</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">[</span><span class="err">設置</span> <span class="n">Mock</span> <span class="err">行為</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">// When: [觸發動作]
</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">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">input</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">// Then: [預期結果]
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</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="err">驗證業務結果</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>業務場景描述容易遺漏技術性的邊界條件（null、異常、極端值）。每個 UseCase 最少需要：一個正常流程、兩個異常流程、三個邊界條件。建立技術性測試檢查清單並在 Code Review 重點確認。</p>
<h3 id="測試設置複雜度">測試設置複雜度</h3>
<p>UseCase 層的 BDD 測試需要 Mock 多個依賴，建立 Test Helper 和 Builder Pattern 減少重複：</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="kd">class</span> <span class="nc">UseCaseTestHelper</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">static</span> <span class="n">MockOrderRepository</span> <span class="n">createMockRepository</span><span class="p">({</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="kd">required</span> <span class="n">SaveResult</span> <span class="n">saveResult</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="kd">final</span> <span class="n">mock</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">when</span><span class="p">(</span><span class="n">mock</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">)).</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">saveResult</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="n">mock</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="kd">class</span> <span class="nc">OrderBuilder</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="kt">int</span> <span class="n">_amount</span> <span class="o">=</span> <span class="m">100</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="kt">String</span> <span class="n">_userId</span> <span class="o">=</span> <span class="s1">&#39;user-001&#39;</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="n">OrderBuilder</span> <span class="n">withAmount</span><span class="p">(</span><span class="kt">int</span> <span class="n">amount</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">_amount</span> <span class="o">=</span> <span class="n">amount</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="k">return</span> <span class="k">this</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="n">Order</span> <span class="n">build</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl">    <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="n">_amount</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="n">_userId</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="行為粒度">行為粒度</h3>
<p>粒度太粗，失敗時難以定位；太細則接近單元測試，失去 BDD 優勢。採用「一個 UseCase 等於一個核心行為」的原則：UseCase 代表完整業務流程，名稱以動詞開頭（Submit, Cancel, Query），所有測試場景屬於同一個業務流程。</p>
<h3 id="業務需求變更">業務需求變更</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="kd">class</span> <span class="nc">OrderBusinessRules</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">static</span> <span class="kd">const</span> <span class="kt">int</span> <span class="n">freeShippingThreshold</span> <span class="o">=</span> <span class="m">1000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">static</span> <span class="kd">const</span> <span class="kt">int</span> <span class="n">maxOrderAmount</span> <span class="o">=</span> <span class="m">100000</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">static</span> <span class="kd">const</span> <span class="kt">int</span> <span class="n">minOrderAmount</span> <span class="o">=</span> <span class="m">1</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><h2 id="完整範例">完整範例</h2>
<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">group</span><span class="p">(</span><span class="s1">&#39;SubmitOrderUseCase&#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">late</span> <span class="n">MockOrderRepository</span> <span class="n">mockRepository</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  3</span><span class="cl">  <span class="n">late</span> <span class="n">MockInventoryService</span> <span class="n">mockInventoryService</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  4</span><span class="cl">  <span class="n">late</span> <span class="n">MockEventPublisher</span> <span class="n">mockEventPublisher</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">  5</span><span class="cl">  <span class="n">late</span> <span class="n">SubmitOrderUseCase</span> <span class="n">useCase</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="n">setUp</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">mockRepository</span> <span class="o">=</span> <span class="n">MockOrderRepository</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">  9</span><span class="cl">    <span class="n">mockInventoryService</span> <span class="o">=</span> <span class="n">MockInventoryService</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 10</span><span class="cl">    <span class="n">mockEventPublisher</span> <span class="o">=</span> <span class="n">MockEventPublisher</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 11</span><span class="cl">    <span class="n">useCase</span> <span class="o">=</span> <span class="n">SubmitOrderUseCase</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 12</span><span class="cl">      <span class="nl">repository:</span> <span class="n">mockRepository</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 13</span><span class="cl">      <span class="nl">inventoryService:</span> <span class="n">mockInventoryService</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 14</span><span class="cl">      <span class="nl">eventPublisher:</span> <span class="n">mockEventPublisher</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></span><span class="line"><span class="ln"> 18</span><span class="cl">  <span class="n">group</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"> 19</span><span class="cl">    <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="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 20</span><span class="cl">      <span class="c1">// Given: 使用者已選擇商品且填寫完整資訊
</span></span></span><span class="line"><span class="ln"> 21</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 22</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 23</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 24</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">2</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 25</span><span class="cl">        <span class="nl">shippingAddress:</span> <span class="n">Address</span><span class="p">(</span><span class="nl">city:</span> <span class="s1">&#39;台北市&#39;</span><span class="p">,</span> <span class="nl">district:</span> <span class="s1">&#39;信義區&#39;</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="n">when</span><span class="p">(</span><span class="n">mockInventoryService</span><span class="p">.</span><span class="n">checkStock</span><span class="p">(</span><span class="s1">&#39;prod-001&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 28</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">StockStatus</span><span class="p">.</span><span class="n">available</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 29</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 30</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="s1">&#39;order-123&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 31</span><span class="cl">
</span></span><span class="line"><span class="ln"> 32</span><span class="cl">      <span class="c1">// When: 使用者點擊「提交訂單」
</span></span></span><span class="line"><span class="ln"> 33</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</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="c1">// Then: 系統確認訂單已儲存並回傳訂單編號
</span></span></span><span class="line"><span class="ln"> 36</span><span class="cl"><span class="c1"></span>      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 37</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">orderId</span><span class="p">,</span> <span class="s1">&#39;order-123&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 38</span><span class="cl">      <span class="n">verify</span><span class="p">(</span><span class="n">mockEventPublisher</span><span class="p">.</span><span class="n">publish</span><span class="p">(</span><span class="n">any</span><span class="p">.</span><span class="n">having</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 39</span><span class="cl">        <span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">e</span><span class="p">.</span><span class="n">type</span><span class="p">,</span> <span class="s1">&#39;event type&#39;</span><span class="p">,</span> <span class="n">EventType</span><span class="p">.</span><span class="n">orderCreated</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 40</span><span class="cl">      <span class="p">))).</span><span class="n">called</span><span class="p">(</span><span class="m">1</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 41</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 42</span><span class="cl">  <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 class="n">group</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"> 45</span><span class="cl">    <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="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 46</span><span class="cl">      <span class="c1">// Given: 選擇的商品庫存為 0
</span></span></span><span class="line"><span class="ln"> 47</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 48</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 49</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 50</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">2</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 51</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 52</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockInventoryService</span><span class="p">.</span><span class="n">checkStock</span><span class="p">(</span><span class="s1">&#39;prod-001&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 53</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">StockStatus</span><span class="p">.</span><span class="n">outOfStock</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="c1">// When: 使用者點擊「提交訂單」
</span></span></span><span class="line"><span class="ln"> 56</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 57</span><span class="cl">
</span></span><span class="line"><span class="ln"> 58</span><span class="cl">      <span class="c1">// Then: 系統回應庫存不足錯誤，不儲存訂單
</span></span></span><span class="line"><span class="ln"> 59</span><span class="cl"><span class="c1"></span>      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 60</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">outOfStock</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 61</span><span class="cl">      <span class="n">verifyNever</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 62</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 63</span><span class="cl">
</span></span><span class="line"><span class="ln"> 64</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - Repository 儲存失敗&#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"> 65</span><span class="cl">      <span class="c1">// Given: Repository 無法儲存（網路錯誤）
</span></span></span><span class="line"><span class="ln"> 66</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 67</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">100</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 68</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 69</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">1</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln"> 70</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 71</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockInventoryService</span><span class="p">.</span><span class="n">checkStock</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 72</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">StockStatus</span><span class="p">.</span><span class="n">available</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 73</span><span class="cl">      <span class="n">when</span><span class="p">(</span><span class="n">mockRepository</span><span class="p">.</span><span class="n">save</span><span class="p">(</span><span class="n">any</span><span class="p">))</span>
</span></span><span class="line"><span class="ln"> 74</span><span class="cl">          <span class="p">.</span><span class="n">thenAnswer</span><span class="p">((</span><span class="n">_</span><span class="p">)</span> <span class="kd">async</span> <span class="o">=&gt;</span> <span class="n">SaveResult</span><span class="p">.</span><span class="n">failure</span><span class="p">(</span><span class="s1">&#39;網路連線失敗&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 75</span><span class="cl">
</span></span><span class="line"><span class="ln"> 76</span><span class="cl">      <span class="c1">// When: 使用者點擊「提交訂單」
</span></span></span><span class="line"><span class="ln"> 77</span><span class="cl"><span class="c1"></span>      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 78</span><span class="cl">
</span></span><span class="line"><span class="ln"> 79</span><span class="cl">      <span class="c1">// Then: 系統回應訂單提交失敗
</span></span></span><span class="line"><span class="ln"> 80</span><span class="cl"><span class="c1"></span>      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 81</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">saveFailed</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 82</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 83</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 84</span><span class="cl">
</span></span><span class="line"><span class="ln"> 85</span><span class="cl">  <span class="n">group</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"> 86</span><span class="cl">    <span class="n">test</span><span class="p">(</span><span class="s1">&#39;使用者提交訂單失敗 - 訂單金額為 0&#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"> 87</span><span class="cl">      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 88</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">0</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 89</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln"> 90</span><span class="cl">        <span class="nl">items:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="ln"> 91</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln"> 92</span><span class="cl">      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 93</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 94</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">invalidAmount</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 95</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 96</span><span class="cl">
</span></span><span class="line"><span class="ln"> 97</span><span class="cl">    <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"> 98</span><span class="cl">      <span class="n">expect</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 99</span><span class="cl">        <span class="p">()</span> <span class="o">=&gt;</span> <span class="n">Order</span><span class="p">(</span><span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="o">-</span><span class="m">100</span><span class="p">),</span> <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">)),</span>
</span></span><span class="line"><span class="ln">100</span><span class="cl">        <span class="n">throwsA</span><span class="p">(</span><span class="n">isA</span><span class="o">&lt;</span><span class="n">InvalidAmountException</span><span class="o">&gt;</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">101</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">102</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">103</span><span class="cl">
</span></span><span class="line"><span class="ln">104</span><span class="cl">    <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="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">105</span><span class="cl">      <span class="kd">final</span> <span class="n">order</span> <span class="o">=</span> <span class="n">Order</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">106</span><span class="cl">        <span class="nl">amount:</span> <span class="n">OrderAmount</span><span class="p">(</span><span class="m">1000001</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">107</span><span class="cl">        <span class="nl">userId:</span> <span class="n">UserId</span><span class="p">(</span><span class="s1">&#39;user-001&#39;</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">108</span><span class="cl">        <span class="nl">items:</span> <span class="p">[</span><span class="n">OrderItem</span><span class="p">(</span><span class="nl">productId:</span> <span class="s1">&#39;prod-001&#39;</span><span class="p">,</span> <span class="nl">quantity:</span> <span class="m">10000</span><span class="p">)],</span>
</span></span><span class="line"><span class="ln">109</span><span class="cl">      <span class="p">);</span>
</span></span><span class="line"><span class="ln">110</span><span class="cl">      <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">useCase</span><span class="p">.</span><span class="n">execute</span><span class="p">(</span><span class="n">order</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">111</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">isSuccess</span><span class="p">,</span> <span class="kc">false</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">112</span><span class="cl">      <span class="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">error</span><span class="p">,</span> <span class="n">ErrorType</span><span class="p">.</span><span class="n">amountExceedsLimit</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">113</span><span class="cl">    <span class="p">});</span>
</span></span><span class="line"><span class="ln">114</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">115</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="結論">結論</h2>
<p>回頭看最初那個重構週期，二十幾個因為替換 Repository 實作而失敗的測試，問題很清楚：測試在監視實作細節，而不是守護業務行為。</p>
<p>切換到 BDD 之後，同樣的重構只需確認業務行為沒有改變，測試套件就能保持穩定。</p>
<p>但 BDD 不是萬靈丹。它需要思維轉換，需要建立明確規範，需要持續 Code Review 維持品質。混合策略（UseCase 層 BDD、Domain 層單元測試、UI 層整合測試）才能真正發揮效果。</p>]]></content:encoded></item><item><title>混合測試策略：根據架構層級選擇測試方法</title><link>https://tarrragon.github.io/blog/record/%E6%B7%B7%E5%90%88%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5%E6%A0%B9%E6%93%9A%E6%9E%B6%E6%A7%8B%E5%B1%A4%E7%B4%9A%E9%81%B8%E6%93%87%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E6%B7%B7%E5%90%88%E6%B8%AC%E8%A9%A6%E7%AD%96%E7%95%A5%E6%A0%B9%E6%93%9A%E6%9E%B6%E6%A7%8B%E5%B1%A4%E7%B4%9A%E9%81%B8%E6%93%87%E6%B8%AC%E8%A9%A6%E6%96%B9%E6%B3%95/</guid><description>&lt;p>開始實踐 TDD 時，我們遇到一個困惑的問題：什麼都測，還是只測部分？&lt;/p>
&lt;p>追求覆蓋率，會寫出大量測試 getter 和直接欄位映射的測試，維護成本高，保護力低。不管覆蓋率，又很難有信心說業務邏輯正確。&lt;/p>
&lt;p>答案是：測試策略跟著架構走。&lt;/p></description><content:encoded><![CDATA[<p>開始實踐 TDD 時，我們遇到一個困惑的問題：什麼都測，還是只測部分？</p>
<p>追求覆蓋率，會寫出大量測試 getter 和直接欄位映射的測試，維護成本高，保護力低。不管覆蓋率，又很難有信心說業務邏輯正確。</p>
<p>答案是：測試策略跟著架構走。</p>
<h2 id="問題的根源">問題的根源</h2>
<p>全面單元測試的問題是，重構 ViewModel 內部實作時，大量測試跟著壞掉，但行為根本沒變。全面 BDD 的問題是，Domain 層邊界條件很難透過業務語言的場景完整覆蓋。</p>
<p>不同層級的程式碼，用不同的測試方法。</p>
<h2 id="五層架構的測試分工">五層架構的測試分工</h2>
<p><strong>Layer 1（UI/Presentation）</strong> 只針對關鍵互動流程撰寫整合測試。判斷標準：流程失敗是否影響核心業務、是否需要多步驟操作、是否涉及金流或敏感資料。靜態展示頁面、簡單列表交給人工測試。</p>
<p><strong>Layer 2（Application/Behavior）</strong> 只針對複雜轉換邏輯撰寫單元測試。判斷標準：轉換是否包含條件判斷、計算邏輯、多個來源資料，或邏輯超過十行。簡單的 DTO 欄位直接映射，不需要獨立測試，由 UseCase 層間接覆蓋。</p>
<p><strong>Layer 3（UseCase）</strong> 所有業務場景都必須撰寫 BDD 測試，沒有例外。每個 UseCase 至少涵蓋一個正常流程、兩個異常流程、三個邊界條件。格式使用 Given-When-Then，只 Mock 外層依賴，使用真實的 Domain Entity。</p>
<p><strong>Layer 4（Interface）</strong> 不測試。介面只定義合約，沒有可測試的行為。</p>
<p><strong>Layer 5（Domain Implementation）</strong> 複雜業務規則必須撰寫單元測試。判斷標準：是否包含業務規則驗證、計算邏輯、狀態轉換、不變量檢查。Email 格式驗證、金額範圍、訂單狀態轉換都需要完整的單元測試。純資料容器的 Entity 不需要獨立測試。</p>
<h2 id="做決策的流程">做決策的流程</h2>
<p>確定程式碼屬於哪一層（目錄結構直接反映架構層級），然後問一個問題：</p>
<ul>
<li>UI 層：這是關鍵互動流程嗎？</li>
<li>Behavior 層：這裡有複雜轉換邏輯嗎？</li>
<li>UseCase 層：直接寫 BDD 測試。</li>
<li>Interface 層：不測試。</li>
<li>Domain 層：這裡有複雜業務規則嗎？</li>
</ul>
<p>答案是就寫，不是就跳過讓上層覆蓋。</p>
<h2 id="技術性測試項目">技術性測試項目</h2>
<p>不分層級都要納入：</p>
<ul>
<li>Null 值和空集合</li>
<li>邊界值（零、負數、最大值）</li>
<li>異常處理（網路錯誤、儲存失敗）</li>
<li>資料驗證（格式、範圍、必填欄位）</li>
</ul>
<p>這些容易被忽略，但往往是上線後出問題的地方。</p>
<h2 id="覆蓋率的意義">覆蓋率的意義</h2>
<p>這套策略讓覆蓋率指標更有意義。UseCase 層要求行為場景覆蓋率 100%，是所有業務場景都有測試，不是追求程式碼行數百分比。Domain 層複雜邏輯要求分支覆蓋率 100%，每個分支都代表一個業務決策。整體新增程式碼維持 80% 以上。</p>
<p>數字背後有實際意義，不是為了報告好看。</p>
<h2 id="測試的穩定性">測試的穩定性</h2>
<p>UseCase 層的 BDD 測試關注行為，重構內部邏輯只要業務行為沒變，測試就不需要動。Domain 層只有規則本身改變才需要更新。Behavior 層只測複雜的轉換邏輯，重構簡單映射不影響測試。</p>
<p>測試應該是開發的保護網，不是阻力。測試因為業務改變而失敗，那很好；因為重構而大量失敗，那是設計問題。</p>]]></content:encoded></item></channel></rss>