<?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>Coverage on Tarragon</title><link>https://tarrragon.github.io/blog/tags/coverage/</link><description>Recent content in Coverage on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/coverage/index.xml" rel="self" type="application/rss+xml"/><item><title>Widget test 的狀態覆蓋策略</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/</guid><description>&lt;p>Widget test 的狀態覆蓋策略是用&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>）作為 test case 的來源。矩陣的每一行（一個狀態）對應至少一個 test case，矩陣的每一欄（顯示 / 可用操作 / 退出路徑）對應該 test case 中的斷言。&lt;/p>
&lt;h2 id="從矩陣到-test-case-的轉換規則">從矩陣到 test case 的轉換規則&lt;/h2>
&lt;h3 id="每個狀態至少一個-test-case">每個狀態至少一個 test case&lt;/h3>
&lt;p>矩陣中的每一行代表畫面的一個狀態。每個狀態產生一個 test case，驗證三件事：&lt;/p>
&lt;ol>
&lt;li>該狀態下的顯示元素是否存在&lt;/li>
&lt;li>該狀態下的可用操作是否可觸發&lt;/li>
&lt;li>該狀態下的退出路徑是否可到達&lt;/li>
&lt;/ol>
&lt;p>以 app_tunnel Terminal 畫面為例，五個狀態產生五個 test case：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;idle state shows blank and allows cancel&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">idle&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">CircularProgressIndicator&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsNothing&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;cancel_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;error state shows message, retry, and back&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;連線失敗&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;retry_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="退出路徑是獨立的斷言">退出路徑是獨立的斷言&lt;/h3>
&lt;p>退出路徑驗證的是「使用者能否離開當前狀態」。斷言方式是 tap 退出按鈕後驗證導航是否發生：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;error state back button navigates to home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&lt;/span>&lt;span class="p">)));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>矩陣中退出路徑為空的狀態 = 沒有退出路徑的 test case = UX 死胡同。如果在填寫 test case 時發現某個狀態沒有退出路徑可以斷言，這本身就是設計缺口的發現。&lt;/p>
&lt;h2 id="覆蓋率的衡量">覆蓋率的衡量&lt;/h2>
&lt;p>Widget test 的狀態覆蓋率 = 有 test case 的狀態數 / 矩陣中的總狀態數。100% 代表矩陣中每個狀態都有對應的 test case。&lt;/p>
&lt;p>狀態覆蓋率和 line coverage 衡量不同的東西。Line coverage 衡量「程式碼中有多少行被執行過」，狀態覆蓋率衡量「設計中有多少狀態被驗證過」。一個狀態的 test case 可能覆蓋很少的程式碼行（只驗證特定狀態下的 UI），但確認了該狀態的設計意圖被正確實作。&lt;/p>
&lt;h2 id="狀態轉換的-test">狀態轉換的 test&lt;/h2>
&lt;p>除了靜態狀態的驗證，狀態之間的轉換也需要 test。矩陣的「進入條件」欄定義了觸發轉換的事件。&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;connecting transitions to connected on ws success&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">terminalScreen&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">state:&lt;/span> &lt;span class="n">connecting&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 模擬 WebSocket 連線成功
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="n">connectionManager&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">emit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ConnectionState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">connected&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TerminalView&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>狀態轉換 test 的數量 = 矩陣中的狀態轉換邊數。五個狀態的畫面可能有 8-12 條轉換邊，每條邊一個 test case。&lt;/p>
&lt;p>狀態覆蓋和轉換覆蓋確認畫面的邏輯正確性後，&lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test&lt;/a> 進一步驗證 back 按鈕和 route 可達性。矩陣本身的填寫方法和四欄定義見 &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣&lt;/a>。如果需要在視覺層面確認 UI 呈現的一致性，&lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對&lt;/a>提供 visual regression 的實作方式。&lt;/p></description><content:encoded><![CDATA[<p>Widget test 的狀態覆蓋策略是用<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>（<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一</a>）作為 test case 的來源。矩陣的每一行（一個狀態）對應至少一個 test case，矩陣的每一欄（顯示 / 可用操作 / 退出路徑）對應該 test case 中的斷言。</p>
<h2 id="從矩陣到-test-case-的轉換規則">從矩陣到 test case 的轉換規則</h2>
<h3 id="每個狀態至少一個-test-case">每個狀態至少一個 test case</h3>
<p>矩陣中的每一行代表畫面的一個狀態。每個狀態產生一個 test case，驗證三件事：</p>
<ol>
<li>該狀態下的顯示元素是否存在</li>
<li>該狀態下的可用操作是否可觸發</li>
<li>該狀態下的退出路徑是否可到達</li>
</ol>
<p>以 app_tunnel Terminal 畫面為例，五個狀態產生五個 test case：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;idle state shows blank and allows cancel&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">idle</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">CircularProgressIndicator</span><span class="p">),</span> <span class="n">findsNothing</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;cancel_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;error state shows message, retry, and back&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">error</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;連線失敗&#39;</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;retry_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="退出路徑是獨立的斷言">退出路徑是獨立的斷言</h3>
<p>退出路徑驗證的是「使用者能否離開當前狀態」。斷言方式是 tap 退出按鈕後驗證導航是否發生：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;error state back button navigates to home&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">error</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>矩陣中退出路徑為空的狀態 = 沒有退出路徑的 test case = UX 死胡同。如果在填寫 test case 時發現某個狀態沒有退出路徑可以斷言，這本身就是設計缺口的發現。</p>
<h2 id="覆蓋率的衡量">覆蓋率的衡量</h2>
<p>Widget test 的狀態覆蓋率 = 有 test case 的狀態數 / 矩陣中的總狀態數。100% 代表矩陣中每個狀態都有對應的 test case。</p>
<p>狀態覆蓋率和 line coverage 衡量不同的東西。Line coverage 衡量「程式碼中有多少行被執行過」，狀態覆蓋率衡量「設計中有多少狀態被驗證過」。一個狀態的 test case 可能覆蓋很少的程式碼行（只驗證特定狀態下的 UI），但確認了該狀態的設計意圖被正確實作。</p>
<h2 id="狀態轉換的-test">狀態轉換的 test</h2>
<p>除了靜態狀態的驗證，狀態之間的轉換也需要 test。矩陣的「進入條件」欄定義了觸發轉換的事件。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">testWidgets</span><span class="p">(</span><span class="s1">&#39;connecting transitions to connected on ws success&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">terminalScreen</span><span class="p">(</span><span class="nl">state:</span> <span class="n">connecting</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// 模擬 WebSocket 連線成功
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="n">connectionManager</span><span class="p">.</span><span class="n">emit</span><span class="p">(</span><span class="n">ConnectionState</span><span class="p">.</span><span class="n">connected</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalView</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>狀態轉換 test 的數量 = 矩陣中的狀態轉換邊數。五個狀態的畫面可能有 8-12 條轉換邊，每條邊一個 test case。</p>
<p>狀態覆蓋和轉換覆蓋確認畫面的邏輯正確性後，<a href="/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test</a> 進一步驗證 back 按鈕和 route 可達性。矩陣本身的填寫方法和四欄定義見 <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣</a>。如果需要在視覺層面確認 UI 呈現的一致性，<a href="/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對</a>提供 visual regression 的實作方式。</p>
]]></content:encoded></item><item><title>反模式：用 mock 數量彌補 mock 盲區</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/anti-pattern-mock-quantity/</guid><description>&lt;p>當 mock test 全過但實機出問題時，常見的第一反應是「test 不夠多」或「覆蓋率不夠高」。這個反應假設 mock test 的問題在數量，而實際上問題在層級 — mock test 驗證的對象和實機暴露的問題不在同一層。增加 mock test 數量擴展的是同一層的覆蓋範圍，不會跨越到另一層。&lt;/p>
&lt;h2 id="數量與層級的區別">數量與層級的區別&lt;/h2>
&lt;p>app_tunnel 的 192 個 unit test 覆蓋了 &lt;code>ConnectionManager&lt;/code>、&lt;code>AnsiParser&lt;/code>、&lt;code>TerminalBuffer&lt;/code> 等元件的邏輯分支。如果在 mock test 全過但實機失敗後，反應是「再寫 50 個 test」，新寫的 test 會使用同一個 &lt;code>FakeWebSocketChannel&lt;/code>，測試更多的邏輯分支 — 更多的輸入組合、更多的邊界條件、更多的錯誤處理路徑。&lt;/p>
&lt;p>這 50 個新 test 和原來的 192 個 test 在同一個 mock 環境中執行，受到同一個 &lt;code>FakeWebSocketChannel&lt;/code> 的行為限制。&lt;code>FakeWebSocketChannel&lt;/code> 不區分 text frame 和 binary frame — 這個限制在第 1 個 test 和第 242 個 test 中都一樣。數量增加了，遮蔽範圍沒有改變。&lt;/p>
&lt;p>用類比說明：用純水測試淨水器的過濾效果，不管測 1 杯還是 1000 杯，結論都是「水很乾淨」。問題在測試材料 — 需要用含有雜質的水測試才能驗證過濾功能。Mock 是純水，真實服務互動是含雜質的水。&lt;/p>
&lt;h2 id="覆蓋率指標的盲點">覆蓋率指標的盲點&lt;/h2>
&lt;p>Line coverage 和 branch coverage 衡量的是「程式碼中有多少行 / 分支被 test 執行過」。這些指標在同一層 test 內有意義 — 100% branch coverage 的 unit test 確保每個 if/else 都被走過。&lt;/p>
&lt;p>但覆蓋率指標不區分 test 的依賴環境。一個使用 &lt;code>FakeWebSocketChannel&lt;/code> 的 test 和一個使用 &lt;code>IOWebSocketChannel&lt;/code> 的 test 走過同一行 &lt;code>sink.add(data)&lt;/code> — 在覆蓋率報告中是同一行被覆蓋，但驗證的語意完全不同。&lt;/p>
&lt;p>覆蓋率 100% 意味著「在 mock 環境中，所有程式碼分支都被走過」。這不等於「在真實環境中，所有程式碼分支的行為都是正確的」。app_tunnel 的 &lt;code>sendData()&lt;/code> 在覆蓋率報告中是「已覆蓋」的，但覆蓋它的 test 用的是不區分 frame type 的 fake。&lt;/p>
&lt;h2 id="這個反模式如何在團隊中擴散">這個反模式如何在團隊中擴散&lt;/h2>
&lt;p>「test 不夠多」是一個容易執行、容易衡量的回應。在沒有獨立 QA 驗收流程的團隊中（覆蓋率報告是主要品質指標），寫更多 test 可以提高覆蓋率數字，覆蓋率數字上升給團隊信心。相比之下，「需要一個新的 test 層級」需要建置新的 test 環境、學習不同的 test 技術、接受較慢的執行速度。&lt;/p>
&lt;p>這個成本差異讓團隊傾向於在既有的 mock test 層加量，而非引入新的 test 層。每一輪加量後覆蓋率上升，團隊信心增加，但 mock 遮蔽的盲區從未被觸及。問題在下一次實機測試或 production incident 中再次浮現，觸發新一輪的「test 不夠多」反應。&lt;/p>
&lt;p>打破這個循環的起點是區分「同層覆蓋率不足」和「層級缺失」。如果問題是同層覆蓋率不足（某個分支沒被 test 走到），加 test 有效。如果問題是層級缺失（mock 結構性地遮蔽了某類行為），加同一層的 test 無效，需要引入新的 test 層級。&lt;/p>
&lt;h2 id="判讀訊號">判讀訊號&lt;/h2>
&lt;p>以下訊號指向「層級缺失」而非「數量不足」：&lt;/p>
&lt;p>&lt;strong>test 全過但實機失敗的 bug 類型集中在外部互動&lt;/strong>：連線問題、認證問題、資料格式問題、逾時問題 — 這些問題的共同特徵是發生在程式碼與外部服務的邊界上，不是程式碼內部的邏輯錯誤。&lt;/p>
&lt;p>&lt;strong>修復後原有 test 不需要改動&lt;/strong>：如果 bug 修復只加了新程式碼（例如新增 auth handshake 步驟）而原有 test 全部不受影響，說明原有 test 從一開始就沒有覆蓋這個行為 — 整個 test 層級不涵蓋這類行為。&lt;/p>
&lt;p>&lt;strong>bug 修復是型別轉換或編碼調整&lt;/strong>：&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code> 這類修復改變的是資料在協議層的表現，不是程式邏輯。在 mock 環境中，這個修改前後的行為完全相同 — mock 不區分 frame type。&lt;/p>
&lt;p>區分「同層覆蓋率不足」和「層級缺失」之後，回到&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層定義與職責表&lt;/a>確認每層的邊界。Mock 遮蔽的結構性原因在 &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析&lt;/a>中展開。如果判斷結果是層級缺失，&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test&lt;/a> 提供引入新層級的決策流程。&lt;/p></description><content:encoded><![CDATA[<p>當 mock test 全過但實機出問題時，常見的第一反應是「test 不夠多」或「覆蓋率不夠高」。這個反應假設 mock test 的問題在數量，而實際上問題在層級 — mock test 驗證的對象和實機暴露的問題不在同一層。增加 mock test 數量擴展的是同一層的覆蓋範圍，不會跨越到另一層。</p>
<h2 id="數量與層級的區別">數量與層級的區別</h2>
<p>app_tunnel 的 192 個 unit test 覆蓋了 <code>ConnectionManager</code>、<code>AnsiParser</code>、<code>TerminalBuffer</code> 等元件的邏輯分支。如果在 mock test 全過但實機失敗後，反應是「再寫 50 個 test」，新寫的 test 會使用同一個 <code>FakeWebSocketChannel</code>，測試更多的邏輯分支 — 更多的輸入組合、更多的邊界條件、更多的錯誤處理路徑。</p>
<p>這 50 個新 test 和原來的 192 個 test 在同一個 mock 環境中執行，受到同一個 <code>FakeWebSocketChannel</code> 的行為限制。<code>FakeWebSocketChannel</code> 不區分 text frame 和 binary frame — 這個限制在第 1 個 test 和第 242 個 test 中都一樣。數量增加了，遮蔽範圍沒有改變。</p>
<p>用類比說明：用純水測試淨水器的過濾效果，不管測 1 杯還是 1000 杯，結論都是「水很乾淨」。問題在測試材料 — 需要用含有雜質的水測試才能驗證過濾功能。Mock 是純水，真實服務互動是含雜質的水。</p>
<h2 id="覆蓋率指標的盲點">覆蓋率指標的盲點</h2>
<p>Line coverage 和 branch coverage 衡量的是「程式碼中有多少行 / 分支被 test 執行過」。這些指標在同一層 test 內有意義 — 100% branch coverage 的 unit test 確保每個 if/else 都被走過。</p>
<p>但覆蓋率指標不區分 test 的依賴環境。一個使用 <code>FakeWebSocketChannel</code> 的 test 和一個使用 <code>IOWebSocketChannel</code> 的 test 走過同一行 <code>sink.add(data)</code> — 在覆蓋率報告中是同一行被覆蓋，但驗證的語意完全不同。</p>
<p>覆蓋率 100% 意味著「在 mock 環境中，所有程式碼分支都被走過」。這不等於「在真實環境中，所有程式碼分支的行為都是正確的」。app_tunnel 的 <code>sendData()</code> 在覆蓋率報告中是「已覆蓋」的，但覆蓋它的 test 用的是不區分 frame type 的 fake。</p>
<h2 id="這個反模式如何在團隊中擴散">這個反模式如何在團隊中擴散</h2>
<p>「test 不夠多」是一個容易執行、容易衡量的回應。在沒有獨立 QA 驗收流程的團隊中（覆蓋率報告是主要品質指標），寫更多 test 可以提高覆蓋率數字，覆蓋率數字上升給團隊信心。相比之下，「需要一個新的 test 層級」需要建置新的 test 環境、學習不同的 test 技術、接受較慢的執行速度。</p>
<p>這個成本差異讓團隊傾向於在既有的 mock test 層加量，而非引入新的 test 層。每一輪加量後覆蓋率上升，團隊信心增加，但 mock 遮蔽的盲區從未被觸及。問題在下一次實機測試或 production incident 中再次浮現，觸發新一輪的「test 不夠多」反應。</p>
<p>打破這個循環的起點是區分「同層覆蓋率不足」和「層級缺失」。如果問題是同層覆蓋率不足（某個分支沒被 test 走到），加 test 有效。如果問題是層級缺失（mock 結構性地遮蔽了某類行為），加同一層的 test 無效，需要引入新的 test 層級。</p>
<h2 id="判讀訊號">判讀訊號</h2>
<p>以下訊號指向「層級缺失」而非「數量不足」：</p>
<p><strong>test 全過但實機失敗的 bug 類型集中在外部互動</strong>：連線問題、認證問題、資料格式問題、逾時問題 — 這些問題的共同特徵是發生在程式碼與外部服務的邊界上，不是程式碼內部的邏輯錯誤。</p>
<p><strong>修復後原有 test 不需要改動</strong>：如果 bug 修復只加了新程式碼（例如新增 auth handshake 步驟）而原有 test 全部不受影響，說明原有 test 從一開始就沒有覆蓋這個行為 — 整個 test 層級不涵蓋這類行為。</p>
<p><strong>bug 修復是型別轉換或編碼調整</strong>：<code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code> 這類修復改變的是資料在協議層的表現，不是程式邏輯。在 mock 環境中，這個修改前後的行為完全相同 — mock 不區分 frame type。</p>
<p>區分「同層覆蓋率不足」和「層級缺失」之後，回到<a href="/blog/testing/01-test-strategy-layers/three-layer-definition/" data-link-title="三層定義與職責表" data-link-desc="Unit Test / Protocol Integration Test / Screen State Test 各層職責、驗證目標與盲區的完整論述">三層定義與職責表</a>確認每層的邊界。Mock 遮蔽的結構性原因在 <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a>中展開。如果判斷結果是層級缺失，<a href="/blog/testing/01-test-strategy-layers/when-protocol-integration-test/" data-link-title="判斷原則：什麼時候需要 protocol integration test" data-link-desc="從服務架構特徵判斷是否需要 protocol integration test 的決策流程 — 協議複雜度、mock 寬鬆度、失敗靜默度三個維度">判斷原則：什麼時候需要 protocol integration test</a> 提供引入新層級的決策流程。</p>
]]></content:encoded></item></channel></rss>