<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>測試 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E6%B8%AC%E8%A9%A6/</link><description>Recent content in 測試 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Thu, 18 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E6%B8%AC%E8%A9%A6/index.xml" rel="self" type="application/rss+xml"/><item><title>flaky：時綠時紅的測試</title><link>https://tarrragon.github.io/blog/til/terms/flaky/</link><pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/til/terms/flaky/</guid><description>&lt;blockquote>
&lt;p>這個詞出現在「&lt;a href="../unreliable-tests/">測試紅燈不一定是真的壞&lt;/a>」這個問題裡。&lt;/p>&lt;/blockquote>
&lt;p>flaky test（不穩定測試）指&lt;strong>同一份程式碼、同一個測試，什麼都沒改，卻有時過有時不過&lt;/strong>。&lt;/p>
&lt;p>它是測試領域的 &lt;a href="../false-positive/">false positive&lt;/a>——測試報紅、但被測的程式碼其實沒問題——而 flaky 特指其中「&lt;strong>間歇、非確定性&lt;/strong>」的那種。&lt;/p>
&lt;h2 id="常見成因">常見成因&lt;/h2>
&lt;ul>
&lt;li>競態（race condition）、依賴執行順序。&lt;/li>
&lt;li>時間依賴：sleep、timeout、時鐘、時區。&lt;/li>
&lt;li>共用狀態沒清乾淨：測試之間互相污染。&lt;/li>
&lt;li>外部依賴：網路、第三方服務的暫態抖動。&lt;/li>
&lt;/ul>
&lt;h2 id="為何危險">為何危險&lt;/h2>
&lt;p>flaky 會侵蝕對測試套件的信任：紅了第一反應是點重跑而不是查 bug，久了連真的失敗也被當 flaky 忽略——這是測試版的 &lt;a href="../alert-fatigue/">alert fatigue&lt;/a>。&lt;/p>
&lt;h2 id="與-spurious-failure-的區別">與 spurious failure 的區別&lt;/h2>
&lt;p>flaky 強調「&lt;strong>間歇重現&lt;/strong>」（重跑可能就過）；&lt;a href="../spurious-failure/">spurious failure&lt;/a> 強調「這次失敗的原因不是被測對象」，不一定間歇。兩者都是測試的 false positive，角度不同。&lt;/p>
&lt;h2 id="相關概念">相關概念&lt;/h2>
&lt;ul>
&lt;li>上位概念：&lt;a href="../false-positive/">false positive&lt;/a>。&lt;/li>
&lt;li>近鄰：&lt;a href="../spurious-failure/">spurious failure&lt;/a>、後果 &lt;a href="../alert-fatigue/">alert fatigue&lt;/a>。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p>這個詞出現在「<a href="../unreliable-tests/">測試紅燈不一定是真的壞</a>」這個問題裡。</p></blockquote>
<p>flaky test（不穩定測試）指<strong>同一份程式碼、同一個測試，什麼都沒改，卻有時過有時不過</strong>。</p>
<p>它是測試領域的 <a href="../false-positive/">false positive</a>——測試報紅、但被測的程式碼其實沒問題——而 flaky 特指其中「<strong>間歇、非確定性</strong>」的那種。</p>
<h2 id="常見成因">常見成因</h2>
<ul>
<li>競態（race condition）、依賴執行順序。</li>
<li>時間依賴：sleep、timeout、時鐘、時區。</li>
<li>共用狀態沒清乾淨：測試之間互相污染。</li>
<li>外部依賴：網路、第三方服務的暫態抖動。</li>
</ul>
<h2 id="為何危險">為何危險</h2>
<p>flaky 會侵蝕對測試套件的信任：紅了第一反應是點重跑而不是查 bug，久了連真的失敗也被當 flaky 忽略——這是測試版的 <a href="../alert-fatigue/">alert fatigue</a>。</p>
<h2 id="與-spurious-failure-的區別">與 spurious failure 的區別</h2>
<p>flaky 強調「<strong>間歇重現</strong>」（重跑可能就過）；<a href="../spurious-failure/">spurious failure</a> 強調「這次失敗的原因不是被測對象」，不一定間歇。兩者都是測試的 false positive，角度不同。</p>
<h2 id="相關概念">相關概念</h2>
<ul>
<li>上位概念：<a href="../false-positive/">false positive</a>。</li>
<li>近鄰：<a href="../spurious-failure/">spurious failure</a>、後果 <a href="../alert-fatigue/">alert fatigue</a>。</li>
</ul>
]]></content:encoded></item><item><title>spurious failure：偽失敗</title><link>https://tarrragon.github.io/blog/til/terms/spurious-failure/</link><pubDate>Thu, 18 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/til/terms/spurious-failure/</guid><description>&lt;blockquote>
&lt;p>這個詞出現在「&lt;a href="../unreliable-tests/">測試紅燈不一定是真的壞&lt;/a>」這個問題裡。&lt;/p>&lt;/blockquote>
&lt;p>spurious failure（偽失敗）指&lt;strong>測試或建置確實失敗了，但失敗的原因是環境、網路、暫態干擾、基礎設施問題&lt;/strong>——被測對象本身沒有問題。&lt;/p>
&lt;p>它是測試的 &lt;a href="../false-positive/">false positive&lt;/a>：報了失敗、但程式碼其實沒問題。&lt;/p>
&lt;h2 id="與-flaky-的區別">與 flaky 的區別&lt;/h2>
&lt;p>兩者都是測試的 false positive，重點不同：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>spurious failure&lt;/strong> 強調「&lt;strong>這次失敗的原因不是真因&lt;/strong>」——例如 CI 機器磁碟滿了、套件源連不上。&lt;/li>
&lt;li>&lt;strong>&lt;a href="../flaky/">flaky&lt;/a>&lt;/strong> 強調「&lt;strong>間歇、不穩定&lt;/strong>」——重跑可能就過，成因常是競態或時序。&lt;/li>
&lt;/ul>
&lt;p>一次 spurious failure 不一定 flaky（環境修好就穩定失敗或穩定通過）；flaky 的每次紅則多半是 spurious——除非不穩定源自被測碼自身的非確定性（那時紅燈反映的是真缺陷，不是偽失敗）。&lt;/p>
&lt;h2 id="處理">處理&lt;/h2>
&lt;p>確認是偽失敗後，修的是&lt;strong>環境或基礎設施&lt;/strong>，不是被測程式碼。把它和真失敗區分開，避免污染對測試的信任（見 &lt;a href="../alert-fatigue/">alert fatigue&lt;/a>）。&lt;/p>
&lt;h2 id="相關概念">相關概念&lt;/h2>
&lt;ul>
&lt;li>上位概念：&lt;a href="../false-positive/">false positive&lt;/a>。&lt;/li>
&lt;li>近鄰：&lt;a href="../flaky/">flaky&lt;/a>。&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p>這個詞出現在「<a href="../unreliable-tests/">測試紅燈不一定是真的壞</a>」這個問題裡。</p></blockquote>
<p>spurious failure（偽失敗）指<strong>測試或建置確實失敗了，但失敗的原因是環境、網路、暫態干擾、基礎設施問題</strong>——被測對象本身沒有問題。</p>
<p>它是測試的 <a href="../false-positive/">false positive</a>：報了失敗、但程式碼其實沒問題。</p>
<h2 id="與-flaky-的區別">與 flaky 的區別</h2>
<p>兩者都是測試的 false positive，重點不同：</p>
<ul>
<li><strong>spurious failure</strong> 強調「<strong>這次失敗的原因不是真因</strong>」——例如 CI 機器磁碟滿了、套件源連不上。</li>
<li><strong><a href="../flaky/">flaky</a></strong> 強調「<strong>間歇、不穩定</strong>」——重跑可能就過，成因常是競態或時序。</li>
</ul>
<p>一次 spurious failure 不一定 flaky（環境修好就穩定失敗或穩定通過）；flaky 的每次紅則多半是 spurious——除非不穩定源自被測碼自身的非確定性（那時紅燈反映的是真缺陷，不是偽失敗）。</p>
<h2 id="處理">處理</h2>
<p>確認是偽失敗後，修的是<strong>環境或基礎設施</strong>，不是被測程式碼。把它和真失敗區分開，避免污染對測試的信任（見 <a href="../alert-fatigue/">alert fatigue</a>）。</p>
<h2 id="相關概念">相關概念</h2>
<ul>
<li>上位概念：<a href="../false-positive/">false positive</a>。</li>
<li>近鄰：<a href="../flaky/">flaky</a>。</li>
</ul>
]]></content:encoded></item><item><title>測試全過但有 Bug</title><link>https://tarrragon.github.io/blog/record/%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E4%BD%86%E6%9C%89-bug/</link><pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E6%B8%AC%E8%A9%A6%E5%85%A8%E9%81%8E%E4%BD%86%E6%9C%89-bug/</guid><description>&lt;blockquote>
&lt;p>2026-03，開發線上點單多廚房印表機列印功能。
寫了 28 個測試，全部通過，上實機後陸續發現四個 Bug。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="發生了什麼事">發生了什麼事&lt;/h2>
&lt;p>功能的核心流程：&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">收到追加點單
&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"> → 逐行列印
&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;/code>&lt;/pre>&lt;/div>&lt;p>上實機後，四個 Bug 依序出現——因為它們在同一條執行路徑的不同深度，每修一個，程式才能走到下一個：&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">Bug 1（印表機內部元件未初始化）
&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">Bug 2（品項分派邏輯錯誤，全部送到同一台）
&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">Bug 3（多欄表格的欄位寬度不符合規定）
&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">Bug 4（空行觸發第三方 library 的越界錯誤）&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Bug&lt;/th>
 &lt;th>出了什麼事&lt;/th>
 &lt;th>測試沒抓到的原因&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Bug 1: 印表機元件未初始化&lt;/td>
 &lt;td>模擬印表機的初始化漏掉了內部元件&lt;/td>
 &lt;td>只測了「送出資料」，沒測「組裝列印指令」這個步驟&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Bug 2: 品項全分到同一台&lt;/td>
 &lt;td>分派邏輯找到第一台可用的印表機就全部送過去&lt;/td>
 &lt;td>手動構造預期結果，分派邏輯沒有被執行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Bug 3: 欄位寬度錯誤&lt;/td>
 &lt;td>欄位比例（3:1=4）不符合 library 要求的總和 12&lt;/td>
 &lt;td>模擬的收據內容只有純文字行，沒有多欄表格行&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Bug 4: 空行越界錯誤&lt;/td>
 &lt;td>第三方 library 沒有處理空字串&lt;/td>
 &lt;td>被 Bug 3 擋住，程式從未執行到這一行&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼一次只能發現一個-bug">為什麼一次只能發現一個 Bug&lt;/h2>
&lt;p>四個 Bug 都在同一條執行路徑上，只是深度不同。程式走到第一個錯誤就中斷了，後面的都被遮蔽。&lt;/p>
&lt;p>而測試沒有發現這些問題，是因為測試沒有走過這條完整路徑。&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">真實路徑： 接收訂單 → 組裝收據 → 列印中心 → 表格列印/文字列印 → 印表機底層
&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">後來補了模擬元件：接收訂單 → 模擬收據產生器 → 列印中心 → 模擬印表機 → 底層
&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>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> → Bug 3, 4 仍然隱藏&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這次的問題在測試覆蓋的路徑深度。&lt;/p>
&lt;hr>
&lt;h2 id="回顧這次遇到的五個事故">回顧：這次遇到的五個事故&lt;/h2>
&lt;h3 id="1-測試的是手動構造的結果不是程式的行為">1. 測試的是「手動構造的結果」，不是「程式的行為」&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 實機上兩台廚房印表機只有一台收到品項，另一台完全沒動。但測試裡「品項分派邏輯」的測試案例是通過的。&lt;/p>
&lt;p>&lt;strong>怎麼找到原因的：&lt;/strong> 回頭看測試程式碼，發現測試裡的分派結果是手動寫死的，不是由分派邏輯算出來的：&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;品名長度奇數分配到第一台&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="kd">final&lt;/span> &lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OnlineOrderPrintResult&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="nl">itemPrinterMapping:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s1">&amp;#39;item-1&amp;#39;&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;kitchen-2&amp;#39;&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">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 class="n">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">applyPrintResult&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">6&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">kitchenItemPrintJobs&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;item-1&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="n">printerId&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;kitchen-2&amp;#39;&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>這個測試驗證的是「把結果存進去再讀出來，資料有沒有一致」，但品項分派的程式碼從頭到尾沒有被執行過。測試名稱寫的是分派邏輯，實際測的是資料儲存。&lt;/p>
&lt;p>&lt;strong>怎麼修的：&lt;/strong> 改成從入口方法開始呼叫，讓品項分派的邏輯實際跑一遍。跑完之後 Bug 2 就出現了——分派邏輯的 fallback 條件寫錯，所有品項都被送到同一台。&lt;/p>
&lt;hr>
&lt;h3 id="2-只測了子類別自己的方法沒測從父類別繼承的方法">2. 只測了子類別自己的方法，沒測從父類別繼承的方法&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 實機上廚房印表機列印全部失敗，log 顯示內部元件未初始化的錯誤，但測試裡模擬印表機的初始化和列印測試都是通過的。&lt;/p>
&lt;p>&lt;strong>怎麼找到原因的：&lt;/strong> 比對測試和實際程式碼的呼叫路徑。測試裡呼叫的是模擬印表機自己覆寫的「送出資料」方法（改成什麼都不做），但實際列印時上層呼叫的是從父類別繼承的「組裝列印指令」方法，這個方法內部依賴一個需要初始化的元件。測試覆蓋到的方法，和實際執行路徑走到的方法不是同一個。&lt;/p>
&lt;p>&lt;strong>怎麼修的：&lt;/strong> 在測試中加入對繼承方法的測試——初始化後呼叫「組裝列印指令」確認不報錯，以及未初始化時呼叫確認會拋出錯誤。同時修正模擬印表機的初始化方法，補上內部元件的建立。&lt;/p>
&lt;hr>
&lt;h3 id="3-斷言只檢查有沒有沒檢查對不對">3. 斷言只檢查「有沒有」，沒檢查「對不對」&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 修完 Bug 1 和 2 之後重跑測試，整合測試通過了。但看 log 發現廚房 1 和廚房 2 的列印結果都是 &lt;code>false&lt;/code>（失敗），和測試通過的結果矛盾。&lt;/p>
&lt;p>&lt;strong>怎麼找到原因的：&lt;/strong> 回頭看斷言，發現寫的是 &lt;code>containsKey&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="n">expect&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">kitchenResults&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">containsKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;kitchen-1&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">isTrue&lt;/span>&lt;span class="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>這個斷言只檢查「有沒有這台印表機的結果」，不管結果是成功還是失敗。列印在 try-catch 裡失敗後回傳 &lt;code>false&lt;/code>，但 key 存在，所以斷言通過。&lt;/p>
&lt;p>&lt;strong>怎麼修的：&lt;/strong> 改成直接檢查值 &lt;code>expect(result.kitchenResults['kitchen-1'], isTrue)&lt;/code>。改完之後測試立刻失敗，顯示列印結果確實是 &lt;code>false&lt;/code>。這才發現測試環境缺少收據產生器的依賴，列印路徑在組裝收據的步驟就斷了，被 try-catch 吞掉回傳 &lt;code>false&lt;/code>。&lt;/p>
&lt;hr>
&lt;h3 id="4-模擬元件的回傳資料只覆蓋了部分分支">4. 模擬元件的回傳資料只覆蓋了部分分支&lt;/h3>
&lt;p>&lt;strong>怎麼發現的：&lt;/strong> 修完 Bug 1、2，也修正了斷言（坑 3）之後，測試全過，列印結果也都是 &lt;code>true&lt;/code>。但上實機測試時，廚房印表機仍然全部失敗，log 顯示「欄位寬度總和必須等於 12」。&lt;/p>
&lt;p>&lt;strong>怎麼找到原因的：&lt;/strong> 測試環境和實機的差異在於收據的內容。實機用的是真實的廚房收據模板，包含多欄表格（品名+數量）。測試用的是模擬的收據產生器，只回傳一行純文字：&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="kd">class&lt;/span> &lt;span class="nc">FakeReceiptBuilderService&lt;/span> &lt;span class="kd">extends&lt;/span> &lt;span class="n">ReceiptBuilderService&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">Future&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">ReceiptLine&lt;/span>&lt;span class="o">&amp;gt;&amp;gt;&lt;/span> &lt;span class="n">buildReceiptLines&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">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">ReceiptLine&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">singleLine&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">title&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">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 class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>純文字走的是「文字列印」，多欄表格走的是「表格列印」——這是兩條不同的分支。模擬的資料只觸發了文字列印，表格列印從未被測試執行過。&lt;/p></description><content:encoded><![CDATA[<blockquote>
<p>2026-03，開發線上點單多廚房印表機列印功能。
寫了 28 個測試，全部通過，上實機後陸續發現四個 Bug。</p></blockquote>
<hr>
<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">收到追加點單
</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">  → 逐行列印
</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></code></pre></div><p>上實機後，四個 Bug 依序出現——因為它們在同一條執行路徑的不同深度，每修一個，程式才能走到下一個：</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">Bug 1（印表機內部元件未初始化）
</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">Bug 2（品項分派邏輯錯誤，全部送到同一台）
</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">Bug 3（多欄表格的欄位寬度不符合規定）
</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">Bug 4（空行觸發第三方 library 的越界錯誤）</span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>Bug</th>
          <th>出了什麼事</th>
          <th>測試沒抓到的原因</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 1: 印表機元件未初始化</td>
          <td>模擬印表機的初始化漏掉了內部元件</td>
          <td>只測了「送出資料」，沒測「組裝列印指令」這個步驟</td>
      </tr>
      <tr>
          <td>Bug 2: 品項全分到同一台</td>
          <td>分派邏輯找到第一台可用的印表機就全部送過去</td>
          <td>手動構造預期結果，分派邏輯沒有被執行</td>
      </tr>
      <tr>
          <td>Bug 3: 欄位寬度錯誤</td>
          <td>欄位比例（3:1=4）不符合 library 要求的總和 12</td>
          <td>模擬的收據內容只有純文字行，沒有多欄表格行</td>
      </tr>
      <tr>
          <td>Bug 4: 空行越界錯誤</td>
          <td>第三方 library 沒有處理空字串</td>
          <td>被 Bug 3 擋住，程式從未執行到這一行</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼一次只能發現一個-bug">為什麼一次只能發現一個 Bug</h2>
<p>四個 Bug 都在同一條執行路徑上，只是深度不同。程式走到第一個錯誤就中斷了，後面的都被遮蔽。</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">真實路徑：  接收訂單 → 組裝收據 → 列印中心 → 表格列印/文字列印 → 印表機底層
</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">後來補了模擬元件：接收訂單 → 模擬收據產生器 → 列印中心 → 模擬印表機 → 底層
</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></span><span class="line"><span class="ln">8</span><span class="cl">                           → Bug 3, 4 仍然隱藏</span></span></code></pre></div><p>這次的問題在測試覆蓋的路徑深度。</p>
<hr>
<h2 id="回顧這次遇到的五個事故">回顧：這次遇到的五個事故</h2>
<h3 id="1-測試的是手動構造的結果不是程式的行為">1. 測試的是「手動構造的結果」，不是「程式的行為」</h3>
<p><strong>怎麼發現的：</strong> 實機上兩台廚房印表機只有一台收到品項，另一台完全沒動。但測試裡「品項分派邏輯」的測試案例是通過的。</p>
<p><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="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">result</span> <span class="o">=</span> <span class="n">OnlineOrderPrintResult</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="nl">itemPrinterMapping:</span> <span class="p">{</span><span class="s1">&#39;item-1&#39;</span><span class="o">:</span> <span class="s1">&#39;kitchen-2&#39;</span><span class="p">},</span> <span class="c1">// 寫死的值
</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 class="n">record</span><span class="p">.</span><span class="n">applyPrintResult</span><span class="p">(</span><span class="n">result</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">record</span><span class="p">.</span><span class="n">kitchenItemPrintJobs</span><span class="p">[</span><span class="s1">&#39;item-1&#39;</span><span class="p">]</span><span class="o">!</span><span class="p">.</span><span class="n">printerId</span><span class="p">,</span> <span class="s1">&#39;kitchen-2&#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></code></pre></div><p>這個測試驗證的是「把結果存進去再讀出來，資料有沒有一致」，但品項分派的程式碼從頭到尾沒有被執行過。測試名稱寫的是分派邏輯，實際測的是資料儲存。</p>
<p><strong>怎麼修的：</strong> 改成從入口方法開始呼叫，讓品項分派的邏輯實際跑一遍。跑完之後 Bug 2 就出現了——分派邏輯的 fallback 條件寫錯，所有品項都被送到同一台。</p>
<hr>
<h3 id="2-只測了子類別自己的方法沒測從父類別繼承的方法">2. 只測了子類別自己的方法，沒測從父類別繼承的方法</h3>
<p><strong>怎麼發現的：</strong> 實機上廚房印表機列印全部失敗，log 顯示內部元件未初始化的錯誤，但測試裡模擬印表機的初始化和列印測試都是通過的。</p>
<p><strong>怎麼找到原因的：</strong> 比對測試和實際程式碼的呼叫路徑。測試裡呼叫的是模擬印表機自己覆寫的「送出資料」方法（改成什麼都不做），但實際列印時上層呼叫的是從父類別繼承的「組裝列印指令」方法，這個方法內部依賴一個需要初始化的元件。測試覆蓋到的方法，和實際執行路徑走到的方法不是同一個。</p>
<p><strong>怎麼修的：</strong> 在測試中加入對繼承方法的測試——初始化後呼叫「組裝列印指令」確認不報錯，以及未初始化時呼叫確認會拋出錯誤。同時修正模擬印表機的初始化方法，補上內部元件的建立。</p>
<hr>
<h3 id="3-斷言只檢查有沒有沒檢查對不對">3. 斷言只檢查「有沒有」，沒檢查「對不對」</h3>
<p><strong>怎麼發現的：</strong> 修完 Bug 1 和 2 之後重跑測試，整合測試通過了。但看 log 發現廚房 1 和廚房 2 的列印結果都是 <code>false</code>（失敗），和測試通過的結果矛盾。</p>
<p><strong>怎麼找到原因的：</strong> 回頭看斷言，發現寫的是 <code>containsKey</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="n">expect</span><span class="p">(</span><span class="n">result</span><span class="p">.</span><span class="n">kitchenResults</span><span class="p">.</span><span class="n">containsKey</span><span class="p">(</span><span class="s1">&#39;kitchen-1&#39;</span><span class="p">),</span> <span class="n">isTrue</span><span class="p">);</span></span></span></code></pre></div><p>這個斷言只檢查「有沒有這台印表機的結果」，不管結果是成功還是失敗。列印在 try-catch 裡失敗後回傳 <code>false</code>，但 key 存在，所以斷言通過。</p>
<p><strong>怎麼修的：</strong> 改成直接檢查值 <code>expect(result.kitchenResults['kitchen-1'], isTrue)</code>。改完之後測試立刻失敗，顯示列印結果確實是 <code>false</code>。這才發現測試環境缺少收據產生器的依賴，列印路徑在組裝收據的步驟就斷了，被 try-catch 吞掉回傳 <code>false</code>。</p>
<hr>
<h3 id="4-模擬元件的回傳資料只覆蓋了部分分支">4. 模擬元件的回傳資料只覆蓋了部分分支</h3>
<p><strong>怎麼發現的：</strong> 修完 Bug 1、2，也修正了斷言（坑 3）之後，測試全過，列印結果也都是 <code>true</code>。但上實機測試時，廚房印表機仍然全部失敗，log 顯示「欄位寬度總和必須等於 12」。</p>
<p><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="kd">class</span> <span class="nc">FakeReceiptBuilderService</span> <span class="kd">extends</span> <span class="n">ReceiptBuilderService</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="n">List</span><span class="o">&lt;</span><span class="n">ReceiptLine</span><span class="o">&gt;&gt;</span> <span class="n">buildReceiptLines</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="k">return</span> <span class="p">[</span><span class="n">ReceiptLine</span><span class="p">.</span><span class="n">singleLine</span><span class="p">(</span><span class="n">data</span><span class="p">.</span><span class="n">title</span><span class="p">)];</span> <span class="c1">// 只有標題
</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 class="p">}</span></span></span></code></pre></div><p>純文字走的是「文字列印」，多欄表格走的是「表格列印」——這是兩條不同的分支。模擬的資料只觸發了文字列印，表格列印從未被測試執行過。</p>
<ul>
<li>純文字列印有被覆蓋 → 沒問題</li>
<li>多欄表格列印沒有被觸發 → Bug 3 仍然隱藏</li>
<li>空行列印沒有被觸發 → Bug 4 仍然隱藏</li>
</ul>
<p><strong>怎麼修的：</strong> 在印表機的表格列印方法中加入自動正規化，將欄位比例換算為符合 library 要求的總和 12。這是適配層的修復，所有收據模板都不需要修改。</p>
<hr>
<h3 id="5-第三方-library-的地雷前面都做對了才踩到">5. 第三方 library 的地雷——前面都做對了才踩到</h3>
<p><strong>怎麼發現的：</strong> 修完 Bug 3 之後再上實機，廚房印表機仍然失敗，但錯誤訊息不同了——從「欄位寬度總和必須等於 12」變成「RangeError: Valid value range is empty: 0」。</p>
<p><strong>怎麼找到原因的：</strong> 從 log 看到列印標題和桌號成功（兩次資料送出），在第三行（空行）就失敗了。追蹤到第三方 library 的原始碼，發現它在解析文字時會取第一個字元來判斷是否為中文字，但沒有處理空字串的情況，直接對空字串取 <code>text[0]</code> 導致越界。</p>
<p>這個問題一直存在，但之前 Bug 3 擋在前面（程式在表格列印就失敗了，走不到後面的空行列印），前三個 Bug 都修好之後，執行路徑才真正打通，觸發了這個潛在問題。</p>
<p>從另一個角度看，能走到 Bug 4 代表前面的修復都是有效的。</p>
<p><strong>怎麼修的：</strong> 在呼叫 library 之前加了空字串的前置檢查，遇到空字串時改用換行指令代替，繞過 library 的問題。</p>
<hr>
<h3 id="6-try-catch-的範圍太大把程式碼-bug-和硬體故障混在一起處理">6. try-catch 的範圍太大，把程式碼 bug 和硬體故障混在一起處理</h3>
<p><strong>怎麼發現的：</strong> 回顧整個除錯過程，Bug 1、3、4 都有一個共同特徵——錯誤被 try-catch 吞掉，回傳 <code>false</code>，沒有任何明顯的異常。在測試中，缺少依賴的情況也被同樣的 try-catch 吞掉，測試照樣通過。</p>
<p><strong>怎麼找到原因的：</strong> 看列印方法的 catch 區塊：</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">Future</span><span class="o">&lt;</span><span class="kt">bool</span><span class="o">&gt;</span> <span class="n">_printKitchenReceipt</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="k">try</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="c1">// 組裝收據行
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>    <span class="c1">// 呼叫印表機列印
</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="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">e</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">debugPrint</span><span class="p">(</span><span class="s1">&#39;failed: </span><span class="si">$</span><span class="n">e</span><span class="s1">&#39;</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">false</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>catch (e)</code> 攔截了所有錯誤，不區分類型。但這裡面混了兩種性質不同的錯誤：</p>
<ul>
<li><strong>印表機故障</strong>（連線逾時、無紙、裝置離線）→ 執行期的預期狀況，應該攔截，回傳失敗讓 UI 顯示重印按鈕</li>
<li><strong>程式碼 bug</strong>（未初始化的元件、欄位寬度不合法、空字串越界）→ 開發階段就該被發現的問題，不應該被靜默吞掉</li>
</ul>
<p>四個 Bug 裡有三個屬於後者，全部被同一個 <code>catch (e)</code> 攔住，在開發和測試階段都沒有任何異常跡象。</p>
<p><strong>怎麼修的：</strong> 做了三件事：</p>
<ol>
<li>定義 <code>PrinterException</code>，專門代表印表機硬體/連線錯誤</li>
<li>在列印中心（IO 邊界）把印表機拋出的 <code>Exception</code> 包成 <code>PrinterException</code>，但不攔截 <code>Error</code>（程式碼 bug）</li>
<li>列印方法改為 <code>on PrinterException catch</code>，只處理印表機故障</li>
</ol>
<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="c1">// 改動前：所有錯誤都被吞掉
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="k">try</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">lines</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">receiptBuilder</span><span class="p">.</span><span class="n">buildReceiptLines</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">template</span><span class="p">);</span>  <span class="c1">// ← 出錯也被吞
</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">printCenter</span><span class="p">.</span><span class="n">printReceiptLines</span><span class="p">(</span><span class="nl">lines:</span> <span class="n">lines</span><span class="p">,</span> <span class="nl">printer:</span> <span class="n">printer</span><span class="p">);</span>   <span class="c1">// ← 出錯也被吞
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="n">e</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 class="kc">false</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></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 改動後：資料準備不在 try 裡，只攔截印表機錯誤
</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">lines</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">receiptBuilder</span><span class="p">.</span><span class="n">buildReceiptLines</span><span class="p">(</span><span class="n">data</span><span class="p">,</span> <span class="n">template</span><span class="p">);</span>  <span class="c1">// ← 出錯直接拋出
</span></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="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="kd">await</span> <span class="n">printCenter</span><span class="p">.</span><span class="n">printReceiptLines</span><span class="p">(</span><span class="nl">lines:</span> <span class="n">lines</span><span class="p">,</span> <span class="nl">printer:</span> <span class="n">printer</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="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 class="n">on</span> <span class="n">PrinterException</span> <span class="k">catch</span> <span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span>  <span class="c1">// ← 只攔截印表機故障
</span></span></span><span class="line"><span class="ln">17</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="kc">false</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>改完之後，測試裡缺少依賴的情況不再被吞掉——之前有一組測試預期列印結果是 <code>false</code>（因為缺收據產生器被 catch 吞掉），現在補上依賴後預期改為 <code>true</code>，測試驗證的是真正的列印行為。</p>
<hr>
<h2 id="從這次經驗歸納的測試方法">從這次經驗歸納的測試方法</h2>
<h3 id="一從呼叫路徑出發而非從程式碼結構出發">一、從呼叫路徑出發，而非從程式碼結構出發</h3>
<p>這次犯的最大錯誤是按照「這個 class 有哪些方法」來分配測試，結果每個方法各自通過，但串在一起就出問題。後來改成按「使用者操作觸發了什麼路徑」來規劃：</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">使用者操作                    要測試的完整路徑
</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">追加點餐送出     →  handler.printAppendedOrder
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">                      → _buildItemPrinterMapping  ← 分派邏輯
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">                      → buildReceiptLines          ← 收據組裝
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">                      → printReceiptLines           ← 實際列印
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">                      → printText / printRow        ← 印表機操作
</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">點擊重印按鈕     →  retryItemKitchenPrint
</span></span><span class="line"><span class="ln">10</span><span class="cl">                      → printAppendedOrder(kitchenItemIds: {itemId})</span></span></code></pre></div><p>按路徑規劃之後，每個測試案例都會走過完整的鏈路，中間環節的問題自然會被觸發。</p>
<h3 id="二整合測試與單元測試的分工">二、整合測試與單元測試的分工</h3>
<p>這次的六個坑裡，有四個（坑 1、3、4、6）屬於「元件之間銜接」的問題，單元測試各自通過但串接失敗。回頭看分工：</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">                    單元測試                     整合測試
</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">假設什麼？        其他元件是正確的               驗證元件之間的銜接
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">能抓到什麼 Bug？  演算法邏輯錯誤                 初始化遺漏、依賴缺失、
</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">本案例中          KitchenPrinterConfig           printAppendedOrder +
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">                  .handlesProduct                PrintCenter + FakePrinter +
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">                  → 匹配邏輯正確                 ReceiptBuilderService
</span></span><span class="line"><span class="ln">10</span><span class="cl">                                                → 端到端路徑正確</span></span></code></pre></div><p>這次的經驗是：功能涉及多個元件協作時，只有單元測試是不夠的。整合測試才能抓到元件之間的銜接問題。</p>
<h3 id="三替-try-catch-設計專門的測試">三、替 try-catch 設計專門的測試</h3>
<p>try-catch 在這次經驗裡反覆出現——Bug 1、3、4 被它吞掉，坑 3 的斷言因為它而失效，坑 6 則是根本性的設計問題。</p>
<p>回顧後歸納的三個對策：</p>
<ul>
<li><strong>斷言成功路徑的值</strong>：不只檢查「沒拋錯」，要檢查回傳值是 <code>true</code>。坑 3 就是因為只檢查 key 存在，沒檢查值</li>
<li><strong>提供完整的依賴</strong>：讓 try 區塊能完整執行，而非依賴 catch 來「通過」測試。坑 3 的根因就是缺少收據產生器</li>
<li><strong>寫專門的失敗測試</strong>：故意製造失敗條件（如模擬印表機拋出 <code>PrinterException</code>），驗證錯誤處理行為符合預期</li>
</ul>
<h3 id="四fake--mock-的設計原則">四、Fake / Mock 的設計原則</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">                    Fake（假實作）               Mock（模擬物件）
</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">本案例            FakeReceiptBuilderService      不適用（需要驗證端到端結果）
</span></span><span class="line"><span class="ln">5</span><span class="cl">                  FakePrinterAdapter</span></span></code></pre></div><p>這次 <code>FakePrinterAdapter</code> 的設計漏掉了父類別繼承方法依賴的內部狀態（坑 2），<code>FakeReceiptBuilderService</code> 的回傳資料只覆蓋了部分分支（坑 4）。後來整理出設計 Fake 時的確認項目：</p>
<ul>
<li>繼承/實作的方法中，有哪些是上層呼叫者實際會用到的？</li>
<li>這些方法依賴哪些內部狀態（如 <code>late</code> 變數）？</li>
<li>Fake 的初始化是否正確建立了這些內部狀態？</li>
<li>Fake 回傳的資料是否足以讓下游所有分支都被觸發？</li>
</ul>
<hr>
<h2 id="之後可以改善的地方">之後可以改善的地方</h2>
<h3 id="寫測試時">寫測試時</h3>
<ul>
<li>測試應從入口方法開始驅動，讓中間的邏輯實際執行，避免手動構造中間結果</li>
<li>使用模擬子類別時，確認上層實際呼叫到的繼承方法也有被測試覆蓋</li>
<li>斷言驗證值本身，而非只驗證存在性（<code>containsKey</code> → 直接檢查值）</li>
<li>設計模擬元件的回傳資料時，先確認下游有哪些分支，確認回傳資料能觸發這些分支</li>
<li>回傳資料中加入邊界值——空字串、空列表等</li>
</ul>
<h3 id="修-bug-時">修 Bug 時</h3>
<ul>
<li>修完後確認有測試會實際走到修改的程式碼，否則測試通過不代表修改生效</li>
<li>沿著執行路徑往下看——之前被擋住的程式碼現在可以執行了，那些區段可能存在未發現的問題</li>
<li>如果修改讓新的資料流入第三方 library，檢查那些資料是否有 edge case</li>
</ul>
<h3 id="設計-try-catch-時">設計 try-catch 時</h3>
<ul>
<li>區分「預期的執行期錯誤」和「程式碼 bug」，只攔截前者</li>
<li>定義專用的 exception 類型（如 <code>PrinterException</code>），在 IO 邊界包裝，上層只 catch 這個類型</li>
<li>資料準備、邏輯運算等步驟不要放在 try-catch 裡面，讓錯誤直接拋出</li>
</ul>
<h3 id="自我檢查清單">自我檢查清單</h3>
<p>寫完測試後可以對照的問題：</p>
<ol>
<li>這個測試有走過真實的呼叫路徑嗎？還是只測了資料搬運？</li>
<li>斷言是驗證「值」還是只驗證「存在」？</li>
<li>所有依賴都有提供嗎？缺少的依賴會不會被 try-catch 吞掉？</li>
<li>模擬子類別覆寫的方法之外，繼承的方法有被測到嗎？</li>
<li>模擬元件回傳的資料有觸發下游的所有分支嗎？</li>
<li>邊界值（空字串、空列表、null）有出現在測試資料中嗎？</li>
<li>try-catch 的範圍是否只包含 IO 操作？資料準備和邏輯運算是否在 try 外面？</li>
<li>有寫反向測試（故意觸發錯誤）來確認理解了 Bug 的根因嗎？</li>
</ol>
<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">28 tests
</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">無廚房印表機時的基本行為（4 tests）
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  └── 基本的 handler 行為，不需要廚房印表機
</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">OnlineOrderRecord 模型（7 tests）
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  └── 單元測試：狀態管理、applyPrintResult
</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">FakePrinterAdapter（6 tests）
</span></span><span class="line"><span class="ln">10</span><span class="cl">  ├── init / sendBytes — 基本功能
</span></span><span class="line"><span class="ln">11</span><span class="cl">  ├── printText after init — 驗證內部元件初始化（抓 Bug 1）
</span></span><span class="line"><span class="ln">12</span><span class="cl">  └── printText without init — 反向驗證
</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">KitchenPrinterConfig（2 tests）
</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">品項分派邏輯 — 整合測試（4 tests）     ← 全部重寫
</span></span><span class="line"><span class="ln">18</span><span class="cl">  ├── 2 台空 mapping → odd/even 分配     抓 Bug 2
</span></span><span class="line"><span class="ln">19</span><span class="cl">  ├── 1 台空 mapping → fallback
</span></span><span class="line"><span class="ln">20</span><span class="cl">  ├── 明確 mapping 優先匹配
</span></span><span class="line"><span class="ln">21</span><span class="cl">  └── kitchenItemIds 篩選
</span></span><span class="line"><span class="ln">22</span><span class="cl">  （全部透過 printAppendedOrder 驅動，有 FakeReceiptBuilderService）
</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">PrintCenter 廚房印表機管理（5 tests）
</span></span><span class="line"><span class="ln">25</span><span class="cl">  └── 註冊、移除、初始化、向後兼容</span></span></code></pre></div><hr>
<h2 id="最終的修復">最終的修復</h2>
<table>
  <thead>
      <tr>
          <th>Bug</th>
          <th>修復方式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 1</td>
          <td>模擬印表機的初始化方法補上內部元件的建立</td>
      </tr>
      <tr>
          <td>Bug 2</td>
          <td>品項分派的 fallback 邏輯改為：唯一一台無對應表 → 全部給它，多台 → 依規則分配</td>
      </tr>
      <tr>
          <td>Bug 3</td>
          <td>多欄表格列印前，自動將欄位比例正規化為符合 library 要求的總和 12</td>
      </tr>
      <tr>
          <td>Bug 4</td>
          <td>文字列印前加入空字串檢查，遇到空字串改用換行指令繞過 library 的問題</td>
      </tr>
      <tr>
          <td>設計改善</td>
          <td>定義 <code>PrinterException</code>，列印方法改為只攔截印表機故障，程式碼 bug 不再被靜默吞掉</td>
      </tr>
  </tbody>
</table>
]]></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>