<?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>Fake-Backend on Tarragon</title><link>https://tarrragon.github.io/blog/tags/fake-backend/</link><description>Recent content in Fake-Backend on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Mon, 20 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/fake-backend/index.xml" rel="self" type="application/rss+xml"/><item><title>流程測試基礎設施</title><link>https://tarrragon.github.io/blog/flutter/flow-test-infrastructure/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/flutter/flow-test-infrastructure/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>驅動的是跨服務的真實編排，&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>從策略層回答了「什麼時候值得建假後端、流程測試驗證什麼」；本章處理拿著策略在 Dart/Flutter 生態落地時碰到的實作限制。四個限制形成一條建置鏈：前一個的答案決定後一個的形態，跳著解會繞遠路。&lt;/p>
&lt;h2 id="閘門-spike編排的宿主能不能在-headless-環境立起來">閘門 spike：編排的宿主能不能在 headless 環境立起來&lt;/h2>
&lt;p>流程測試要驅動的是控制器裡的真實編排——順序約束、防護動作、收尾同步——而 Flutter 的控制器在 &lt;code>onInit&lt;/code> 常帶平台耦合（platform channel 訂閱、相機偵測、外接裝置 plugin）。這些耦合在 headless 測試環境全部失效。&lt;/p>
&lt;p>開工前先做一條最小測試 spike：能否建構控制器並呼叫編排入口。spike 的答案決定整個套件形態——立得起來就直接驅動真實編排（零漂移），立不起來要先評估重構接縫的成本。&lt;/p>
&lt;p>平台耦合的中和工具在 Dart 生態有固定的對應：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>耦合類型&lt;/th>
 &lt;th>中和手段&lt;/th>
 &lt;th>時序要求&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>EventChannel（plugin 建構子就訂閱）&lt;/td>
 &lt;td>&lt;code>setMockStreamHandler&lt;/code> 掛空 handler&lt;/td>
 &lt;td>在&lt;strong>建構 plugin 物件之前&lt;/strong>掛好&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>MethodChannel（查詢類呼叫）&lt;/td>
 &lt;td>&lt;code>setMockMethodCallHandler&lt;/code> 回空結果&lt;/td>
 &lt;td>在&lt;strong>呼叫發生之前&lt;/strong>掛好&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>服務級平台依賴（掃碼、藍牙）&lt;/td>
 &lt;td>手寫 no-op 子類覆寫碰平台的成員&lt;/td>
 &lt;td>在 DI 容器註冊子類取代原服務&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>addPostFrameCallback&lt;/code> 承載的初始化&lt;/td>
 &lt;td>測試裡手動呼叫同一個方法&lt;/td>
 &lt;td>控制器建構後、斷言前&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>時序欄位承載的是這張表最容易踩的陷阱：EventChannel 的訂閱發生在 plugin 建構子裡，晚一步掛 mock，訂閱已經對著真實 channel 成立、之後補掛不會回溯生效——症狀是測試偶發卡在等事件。MethodChannel 的容錯高一些（呼叫當下才查 handler），但同樣要在第一次呼叫前就位。no-op 子類優於 mock 框架的場景：要中和的成員少（覆寫列表一目瞭然）、其餘行為要保留真實。流程測試的精神是假件越少，測試的證言越可信——這裡指的是平台耦合層的假件（與被驗編排無關的雜訊），被測邊界本身的假後端是另一回事，它的設計判準與忠實性把關見&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端&lt;/a>。&lt;/p>
&lt;p>這套 channel mock 加 no-op 子類的組合（後文稱 harness）一旦成立就收斂為共用 bootstrap 方法——之後每條流程測試的邊際成本只剩劇本本身。完整 case 與程式碼範例：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來&lt;/a>。&lt;/p>
&lt;h2 id="兩種測試的共存binding-的檔案級-isolate-隔離">兩種測試的共存：binding 的檔案級 isolate 隔離&lt;/h2>
&lt;p>流程測試需要 &lt;code>TestWidgetsFlutterBinding&lt;/code>（mock channel、立控制器），真實後端驗證測試需要真實網路——兩者互斥。&lt;code>TestWidgetsFlutterBinding.ensureInitialized()&lt;/code> 的副作用之一是把 &lt;code>dart:io&lt;/code> 的 &lt;code>HttpClient&lt;/code> 換成一律回 400 的假件，且這個副作用是程序級全域、沒有乾淨的關閉開關。&lt;/p>
&lt;p>共存機制不需要額外設計：&lt;code>flutter test&lt;/code> 讓每個測試檔案跑在獨立 isolate，而 isolate 之間記憶體不共享——binding 換掉的全域物件只在自己的 isolate 內生效，副作用因此以檔案為邊界。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>流程測試檔&lt;/th>
 &lt;th>真實後端驗證檔&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>binding&lt;/td>
 &lt;td>&lt;code>ensureInitialized()&lt;/code>&lt;/td>
 &lt;td>不初始化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>HttpClient&lt;/td>
 &lt;td>假件（走假後端 adapter）&lt;/td>
 &lt;td>真實（需要）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>隔離邊界&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;td>檔案自己的 isolate&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>硬約束：真實後端驗證測試的檔案&lt;strong>不可 import 流程測試的 harness&lt;/strong>——harness 為了 mock channel 第一步就是 &lt;code>ensureInitialized&lt;/code>，import 進來即使不直接呼叫，任何共用 helper 順手初始化都會中招。違反這條約束的症狀是「穩定 400、無網路痕跡」——症狀與原因之間距離太遠，值得寫進檔頭。&lt;/p>
&lt;p>真實後端驗證檔裡繞開產品 DI 的做法：手組最小可用的 &lt;code>Dio&lt;/code>，請求與解析仍走產品的 API client 與模型（型別化解析層共用、不手寫 JSON）。完整機制與程式碼：&lt;a href="https://tarrragon.github.io/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">TestWidgetsFlutterBinding 會擋掉真實網路&lt;/a>。&lt;/p>
&lt;h2 id="輸出雜訊治理預期環境狀態的正確處理路徑">輸出雜訊治理：預期環境狀態的正確處理路徑&lt;/h2>
&lt;p>harness 立起後，測試輸出可能固定印出幾行「錯誤長相」的文字——相機偵測的 &lt;code>MissingPluginException&lt;/code>、toast 套件的 assert fallback。這些在生產環境是正確的防護路徑，在測試環境是必然觸發的假警報。假警報訓練人忽略輸出，新的真警報混在裡面就被同一個心理過濾器吃掉。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>驅動的是跨服務的真實編排，<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>從策略層回答了「什麼時候值得建假後端、流程測試驗證什麼」；本章處理拿著策略在 Dart/Flutter 生態落地時碰到的實作限制。四個限制形成一條建置鏈：前一個的答案決定後一個的形態，跳著解會繞遠路。</p>
<h2 id="閘門-spike編排的宿主能不能在-headless-環境立起來">閘門 spike：編排的宿主能不能在 headless 環境立起來</h2>
<p>流程測試要驅動的是控制器裡的真實編排——順序約束、防護動作、收尾同步——而 Flutter 的控制器在 <code>onInit</code> 常帶平台耦合（platform channel 訂閱、相機偵測、外接裝置 plugin）。這些耦合在 headless 測試環境全部失效。</p>
<p>開工前先做一條最小測試 spike：能否建構控制器並呼叫編排入口。spike 的答案決定整個套件形態——立得起來就直接驅動真實編排（零漂移），立不起來要先評估重構接縫的成本。</p>
<p>平台耦合的中和工具在 Dart 生態有固定的對應：</p>
<table>
  <thead>
      <tr>
          <th>耦合類型</th>
          <th>中和手段</th>
          <th>時序要求</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>EventChannel（plugin 建構子就訂閱）</td>
          <td><code>setMockStreamHandler</code> 掛空 handler</td>
          <td>在<strong>建構 plugin 物件之前</strong>掛好</td>
      </tr>
      <tr>
          <td>MethodChannel（查詢類呼叫）</td>
          <td><code>setMockMethodCallHandler</code> 回空結果</td>
          <td>在<strong>呼叫發生之前</strong>掛好</td>
      </tr>
      <tr>
          <td>服務級平台依賴（掃碼、藍牙）</td>
          <td>手寫 no-op 子類覆寫碰平台的成員</td>
          <td>在 DI 容器註冊子類取代原服務</td>
      </tr>
      <tr>
          <td><code>addPostFrameCallback</code> 承載的初始化</td>
          <td>測試裡手動呼叫同一個方法</td>
          <td>控制器建構後、斷言前</td>
      </tr>
  </tbody>
</table>
<p>時序欄位承載的是這張表最容易踩的陷阱：EventChannel 的訂閱發生在 plugin 建構子裡，晚一步掛 mock，訂閱已經對著真實 channel 成立、之後補掛不會回溯生效——症狀是測試偶發卡在等事件。MethodChannel 的容錯高一些（呼叫當下才查 handler），但同樣要在第一次呼叫前就位。no-op 子類優於 mock 框架的場景：要中和的成員少（覆寫列表一目瞭然）、其餘行為要保留真實。流程測試的精神是假件越少，測試的證言越可信——這裡指的是平台耦合層的假件（與被驗編排無關的雜訊），被測邊界本身的假後端是另一回事，它的設計判準與忠實性把關見<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端</a>。</p>
<p>這套 channel mock 加 no-op 子類的組合（後文稱 harness）一旦成立就收斂為共用 bootstrap 方法——之後每條流程測試的邊際成本只剩劇本本身。完整 case 與程式碼範例：<a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">讓 UI 控制器在 headless 測試立起來</a>。</p>
<h2 id="兩種測試的共存binding-的檔案級-isolate-隔離">兩種測試的共存：binding 的檔案級 isolate 隔離</h2>
<p>流程測試需要 <code>TestWidgetsFlutterBinding</code>（mock channel、立控制器），真實後端驗證測試需要真實網路——兩者互斥。<code>TestWidgetsFlutterBinding.ensureInitialized()</code> 的副作用之一是把 <code>dart:io</code> 的 <code>HttpClient</code> 換成一律回 400 的假件，且這個副作用是程序級全域、沒有乾淨的關閉開關。</p>
<p>共存機制不需要額外設計：<code>flutter test</code> 讓每個測試檔案跑在獨立 isolate，而 isolate 之間記憶體不共享——binding 換掉的全域物件只在自己的 isolate 內生效，副作用因此以檔案為邊界。</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>流程測試檔</th>
          <th>真實後端驗證檔</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>binding</td>
          <td><code>ensureInitialized()</code></td>
          <td>不初始化</td>
      </tr>
      <tr>
          <td>HttpClient</td>
          <td>假件（走假後端 adapter）</td>
          <td>真實（需要）</td>
      </tr>
      <tr>
          <td>隔離邊界</td>
          <td>檔案自己的 isolate</td>
          <td>檔案自己的 isolate</td>
      </tr>
  </tbody>
</table>
<p>硬約束：真實後端驗證測試的檔案<strong>不可 import 流程測試的 harness</strong>——harness 為了 mock channel 第一步就是 <code>ensureInitialized</code>，import 進來即使不直接呼叫，任何共用 helper 順手初始化都會中招。違反這條約束的症狀是「穩定 400、無網路痕跡」——症狀與原因之間距離太遠，值得寫進檔頭。</p>
<p>真實後端驗證檔裡繞開產品 DI 的做法：手組最小可用的 <code>Dio</code>，請求與解析仍走產品的 API client 與模型（型別化解析層共用、不手寫 JSON）。完整機制與程式碼：<a href="/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">TestWidgetsFlutterBinding 會擋掉真實網路</a>。</p>
<h2 id="輸出雜訊治理預期環境狀態的正確處理路徑">輸出雜訊治理：預期環境狀態的正確處理路徑</h2>
<p>harness 立起後，測試輸出可能固定印出幾行「錯誤長相」的文字——相機偵測的 <code>MissingPluginException</code>、toast 套件的 assert fallback。這些在生產環境是正確的防護路徑，在測試環境是必然觸發的假警報。假警報訓練人忽略輸出，新的真警報混在裡面就被同一個心理過濾器吃掉。</p>
<p>治理原則：測試環境 100% 必然成立的觸發條件，用前置判斷或 harness mock 讓它走正常路徑，讓例外接管只剩真正的例外。</p>
<table>
  <thead>
      <tr>
          <th>雜訊類型</th>
          <th>修法</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>平台 plugin 不存在導致的 <code>MissingPluginException</code></td>
          <td>harness 的 channel mock 回空結果（上一節已掛的 mock 同時解決）</td>
      </tr>
      <tr>
          <td>headless 環境沒有 widget tree 導致的 assert fallback</td>
          <td>顯示前前置判斷 <code>rootElement != null</code>，無畫面時跳過顯示</td>
      </tr>
  </tbody>
</table>
<p>前置判斷放在 <code>runZonedGuarded</code> 的 zone 內而非外——極端環境（binding 未初始化）連 <code>WidgetsBinding.instance</code> 都會拋，放 zone 內讓它落入兜底。分工因此變乾淨：前置判斷處理已知的環境狀態，zone 兜底處理未知的失敗。完整案例與判準：<a href="/blog/work-log/flutter_test_noise_expected_paths/" data-link-title="測試輸出的雜訊治理：預期的環境狀態不該走例外路徑" data-link-desc="測試輸出長期印著兩行「已知無害」的錯誤——相機偵測 MissingPluginException、toast 套件的 assert fallback。已知雜訊會訓練人忽略輸出，新警報混在裡面就被過濾掉。修法是把「預期的環境狀態」變成前置判斷（channel mock 回空、無畫面早退），讓例外路徑只剩真正的例外。">測試輸出的雜訊治理</a>。</p>
<h2 id="假後端的回應序列化物件--tojson不是手寫-json">假後端的回應序列化：物件 → toJson，不是手寫 JSON</h2>
<p>有狀態假後端的回應資料從哪裡來，是 Dart 生態特有的選型：freezed 模型天生雙向（<code>fromJson</code>/<code>toJson</code>），假後端持有模型物件、出口一律 <code>toJson()</code>，服務層走的反序列化路徑與生產環境完全相同。</p>
<p>手寫 JSON 樣板跳過這個閉環的前半段——樣板對不對靠人眼比對 API 文件。實際案例：同一批測試裡的 raw 寫法重踩了產品早已內建處理的分頁包裝（信封解析層的知識被重複實作在測試 helper 裡），改走產品的 API client 與模型後，helper 連同它代表的重複知識一起刪除。</p>
<p>判準一句話：<strong>回應形狀的知識只該存在一份</strong>。測試裡出現 <code>data['data']</code> 之類手挖回應欄位的 helper，就是重複知識的訊號。</p>
<p>假後端的狀態演變用 <code>copyWith</code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。toJson 閉環的忠實性由配對的<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>把關——模型的 <code>fromJson</code>/<code>toJson</code> 若與真實後端不對稱，會在那裡先於產品爆出來。完整做法與對照組事故：<a href="/blog/work-log/flutter_fake_backend_real_model_serialization/" data-link-title="有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題" data-link-desc="流程測試的假後端持有 freezed 模型物件、以 toJson 序列化回應，讓服務層走完整的反序列化鏈。對照組是手寫 JSON fixture——同一批測試裡的 raw 寫法重踩了一次產品早已內建處理的分頁包裝，證明「回應形狀的知識」應該只存在一份。">有狀態假後端用真實模型序列化回應</a>。</p>
<h2 id="建置順序">建置順序</h2>
<p>四個限制的處理有先後依賴：</p>
<ol>
<li><strong>spike</strong>：一條最小測試驗證控制器能否在 headless 環境建構並呼叫編排入口</li>
<li><strong>harness 收斂</strong>：spike 過了之後，把 channel mock + no-op 子類 + postFrameCallback 補位收進共用 bootstrap</li>
<li><strong>檔案隔離規劃</strong>：真實後端驗證測試另開檔案、不 import harness、不初始化 binding</li>
<li><strong>雜訊治理</strong>：harness 的 channel mock 順便解決大部分雜訊；剩餘的 headless 環境假警報加前置判斷</li>
<li><strong>假後端序列化</strong>：以 freezed 模型持有狀態、toJson 出口，seed builder 集中維護初始狀態</li>
<li><strong>第一條劇本</strong>：走完一段跨服務業務旅程，斷言散佈在各階段的可觀察結果上</li>
</ol>
<p>步驟 1 決定後續全部形態——立不起來就回到策略層重新評估（<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>的「可測性閘門」段）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>策略層的完整判準（什麼時候值得建假後端）→ <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>配對的真實後端驗證（假後端行為漂移的防線）→ <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>憑證的存放與 CI 注入 → <a href="/blog/testing/03-protocol-integration-test/credential-management/" data-link-title="測試憑證管理" data-link-desc="測試環境的帳號密碼放在哪裡、CI 怎麼拿到、怎麼防止對生產環境執行 — 存放策略的適用前提與失效偵測">測試憑證管理</a></li>
<li>各限制的完整 case：<a href="/blog/work-log/flutter_headless_controller_test_bootstrap/" data-link-title="讓 UI 控制器在 headless 測試立起來：platform channel mock、no-op 子類與 postFrameCallback 的手工補位" data-link-desc="流程測試要驅動真實編排，而編排住在 UI 控制器裡——能不能在無畫面的測試環境把控制器立起來，決定整個測試套件的形態。先用 spike 驗證閘門、再逐項中和平台耦合，讓控制器在 headless 環境可建構。">headless 控制器</a>、<a href="/blog/work-log/flutter_test_binding_blocks_real_network/" data-link-title="TestWidgetsFlutterBinding 會擋掉真實網路：真實後端測試與流程測試的檔案級隔離" data-link-desc="flutter_test 的 binding 初始化後會把 HttpClient 換成回 400 的假件——需要真實網路的後端驗證測試不可初始化 binding，也因此不可 import 任何會初始化 binding 的 harness。靠測試檔案各自跑在獨立 isolate 的特性，兩種測試在同一個目錄共存。">binding 互斥</a>、<a href="/blog/work-log/flutter_test_noise_expected_paths/" data-link-title="測試輸出的雜訊治理：預期的環境狀態不該走例外路徑" data-link-desc="測試輸出長期印著兩行「已知無害」的錯誤——相機偵測 MissingPluginException、toast 套件的 assert fallback。已知雜訊會訓練人忽略輸出，新警報混在裡面就被過濾掉。修法是把「預期的環境狀態」變成前置判斷（channel mock 回空、無畫面早退），讓例外路徑只剩真正的例外。">雜訊治理</a>、<a href="/blog/work-log/flutter_fake_backend_real_model_serialization/" data-link-title="有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題" data-link-desc="流程測試的假後端持有 freezed 模型物件、以 toJson 序列化回應，讓服務層走完整的反序列化鏈。對照組是手寫 JSON fixture——同一批測試裡的 raw 寫法重踩了一次產品早已內建處理的分頁包裝，證明「回應形狀的知識」應該只存在一份。">假後端序列化</a></li>
</ul>
]]></content:encoded></item><item><title>Semantic Fake Backend（語意級假後端）</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/semantic-fake-backend/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/semantic-fake-backend/</guid><description>&lt;p>語意級假後端是一個持有狀態、只固化已證實後端行為的測試假件：多個前端服務對它走完整的互動鏈，每個操作演變它內部的狀態。在 test double 分類中它對應 Fowler 定義的 fake（有狀態、可運作的簡化實作），「語意級」限定的是行為出處——每一條行為都經過實測證實。它是&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>的地基，與&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/real-backend-verification-test/" data-link-title="Real-Backend Verification Test（真實後端驗證測試）" data-link-desc="對共用測試環境常駐斷言後端業務行為的測試；紅、綠、跳過各有語意，承接假後端固化行為的漂移警報">真實後端驗證測試&lt;/a>配對運作。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>與「由測試餵資料的 stub」的分界有兩條：狀態的歸屬（stub 的回應由測試作者逐條寫死，假後端自己持有狀態並隨操作演變）、行為的出處（stub 回放作者對後端的假設，假後端只收錄實測證實的後端行為）。它與 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽&lt;/a>批評的「讓 mock 更逼真」處在相異層次：假後端的模擬止於應用層行為，協議層仍歸 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">protocol integration test&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>多個前端服務對同一份後端狀態接力，且出現過「對後端行為假設錯誤」型的漏網 bug。典型例：後端合併資料時重建全部子項並更換 id，前端依賴&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/frozen-vs-live-reference/" data-link-title="Frozen vs Live Reference（凍結參照與活解析）" data-link-desc="下游持有上游 id 的兩種策略——寫死 id vs 查詢時動態解引用；上游重建後凍結參照失效，stub 對此結構性盲目">凍結參照&lt;/a>的錯誤在 stub 上永遠測不紅（&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>假後端要決定掛載接縫（in-process 假實作或本地假 server）、行為取證方式、與配對慣例——每一條行為假設對應一條真實後端驗證斷言。掛載判準、取證選單與成本量級的推導在&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>章。&lt;/p></description><content:encoded><![CDATA[<p>語意級假後端是一個持有狀態、只固化已證實後端行為的測試假件：多個前端服務對它走完整的互動鏈，每個操作演變它內部的狀態。在 test double 分類中它對應 Fowler 定義的 fake（有狀態、可運作的簡化實作），「語意級」限定的是行為出處——每一條行為都經過實測證實。它是<a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>的地基，與<a href="/blog/testing/knowledge-cards/real-backend-verification-test/" data-link-title="Real-Backend Verification Test（真實後端驗證測試）" data-link-desc="對共用測試環境常駐斷言後端業務行為的測試；紅、綠、跳過各有語意，承接假後端固化行為的漂移警報">真實後端驗證測試</a>配對運作。</p>
<h2 id="概念位置">概念位置</h2>
<p>與「由測試餵資料的 stub」的分界有兩條：狀態的歸屬（stub 的回應由測試作者逐條寫死，假後端自己持有狀態並隨操作演變）、行為的出處（stub 回放作者對後端的假設，假後端只收錄實測證實的後端行為）。它與 <a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">mock 遮蔽</a>批評的「讓 mock 更逼真」處在相異層次：假後端的模擬止於應用層行為，協議層仍歸 <a href="/blog/testing/knowledge-cards/protocol-integration-test/" data-link-title="Protocol Integration Test" data-link-desc="驗證程式碼和真實外部服務之間的協議互動是否正確的 test 層級">protocol integration test</a>。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>多個前端服務對同一份後端狀態接力，且出現過「對後端行為假設錯誤」型的漏網 bug。典型例：後端合併資料時重建全部子項並更換 id，前端依賴<a href="/blog/testing/knowledge-cards/frozen-vs-live-reference/" data-link-title="Frozen vs Live Reference（凍結參照與活解析）" data-link-desc="下游持有上游 id 的兩種策略——寫死 id vs 查詢時動態解引用；上游重建後凍結參照失效，stub 對此結構性盲目">凍結參照</a>的錯誤在 stub 上永遠測不紅（<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a>）。</p>
<h2 id="設計責任">設計責任</h2>
<p>假後端要決定掛載接縫（in-process 假實作或本地假 server）、行為取證方式、與配對慣例——每一條行為假設對應一條真實後端驗證斷言。掛載判準、取證選單與成本量級的推導在<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>章。</p>
]]></content:encoded></item><item><title>T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞</title><link>https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/</guid><description>&lt;p>&lt;strong>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/stub/" data-link-title="Stub" data-link-desc="測試作者手動寫死回應資料的 test double：驗證的是假設成立時邏輯是否正確，假設本身錯誤時無法檢出">Stub&lt;/a> 回放的是測試作者的假設，假設錯了，測試照樣綠燈&lt;/strong>——這是 stub 的結構性限制。當 bug 的成因是「我們對後端行為的理解錯誤」時，用自己假設餵出來的 stub 永遠驗證不出來。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>前端的本地追蹤記錄裡凍結了兩個後端 id：事件發生當下所屬單據的 id、與明細列的 id。這是一個前後端分離的 APP 專案，前端為後端推送的每個事件建立這樣一筆記錄，後續的「取消」「追加」操作用這兩個凍結 id 回寫後端。&lt;/p>
&lt;p>後端有一個「合併兩張單據」的操作。實際行為是：&lt;strong>建立一張新單據、明細列全部重建（全新 id）、刪除舊單據&lt;/strong>。合併後，本地記錄裡凍結的舊 id 全部指向已刪除的資料——取消操作永遠失敗、追加操作無聲失效。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>影響範圍&lt;/td>
 &lt;td>合併操作後，該單據所有品項的取消與追加功能全部失效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>單元測試結果&lt;/td>
 &lt;td>全部通過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>遮蔽機制&lt;/td>
 &lt;td>測試把「回寫目標存在」的狀態直接餵給 stub，凍結 id 在測試裡永遠有效&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>動作時&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/frozen-vs-live-reference/" data-link-title="Frozen vs Live Reference（凍結參照與活解析）" data-link-desc="下游持有上游 id 的兩種策略——寫死 id vs 查詢時動態解引用；上游重建後凍結參照失效，stub 對此結構性盲目">活解析&lt;/a>：以跨合併不變的事件 id 反查後端資料的當前快照，取得現在有效的 id；凍結值只做查無時的退路&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>凍結 id 的生死由後端決定，stub 卻由前端決定&lt;/strong>。單元測試建立記錄時，順手把對應的單據與明細餵進 stub——凍結 id 與 stub 資料出自同一隻手、恆常一致。真實世界裡讓 id 死亡的是後端的重建行為，stub 沒有這個行為，於是「id 失效」這個狀態在測試裡不可能出現。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>修復方向也受同一個盲區支配&lt;/strong>。第一版修復只搬了單據層的 id（合併後通知本地記錄改掛新單據），沒人想到明細層的 id 也死了——因為測試裡明細 id 從未死過。盲區不只遮蔽 bug，還遮蔽修復的完整性。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>判斷關鍵：哪個 id 跨越操作仍不變&lt;/strong>。事後分析發現後端合併時保留了事件記錄的 id（只改外鍵）。「什麼會變、什麼不變」是後端行為的事實問題，推理與記憶都不算出處——合法的出處是可驗的契約文件、或一次實測取證，然後把證實的行為固化進測試環境。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>對「參照外部資料 id」的功能，測試必須包含「id 失效」的劇本&lt;/strong>。凡是前端持有後端 id 的地方，就要問：後端的哪些操作會讓這個 id 死亡？該劇本進測試。&lt;/li>
&lt;li>&lt;strong>用有狀態的假後端取代由測試餵資料的 stub&lt;/strong>：假後端自己模擬「合併＝重建明細＋換 id」的行為，前端的活解析邏輯就會在測試裡被真正逼出來。做法見&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端&lt;/a>。&lt;/li>
&lt;li>&lt;strong>實測後端行為一次、固化為測試資產&lt;/strong>。「合併後哪些 id 存活」這類問題用一次埋 log 實測定案，結論寫進假後端，之後不再依賴任何人的記憶。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想建立有狀態假後端 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>假後端的行為假設怎麼對真實後端驗證 → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>&lt;/li>
&lt;li>換了測試形態後首跑就抓到問題的實例 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6 流程測試首跑抓到順序 bug&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><strong><a href="/blog/testing/knowledge-cards/stub/" data-link-title="Stub" data-link-desc="測試作者手動寫死回應資料的 test double：驗證的是假設成立時邏輯是否正確，假設本身錯誤時無法檢出">Stub</a> 回放的是測試作者的假設，假設錯了，測試照樣綠燈</strong>——這是 stub 的結構性限制。當 bug 的成因是「我們對後端行為的理解錯誤」時，用自己假設餵出來的 stub 永遠驗證不出來。</p>
<h2 id="觀察">觀察</h2>
<p>前端的本地追蹤記錄裡凍結了兩個後端 id：事件發生當下所屬單據的 id、與明細列的 id。這是一個前後端分離的 APP 專案，前端為後端推送的每個事件建立這樣一筆記錄，後續的「取消」「追加」操作用這兩個凍結 id 回寫後端。</p>
<p>後端有一個「合併兩張單據」的操作。實際行為是：<strong>建立一張新單據、明細列全部重建（全新 id）、刪除舊單據</strong>。合併後，本地記錄裡凍結的舊 id 全部指向已刪除的資料——取消操作永遠失敗、追加操作無聲失效。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>合併操作後，該單據所有品項的取消與追加功能全部失效</td>
      </tr>
      <tr>
          <td>單元測試結果</td>
          <td>全部通過</td>
      </tr>
      <tr>
          <td>遮蔽機制</td>
          <td>測試把「回寫目標存在」的狀態直接餵給 stub，凍結 id 在測試裡永遠有效</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>動作時<a href="/blog/testing/knowledge-cards/frozen-vs-live-reference/" data-link-title="Frozen vs Live Reference（凍結參照與活解析）" data-link-desc="下游持有上游 id 的兩種策略——寫死 id vs 查詢時動態解引用；上游重建後凍結參照失效，stub 對此結構性盲目">活解析</a>：以跨合併不變的事件 id 反查後端資料的當前快照，取得現在有效的 id；凍結值只做查無時的退路</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>凍結 id 的生死由後端決定，stub 卻由前端決定</strong>。單元測試建立記錄時，順手把對應的單據與明細餵進 stub——凍結 id 與 stub 資料出自同一隻手、恆常一致。真實世界裡讓 id 死亡的是後端的重建行為，stub 沒有這個行為，於是「id 失效」這個狀態在測試裡不可能出現。</p>
</li>
<li>
<p><strong>修復方向也受同一個盲區支配</strong>。第一版修復只搬了單據層的 id（合併後通知本地記錄改掛新單據），沒人想到明細層的 id 也死了——因為測試裡明細 id 從未死過。盲區不只遮蔽 bug，還遮蔽修復的完整性。</p>
</li>
<li>
<p><strong>判斷關鍵：哪個 id 跨越操作仍不變</strong>。事後分析發現後端合併時保留了事件記錄的 id（只改外鍵）。「什麼會變、什麼不變」是後端行為的事實問題，推理與記憶都不算出處——合法的出處是可驗的契約文件、或一次實測取證，然後把證實的行為固化進測試環境。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>對「參照外部資料 id」的功能，測試必須包含「id 失效」的劇本</strong>。凡是前端持有後端 id 的地方，就要問：後端的哪些操作會讓這個 id 死亡？該劇本進測試。</li>
<li><strong>用有狀態的假後端取代由測試餵資料的 stub</strong>：假後端自己模擬「合併＝重建明細＋換 id」的行為，前端的活解析邏輯就會在測試裡被真正逼出來。做法見<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端</a>。</li>
<li><strong>實測後端行為一次、固化為測試資產</strong>。「合併後哪些 id 存活」這類問題用一次埋 log 實測定案，結論寫進假後端，之後不再依賴任何人的記憶。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想建立有狀態假後端 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>假後端的行為假設怎麼對真實後端驗證 → <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>換了測試形態後首跑就抓到問題的實例 → <a href="/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6 流程測試首跑抓到順序 bug</a></li>
</ul>
]]></content:encoded></item><item><title>T.C6 流程測試首跑抓到修復自己引入的順序 bug</title><link>https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/</guid><description>&lt;p>修復跨服務互動 bug 後，新建的&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>首跑就抓到修復自己引入的順序錯誤——單元測試綠燈、流程測試紅燈，紅燈才是對的。單元測試為了聚焦而繞過的路徑，正是跨服務互動 bug 藏身的地方。前情：&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a> 的修復合入後觸發了此問題。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>這個系統的前端以輪詢讀取後端的事件流，把新事件轉成列印輸出；後端的「合併兩張單據」操作會重建明細、更換全部明細 id。&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a> 的修復針對後者：合併後前端要「立即刷新一次快照」，讓後續操作拿到重建後的新明細 id。修復者把刷新放在「同步單據列表」之前。&lt;/p>
&lt;p>這個順序踩中輪詢的兩道機制。第一道是過濾：多店環境下，後端事件流混有其他店的資料，前端以本地單據列表為準濾掉非本店的部分。合併後的瞬間，本地列表還停在舊單據——新單據的資料被&lt;strong>整批濾掉&lt;/strong>，刷新等於白做。第二道是差異比對：輪詢以上一輪結果為基準，只把新增的部分觸發輸出；空結果覆寫了這個基準，下一輪輪詢會把整張單據誤判為新增而重複觸發列印。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>指標&lt;/th>
 &lt;th>值&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>單元測試結果&lt;/td>
 &lt;td>綠燈——測試把快照直接塞進資料層，繞過了過濾鏈&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>流程測試結果&lt;/td>
 &lt;td>首跑紅燈——斷言「快照綁定應指向新明細」失敗&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>根因&lt;/td>
 &lt;td>刷新與列表同步的順序顛倒；過濾鏈依賴列表的新鮮度&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>先同步列表、再刷新快照；順序約束寫進編排的註解與流程測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>「直接塞狀態」是單元測試的合理手段，也是它的邊界&lt;/strong>。單元測試驗證「取消邏輯在快照正確時是否正確」，把快照塞進去是聚焦的做法。但「快照如何變成正確的」——輪詢、過濾、差異比對這條鏈——不在它的視野裡。順序 bug 剛好住在這條鏈上。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>修復引入的 bug 比原始 bug 更難察覺&lt;/strong>。原始 bug 有使用者回報的症狀；修復引入的順序錯誤沒有立即症狀（要等到下一輪輪詢才重複列印），若沒有流程測試，它會以「偶發重複列印」的形態在生產環境出現，極難回溯。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>首跑就紅是價值不是意外&lt;/strong>。流程測試的建置成本花在假後端與服務鏈的組裝上；一旦組起來，它驗證的是「多個服務對同一份資料的接力是否正確」。單元測試在結構上放掉的正是這段接力。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>狀態的「來路」也要被測到&lt;/strong>。若被測邏輯依賴某份狀態，至少要有一條測試讓狀態走真實的產生路徑（輪詢、過濾、同步），而不是全部直接塞。&lt;/li>
&lt;li>&lt;strong>編排順序是一種契約&lt;/strong>。「A 必須先於 B」的約束要同時存在於兩處：編排程式碼的註解（說明不可對調的原因）與流程測試的劇本（順序錯了就紅）。&lt;/li>
&lt;li>&lt;strong>修復也要過流程測試&lt;/strong>。針對跨服務互動的修復，合入前先在流程測試上跑一輪——修復引入的迴歸與原始 bug 同樣真實。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>流程測試的地基怎麼搭 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>被繞過的鏈路屬於哪一層職責 → &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>&lt;/li>
&lt;li>前一集：bug 為什麼漏掉 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>修復跨服務互動 bug 後，新建的<a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>首跑就抓到修復自己引入的順序錯誤——單元測試綠燈、流程測試紅燈，紅燈才是對的。單元測試為了聚焦而繞過的路徑，正是跨服務互動 bug 藏身的地方。前情：<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a> 的修復合入後觸發了此問題。</p>
<h2 id="觀察">觀察</h2>
<p>這個系統的前端以輪詢讀取後端的事件流，把新事件轉成列印輸出；後端的「合併兩張單據」操作會重建明細、更換全部明細 id。<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a> 的修復針對後者：合併後前端要「立即刷新一次快照」，讓後續操作拿到重建後的新明細 id。修復者把刷新放在「同步單據列表」之前。</p>
<p>這個順序踩中輪詢的兩道機制。第一道是過濾：多店環境下，後端事件流混有其他店的資料，前端以本地單據列表為準濾掉非本店的部分。合併後的瞬間，本地列表還停在舊單據——新單據的資料被<strong>整批濾掉</strong>，刷新等於白做。第二道是差異比對：輪詢以上一輪結果為基準，只把新增的部分觸發輸出；空結果覆寫了這個基準，下一輪輪詢會把整張單據誤判為新增而重複觸發列印。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>單元測試結果</td>
          <td>綠燈——測試把快照直接塞進資料層，繞過了過濾鏈</td>
      </tr>
      <tr>
          <td>流程測試結果</td>
          <td>首跑紅燈——斷言「快照綁定應指向新明細」失敗</td>
      </tr>
      <tr>
          <td>根因</td>
          <td>刷新與列表同步的順序顛倒；過濾鏈依賴列表的新鮮度</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>先同步列表、再刷新快照；順序約束寫進編排的註解與流程測試</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>「直接塞狀態」是單元測試的合理手段，也是它的邊界</strong>。單元測試驗證「取消邏輯在快照正確時是否正確」，把快照塞進去是聚焦的做法。但「快照如何變成正確的」——輪詢、過濾、差異比對這條鏈——不在它的視野裡。順序 bug 剛好住在這條鏈上。</p>
</li>
<li>
<p><strong>修復引入的 bug 比原始 bug 更難察覺</strong>。原始 bug 有使用者回報的症狀；修復引入的順序錯誤沒有立即症狀（要等到下一輪輪詢才重複列印），若沒有流程測試，它會以「偶發重複列印」的形態在生產環境出現，極難回溯。</p>
</li>
<li>
<p><strong>首跑就紅是價值不是意外</strong>。流程測試的建置成本花在假後端與服務鏈的組裝上；一旦組起來，它驗證的是「多個服務對同一份資料的接力是否正確」。單元測試在結構上放掉的正是這段接力。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>狀態的「來路」也要被測到</strong>。若被測邏輯依賴某份狀態，至少要有一條測試讓狀態走真實的產生路徑（輪詢、過濾、同步），而不是全部直接塞。</li>
<li><strong>編排順序是一種契約</strong>。「A 必須先於 B」的約束要同時存在於兩處：編排程式碼的註解（說明不可對調的原因）與流程測試的劇本（順序錯了就紅）。</li>
<li><strong>修復也要過流程測試</strong>。針對跨服務互動的修復，合入前先在流程測試上跑一輪——修復引入的迴歸與原始 bug 同樣真實。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>流程測試的地基怎麼搭 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>被繞過的鏈路屬於哪一層職責 → <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></li>
<li>前一集：bug 為什麼漏掉 → <a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽</a></li>
</ul>
]]></content:encoded></item><item><title>語意級假後端與流程測試</title><link>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/</guid><description>&lt;p>單元測試的 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/stub/" data-link-title="Stub" data-link-desc="測試作者手動寫死回應資料的 test double：驗證的是假設成立時邏輯是否正確，假設本身錯誤時無法檢出">stub&lt;/a> 有一個結構性限制：它的回應由測試作者寫死，&lt;strong>回放的是作者對後端的假設&lt;/strong>。當 bug 的成因正是「假設錯了」（後端合併資料時會重建子項並換掉全部 id、刪除會連帶釋放關聯的佔用資源），stub 驗證不出任何東西——假設與斷言出自同一人之手，永遠自洽（案例：&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效&lt;/a>）。&lt;/p>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端&lt;/a>是針對這個限制的測試形態：一個&lt;strong>持有狀態、模擬已證實後端行為&lt;/strong>的假件，讓多個前端服務對它走完整的互動鏈。業界 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">test double 分類&lt;/a>裡，這對應 Fowler 定義的 fake——有狀態、可運作的簡化實作；「語意級」強調的是行為出處紀律。&lt;/p>
&lt;h2 id="與-stub-的差異">與 stub 的差異&lt;/h2>
&lt;p>兩者的分界在狀態的歸屬：stub 的回應由測試作者逐條寫死，語意級假後端自己持有狀態、讓每個操作演變它。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>&lt;/th>
 &lt;th>由測試餵資料的 stub&lt;/th>
 &lt;th>語意級假後端&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>資料來源&lt;/td>
 &lt;td>測試作者在每條測試裡寫死&lt;/td>
 &lt;td>假後端持有狀態，隨操作演變&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>行為&lt;/td>
 &lt;td>固定回應&lt;/td>
 &lt;td>模擬後端動詞的效果（建立、重建、刪除、連帶變更）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證標的&lt;/td>
 &lt;td>前端邏輯在「假設成立」時是否正確&lt;/td>
 &lt;td>前端多服務接力在「已證實的後端行為」下是否正確&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結構盲區&lt;/td>
 &lt;td>假設本身錯誤、跨服務互動&lt;/td>
 &lt;td>後端行為的未知變化（見界限）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「模擬後端動詞的效果」是關鍵：對「合併兩筆資料」這類操作，假後端在自己的狀態裡執行完整效果——子項全部重建、舊參照全部失效——與實測證實的真實後端一致。前端若依賴舊參照仍然有效，測試立刻紅。完整情節見 &lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>。至於值不值得為此建一個假後端，判準見文末「結構界限與適用判準」段。&lt;/p>
&lt;p>本模組的&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 無法也不應該模擬協議行為">遮蔽機制章&lt;/a>批評「讓 mock 更逼真」是結構性錯誤——mock 終究是開發者理解的副本。語意級假後端與那條批評的差別在三點：模擬止於應用層行為，協議層仍歸 protocol integration test；每條行為有實測出處，而非理解的副本；配對的真實後端驗證測試承擔行為漂移。三點缺一，它就退化成該章批評的對象。&lt;/p>
&lt;h2 id="掛載形態接縫選在哪一層">掛載形態：接縫選在哪一層&lt;/h2>
&lt;p>假後端與前端服務鏈的接縫由既有架構決定，判準是「服務怎麼碰到後端」：&lt;/p>
&lt;ul>
&lt;li>多個服務共用同一個 API client 介面（請求都經過同一個抽象出口）→ 注入 in-process 假實作：實作同一個介面、在測試進程內持有狀態。成本最低、執行最快、離線可跑&lt;/li>
&lt;li>服務各自組請求、直接發 HTTP（缺少共用抽象）→ 本地起一個假 server，把服務的目標位址指過去。多維護一層 server 的啟停與序列化，換到的是連傳輸層一起走過&lt;/li>
&lt;li>兩種形態混雜（部分服務走 client、部分自己發）→ 先把散落的請求收斂到共用介面，再掛 in-process 假實作；接縫統一之後，假後端才看得到完整的互動鏈&lt;/li>
&lt;/ul>
&lt;p>兩種形態各有環境前提：假 server 形態要求服務的目標位址可設定——位址硬編碼時，先把 endpoint 抽成可設定再掛；in-process 注入則要求服務與測試跑在同一個測試進程內。&lt;/p>
&lt;h2 id="行為從哪裡來實測一次固化為資產">行為從哪裡來：實測一次、固化為資產&lt;/h2>
&lt;p>假後端的每一條行為都必須有出處——&lt;strong>已證實&lt;/strong>，而不是「我覺得後端應該是這樣」。否則它只是一個更精緻的假設回放器。&lt;/p>
&lt;p>取證方式依成本遞增：&lt;/p>
&lt;ol>
&lt;li>讀後端回應中的診斷資訊（若有 query log 之類的除錯欄位，直接看它做了哪些寫入）&lt;/li>
&lt;li>對開發環境的真實後端做一次探測（操作前後直接讀狀態比對）&lt;/li>
&lt;li>前端埋 log 實機操作一次&lt;/li>
&lt;/ol>
&lt;p>證實後寫進假後端，並遵守配對慣例：&lt;strong>假後端每一條行為假設，對應一條真實後端驗證測試&lt;/strong>（見&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>）。假後端負責讓流程測試快速、確定地跑；驗證測試負責在後端行為漂移時把警報拉響。&lt;/p>
&lt;h2 id="可測性閘門編排住在哪裡">可測性閘門：編排住在哪裡&lt;/h2>
&lt;p>流程測試繞過 UI 皮層、從編排入口以下驅動整條服務鏈——業界詞彙裡接近 subcutaneous test，也常被歸入 integration test 的範疇。它要驅動「真實的編排」，而編排常常住在 UI 層的控制器裡。開工前先做一個 spike：&lt;strong>控制器能不能在測試環境裡立起來&lt;/strong>——具體判定是能在測試環境建構出該控制器並呼叫其編排入口，平台與環境依賴以假件或空實作替換。&lt;/p>
&lt;p>spike 走完會分出兩條路。立得起來——平台通道（行動端的原生橋接、web 的 browser API）可以 mock、背景服務可用 no-op 子類替換——測試就直接呼叫編排入口，零漂移。立不起來，剩下兩個選擇：把編排抽到服務層（重構），或在測試裡複製編排步驟並在兩處互相標註（接受漂移風險）。&lt;/p>
&lt;p>這個閘門的答案決定整個套件的形態，值得用一條最小測試先驗證，而不是寫到一半才發現。&lt;/p>
&lt;p>一併確立驗證邊界：對話框、畫面選取這類純 UI 互動不在流程測試範圍——測試直接呼叫 UI 收集完參數後的編排入口。外接裝置（第二螢幕、印表機）以可注入的假件攔在傳輸出口，斷言送出的資料流與時序（&lt;a href="https://tarrragon.github.io/blog/testing/cases/outbox-sequence-external-display/" data-link-title="T.C9 外接螢幕漏通知 — 訊息序列斷言與訂閱盲區" data-link-desc="客戶端把狀態同步到外接第二螢幕：訂閱驅動的路徑自動生效，顯式呼叫的路徑每開一條新流程就可能漏 — 漏通知 bug 的結構根因。驗證方式是錄音假件攔下送出的訊息序列，斷言「該送什麼、何時送、誰先誰後」">T.C9&lt;/a>），列印則以假印表機計數斷言張數。&lt;/p>
&lt;h2 id="流程測試的典型劇本">流程測試的典型劇本&lt;/h2>
&lt;p>一條劇本 = 一段跨服務的業務旅程，斷言散佈在每個階段的可觀察結果上：&lt;/p>
&lt;ol>
&lt;li>佈置：假後端 seed 初始狀態（資料實體、關聯資源、參照目錄）&lt;/li>
&lt;li>前端服務鏈啟動（以輪詢同步架構為例）：同步列表 → 訂閱事件 → 輪詢建立差異比對基準&lt;/li>
&lt;li>業務操作：呼叫真實編排入口，執行會改變後端狀態的操作（合併、拆分、刪除這類會重建或釋放資料的動詞）&lt;/li>
&lt;li>斷言：假後端的狀態變化（後端該發生的事）、前端的狀態對齊（本地資料該還原或更新）、對外輸出的副作用（外送訊息序列、輸出計數）&lt;/li>
&lt;li>追加一輪輪詢：驗證下一個週期把先前的變更辨識為已處理。差異比對基準是輪詢用來判斷「哪些是新資料」的上一輪快照；這一步防的是基準被污染的迴歸形態——空結果覆寫基準，下一輪就把同一筆資料誤判為新增而重複輸出（見 &lt;a href="https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6&lt;/a>）&lt;/li>
&lt;/ol>
&lt;p>步驟 2 與 5 是輪詢同步架構的情境實例；推播型架構有等價的命題——驗證事件重放時前端只輸出一次、重送的同一事件被辨識為已處理。&lt;/p></description><content:encoded><![CDATA[<p>單元測試的 <a href="/blog/testing/knowledge-cards/stub/" data-link-title="Stub" data-link-desc="測試作者手動寫死回應資料的 test double：驗證的是假設成立時邏輯是否正確，假設本身錯誤時無法檢出">stub</a> 有一個結構性限制：它的回應由測試作者寫死，<strong>回放的是作者對後端的假設</strong>。當 bug 的成因正是「假設錯了」（後端合併資料時會重建子項並換掉全部 id、刪除會連帶釋放關聯的佔用資源），stub 驗證不出任何東西——假設與斷言出自同一人之手，永遠自洽（案例：<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效</a>）。</p>
<p><a href="/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端</a>是針對這個限制的測試形態：一個<strong>持有狀態、模擬已證實後端行為</strong>的假件，讓多個前端服務對它走完整的互動鏈。業界 <a href="/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">test double 分類</a>裡，這對應 Fowler 定義的 fake——有狀態、可運作的簡化實作；「語意級」強調的是行為出處紀律。</p>
<h2 id="與-stub-的差異">與 stub 的差異</h2>
<p>兩者的分界在狀態的歸屬：stub 的回應由測試作者逐條寫死，語意級假後端自己持有狀態、讓每個操作演變它。</p>
<table>
  <thead>
      <tr>
          <th></th>
          <th>由測試餵資料的 stub</th>
          <th>語意級假後端</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>資料來源</td>
          <td>測試作者在每條測試裡寫死</td>
          <td>假後端持有狀態，隨操作演變</td>
      </tr>
      <tr>
          <td>行為</td>
          <td>固定回應</td>
          <td>模擬後端動詞的效果（建立、重建、刪除、連帶變更）</td>
      </tr>
      <tr>
          <td>驗證標的</td>
          <td>前端邏輯在「假設成立」時是否正確</td>
          <td>前端多服務接力在「已證實的後端行為」下是否正確</td>
      </tr>
      <tr>
          <td>結構盲區</td>
          <td>假設本身錯誤、跨服務互動</td>
          <td>後端行為的未知變化（見界限）</td>
      </tr>
  </tbody>
</table>
<p>「模擬後端動詞的效果」是關鍵：對「合併兩筆資料」這類操作，假後端在自己的狀態裡執行完整效果——子項全部重建、舊參照全部失效——與實測證實的真實後端一致。前端若依賴舊參照仍然有效，測試立刻紅。完整情節見 <a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a>。至於值不值得為此建一個假後端，判準見文末「結構界限與適用判準」段。</p>
<p>本模組的<a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">遮蔽機制章</a>批評「讓 mock 更逼真」是結構性錯誤——mock 終究是開發者理解的副本。語意級假後端與那條批評的差別在三點：模擬止於應用層行為，協議層仍歸 protocol integration test；每條行為有實測出處，而非理解的副本；配對的真實後端驗證測試承擔行為漂移。三點缺一，它就退化成該章批評的對象。</p>
<h2 id="掛載形態接縫選在哪一層">掛載形態：接縫選在哪一層</h2>
<p>假後端與前端服務鏈的接縫由既有架構決定，判準是「服務怎麼碰到後端」：</p>
<ul>
<li>多個服務共用同一個 API client 介面（請求都經過同一個抽象出口）→ 注入 in-process 假實作：實作同一個介面、在測試進程內持有狀態。成本最低、執行最快、離線可跑</li>
<li>服務各自組請求、直接發 HTTP（缺少共用抽象）→ 本地起一個假 server，把服務的目標位址指過去。多維護一層 server 的啟停與序列化，換到的是連傳輸層一起走過</li>
<li>兩種形態混雜（部分服務走 client、部分自己發）→ 先把散落的請求收斂到共用介面，再掛 in-process 假實作；接縫統一之後，假後端才看得到完整的互動鏈</li>
</ul>
<p>兩種形態各有環境前提：假 server 形態要求服務的目標位址可設定——位址硬編碼時，先把 endpoint 抽成可設定再掛；in-process 注入則要求服務與測試跑在同一個測試進程內。</p>
<h2 id="行為從哪裡來實測一次固化為資產">行為從哪裡來：實測一次、固化為資產</h2>
<p>假後端的每一條行為都必須有出處——<strong>已證實</strong>，而不是「我覺得後端應該是這樣」。否則它只是一個更精緻的假設回放器。</p>
<p>取證方式依成本遞增：</p>
<ol>
<li>讀後端回應中的診斷資訊（若有 query log 之類的除錯欄位，直接看它做了哪些寫入）</li>
<li>對開發環境的真實後端做一次探測（操作前後直接讀狀態比對）</li>
<li>前端埋 log 實機操作一次</li>
</ol>
<p>證實後寫進假後端，並遵守配對慣例：<strong>假後端每一條行為假設，對應一條真實後端驗證測試</strong>（見<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>）。假後端負責讓流程測試快速、確定地跑；驗證測試負責在後端行為漂移時把警報拉響。</p>
<h2 id="可測性閘門編排住在哪裡">可測性閘門：編排住在哪裡</h2>
<p>流程測試繞過 UI 皮層、從編排入口以下驅動整條服務鏈——業界詞彙裡接近 subcutaneous test，也常被歸入 integration test 的範疇。它要驅動「真實的編排」，而編排常常住在 UI 層的控制器裡。開工前先做一個 spike：<strong>控制器能不能在測試環境裡立起來</strong>——具體判定是能在測試環境建構出該控制器並呼叫其編排入口，平台與環境依賴以假件或空實作替換。</p>
<p>spike 走完會分出兩條路。立得起來——平台通道（行動端的原生橋接、web 的 browser API）可以 mock、背景服務可用 no-op 子類替換——測試就直接呼叫編排入口，零漂移。立不起來，剩下兩個選擇：把編排抽到服務層（重構），或在測試裡複製編排步驟並在兩處互相標註（接受漂移風險）。</p>
<p>這個閘門的答案決定整個套件的形態，值得用一條最小測試先驗證，而不是寫到一半才發現。</p>
<p>一併確立驗證邊界：對話框、畫面選取這類純 UI 互動不在流程測試範圍——測試直接呼叫 UI 收集完參數後的編排入口。外接裝置（第二螢幕、印表機）以可注入的假件攔在傳輸出口，斷言送出的資料流與時序（<a href="/blog/testing/cases/outbox-sequence-external-display/" data-link-title="T.C9 外接螢幕漏通知 — 訊息序列斷言與訂閱盲區" data-link-desc="客戶端把狀態同步到外接第二螢幕：訂閱驅動的路徑自動生效，顯式呼叫的路徑每開一條新流程就可能漏 — 漏通知 bug 的結構根因。驗證方式是錄音假件攔下送出的訊息序列，斷言「該送什麼、何時送、誰先誰後」">T.C9</a>），列印則以假印表機計數斷言張數。</p>
<h2 id="流程測試的典型劇本">流程測試的典型劇本</h2>
<p>一條劇本 = 一段跨服務的業務旅程，斷言散佈在每個階段的可觀察結果上：</p>
<ol>
<li>佈置：假後端 seed 初始狀態（資料實體、關聯資源、參照目錄）</li>
<li>前端服務鏈啟動（以輪詢同步架構為例）：同步列表 → 訂閱事件 → 輪詢建立差異比對基準</li>
<li>業務操作：呼叫真實編排入口，執行會改變後端狀態的操作（合併、拆分、刪除這類會重建或釋放資料的動詞）</li>
<li>斷言：假後端的狀態變化（後端該發生的事）、前端的狀態對齊（本地資料該還原或更新）、對外輸出的副作用（外送訊息序列、輸出計數）</li>
<li>追加一輪輪詢：驗證下一個週期把先前的變更辨識為已處理。差異比對基準是輪詢用來判斷「哪些是新資料」的上一輪快照；這一步防的是基準被污染的迴歸形態——空結果覆寫基準，下一輪就把同一筆資料誤判為新增而重複輸出（見 <a href="/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6</a>）</li>
</ol>
<p>步驟 2 與 5 是輪詢同步架構的情境實例；推播型架構有等價的命題——驗證事件重放時前端只輸出一次、重送的同一事件被辨識為已處理。</p>
<p>隔離紀律是劇本成立的前提：每條流程測試重建假後端實例（或等效重置），劇本之間狀態互不滲漏；seed 用共用 builder 組裝，初始狀態的形狀集中維護，避免每條測試手拼。</p>
<p>價值實例：這個形態的套件在建置過程中，首跑就抓到修復自身引入的順序 bug（<a href="/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6</a>）；一條劇本在「單跑綠、合跑紅」的合跑階段暴露 fire-and-forget（呼叫後不等待）編排的時序競態（<a href="/blog/testing/cases/fire-and-forget-test-race/" data-link-title="T.C8 fire-and-forget 編排讓測試單跑綠、合跑紅" data-link-desc="結帳成功後的收尾動作（列印、狀態清理、資料同步）沒有被 await — 測試斷言與收尾動作賽跑，單獨執行時碰巧贏、整批執行時排程不同就輸；競態本身也揭露了產品的時序特性">T.C8</a>）；雙行為開關（假後端同時模擬後端會做與不會做兩種行為、由開關切換）配合真實後端驗證測試，為一個先前只能互相猜測的前後端責任問題提供歸因定案的手段（<a href="/blog/testing/cases/dual-semantics-attribution/" data-link-title="T.C7 症狀相同、成因兩種 — 用測試切開前後端責任" data-link-desc="刪除單據後資源佔用狀態未還原：可能是後端沒釋放，也可能是後端釋放了但前端沒刷新畫面 — 兩種成因症狀完全相同。假後端模擬兩種行為各寫一條測試、再用真實後端測試一跑定案">T.C7</a>）——開關是歸因期對「已證實」規則的暫時例外（其中一種行為尚未證實），定案後收斂回單一已證實行為。</p>
<h2 id="結構界限與適用判準">結構界限與適用判準</h2>
<p>這套形態的前提是後端無法在本機或容器裡啟動、只有共用測試環境可用（<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>在同一個前提下運作）。前提不成立時，成熟工具鏈有更直接的選項：</p>
<ul>
<li>後端可容器化啟動 → 直接起真後端（testcontainers 類工具），假設由真實行為檢驗</li>
<li>provider 團隊可配合 → <a href="/blog/testing/knowledge-cards/consumer-driven-contract-test/" data-link-title="Consumer-Driven Contract Test" data-link-desc="client 與 server 分屬不同團隊、後端無法本機或容器啟動時，用契約取代對真實服務的協議整合測試">consumer-driven contract test</a>（如 Pact）讓後端在自己的 CI 裡驗證前端的假設</li>
<li>互動是線性單劇本 → 錄放式（record / replay）工具即可覆蓋</li>
</ul>
<p>三者皆否、且需要有狀態的多劇本接力時，才輪到自建語意級假後端——錄放式的 cassette 是線性回放，做不到狀態接力。</p>
<p>語意級假後端固化的是「已知」。後端若默默改變行為，假後端不會跟著變，流程測試照樣綠——這個洞由配對的真實後端驗證測試補上。兩者合起來才是完整的形態：</p>
<ul>
<li>假後端＋流程測試：快、確定、可在離線與 CI 執行，防「前端改壞已知正確的流程」</li>
<li>真實後端驗證測試：慢、需環境，防「後端行為漂移讓已知變成過期」</li>
</ul>
<p>建置與否用兩個自查問題判斷：專案裡是否有多個前端服務對同一份後端狀態接力？是否已經出現「對後端行為假設錯誤」型的漏網 bug？兩者都是 → 值得建。單一服務的 CRUD 驗證，由測試餵資料的 stub 就足夠，假後端持有狀態的維護成本高於它換到的增量。成本量級拆解：初版包含狀態模型（定義假後端持有的資料實體）、最先固化的 2-3 條已證實行為、加上一條端到端劇本——工作量主要取決於需要模擬的動詞數，2-3 個動詞是天級，超過 10 個動詞時先從最高風險的子集開始、其餘增量補入。這兩個數字是經驗閾值，依動詞的狀態複雜度調整——牽涉關聯資源連動（刪除單據要連帶釋放佔用）的動詞，一個就抵得上數個純 CRUD 動詞。之後隨新證實的行為增量維護。持有成本還包括失敗定位：流程測試紅燈時，除錯半徑橫跨 seed、服務鏈、假後端狀態到斷言的整條鏈，定位成本高於單元測試。維護面的 tripwire：後端行為高頻變動的時期，假後端加驗證測試是雙份維護、成本隨變動頻率放大——變動頻繁到每次迭代都要改兩處時，重新評估這套形態。</p>
<h2 id="建置起步順序">建置起步順序</h2>
<p>決定建假後端後，按以下順序推進：</p>
<ol>
<li><strong>可測性閘門 spike</strong>：用一條最小測試驗證編排的宿主（控制器或服務入口）能否在測試環境立起來——這一步的答案決定後續全部形態（Dart/Flutter 生態的平台耦合中和手段見<a href="/blog/flutter/flow-test-infrastructure/" data-link-title="流程測試基礎設施" data-link-desc="在 Flutter 專案建立流程測試時碰到的 Dart 實作限制：控制器能不能在 headless 環境立起來、binding 怎麼跟真實網路測試共存、測試輸出雜訊怎麼治理、假後端的回應資料走什麼路徑序列化——限制形成一條建置鏈，前一個的答案決定後一個的形態">流程測試基礎設施</a>）</li>
<li><strong>掛載形態決定</strong>：依服務碰後端的方式（共用 client 介面 / 各自發 HTTP）選 in-process 注入或假 server</li>
<li><strong>狀態模型</strong>：定義假後端持有的資料實體與初始 seed builder</li>
<li><strong>第一條已證實行為</strong>：對真實後端取證（診斷資訊 / 探測 / 埋 log），固化為假後端的第一個 handler</li>
<li><strong>第一條劇本</strong>：用 seed + handler 走完一段跨服務業務旅程，斷言每階段的可觀察結果</li>
<li><strong>配對驗證測試</strong>：在<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>補一條對應斷言</li>
</ol>
<p>步驟 4-6 形成增量循環：每證實一條新行為，假後端加一個 handler、流程測試加一條劇本、驗證測試加一條斷言。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>配對的另一半 → <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>stub 回放假設的完整案例 → <a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5 凍結參照失效被 stub 遮蔽</a></li>
<li>測試該寫成什麼樣子 → <a href="/blog/testing/05-test-design-judgment/test-comment-and-naming-discipline/" data-link-title="測試註解與命名紀律" data-link-desc="測試註解寫什麼、名稱與 reason 怎麼收斂、分析詞彙與開發過程該不該進程式碼 — 判斷測試文字去留的紀律">測試註解與命名紀律</a></li>
<li>Dart/Flutter 生態的實作限制（headless 立控制器、binding 互斥、假後端序列化）→ <a href="/blog/flutter/flow-test-infrastructure/" data-link-title="流程測試基礎設施" data-link-desc="在 Flutter 專案建立流程測試時碰到的 Dart 實作限制：控制器能不能在 headless 環境立起來、binding 怎麼跟真實網路測試共存、測試輸出雜訊怎麼治理、假後端的回應資料走什麼路徑序列化——限制形成一條建置鏈，前一個的答案決定後一個的形態">流程測試基礎設施</a></li>
</ul>
]]></content:encoded></item><item><title>T.C7 症狀相同、成因兩種 — 用測試切開前後端責任</title><link>https://tarrragon.github.io/blog/testing/cases/dual-semantics-attribution/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/dual-semantics-attribution/</guid><description>&lt;p>同一個症狀、兩種可能成因，光看畫面無法歸因：可能是後端沒釋放資源，也可能是後端釋放了但前端沒刷新。這個案例示範「前後端責任切分」如何用測試完成，取代猜測與互相指責。關鍵認知：同一個症狀往往有多種成因，而其中一種在自己身上。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>前端與後端的契約是：刪除一張單據時，後端一併釋放該單據佔用的資源狀態（例如場地、座位這類由後端控管的佔用旗標）。使用者回報：刪除後，資源在畫面上仍顯示為佔用中。&lt;/p>
&lt;p>初步懷疑指向後端違約。但讀前端程式時發現：刪除編排在成功後&lt;strong>沒有任何一次資源狀態的刷新&lt;/strong>——也就是說，即使後端照契約釋放了，畫面也會殘留舊的「佔用中」，症狀與後端沒釋放&lt;strong>完全無法區分&lt;/strong>。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>階段&lt;/th>
 &lt;th>做法&lt;/th>
 &lt;th>結果&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>切分責任&lt;/td>
 &lt;td>假後端（行為可控的模擬後端）加開關：模擬「會釋放」與「不會釋放」兩種行為，前端刪除編排在兩種行為下各寫一條&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>&lt;/td>
 &lt;td>「會釋放」那條紅燈——逼出前端缺刷新的問題，先修&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>定案成因&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/real-backend-verification-test/" data-link-title="Real-Backend Verification Test（真實後端驗證測試）" data-link-desc="對共用測試環境常駐斷言後端業務行為的測試；紅、綠、跳過各有語意，承接假後端固化行為的漂移警報">真實後端驗證測試&lt;/a>：刪除後直接讀後端的資源狀態（即刻＋延遲各一次，容許非同步）&lt;/td>
 &lt;td>綠燈——後端&lt;strong>有&lt;/strong>同步釋放&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結論&lt;/td>
 &lt;td>是前端 bug（漏刷新），不是後端違約&lt;/td>
 &lt;td>撤銷回報、修復已被既有測試鎖住&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>「畫面顯示的」不等於「後端狀態」&lt;/strong>。前端畫面是「上次同步的後端狀態」的快取；任何會改變後端狀態的操作，若後續沒有同步，畫面就殘留舊值。這類 bug 的歸因第一步是：繞過畫面，直接讀後端。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>兩種行為都寫測試，而不是先賭一邊&lt;/strong>。假後端的開關讓「後端會釋放」與「不會釋放」兩個世界都存在；前端編排在兩個世界裡的正確行為不同（前者要畫面還原、後者要誠實顯示佔用）。分開鎖定後，無論真實後端是哪種，前端這半的責任都已被測試涵蓋。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>真實後端測試把「歸因」變成一次執行&lt;/strong>。紅＝後端違約（測試本身成為修復的驗收標準）、綠＝前端問題。不需要會議、不需要來回猜測——而且這次的結果與最初的懷疑相反，正說明沒有證據的歸因有多不可靠。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>延遲重讀是必要的容錯&lt;/strong>。後端可能非同步釋放（即刻讀還是佔用、幾秒後才釋放）；驗證測試即刻與延遲各讀一次，把「同步釋放／非同步釋放／未釋放」三種結果區分開，各自對應不同的前端處置。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>歸因順序&lt;/strong>：直接讀後端狀態 → 確認後端行為 → 再回頭檢查前端的同步時機。跳過第一步的歸因都是猜測。&lt;/li>
&lt;li>&lt;strong>「改變後端狀態的操作」在前端編排的收尾必須有對應的同步&lt;/strong>。這條規則可以巡檢：列出所有會改變資源狀態的操作，逐一檢查操作後有沒有刷新。&lt;/li>
&lt;li>&lt;strong>定案後收斂測試&lt;/strong>：真實行為確定後，把假後端的開關定死、移除錯誤行為那條測試分支——錯誤行為分支違反假後端「只固化已證實行為」的定位，後端未來變壞由配對的真實後端驗證測試接手。雙行為測試是歸因期的工具，不是永久資產。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>真實後端驗證測試的工程化 → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>&lt;/li>
&lt;li>假後端的行為從哪裡來 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>同場景的另一種畫面殘留 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6 流程測試首跑抓到順序 bug&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>同一個症狀、兩種可能成因，光看畫面無法歸因：可能是後端沒釋放資源，也可能是後端釋放了但前端沒刷新。這個案例示範「前後端責任切分」如何用測試完成，取代猜測與互相指責。關鍵認知：同一個症狀往往有多種成因，而其中一種在自己身上。</p>
<h2 id="觀察">觀察</h2>
<p>前端與後端的契約是：刪除一張單據時，後端一併釋放該單據佔用的資源狀態（例如場地、座位這類由後端控管的佔用旗標）。使用者回報：刪除後，資源在畫面上仍顯示為佔用中。</p>
<p>初步懷疑指向後端違約。但讀前端程式時發現：刪除編排在成功後<strong>沒有任何一次資源狀態的刷新</strong>——也就是說，即使後端照契約釋放了，畫面也會殘留舊的「佔用中」，症狀與後端沒釋放<strong>完全無法區分</strong>。</p>
<table>
  <thead>
      <tr>
          <th>階段</th>
          <th>做法</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>切分責任</td>
          <td>假後端（行為可控的模擬後端）加開關：模擬「會釋放」與「不會釋放」兩種行為，前端刪除編排在兩種行為下各寫一條<a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a></td>
          <td>「會釋放」那條紅燈——逼出前端缺刷新的問題，先修</td>
      </tr>
      <tr>
          <td>定案成因</td>
          <td><a href="/blog/testing/knowledge-cards/real-backend-verification-test/" data-link-title="Real-Backend Verification Test（真實後端驗證測試）" data-link-desc="對共用測試環境常駐斷言後端業務行為的測試；紅、綠、跳過各有語意，承接假後端固化行為的漂移警報">真實後端驗證測試</a>：刪除後直接讀後端的資源狀態（即刻＋延遲各一次，容許非同步）</td>
          <td>綠燈——後端<strong>有</strong>同步釋放</td>
      </tr>
      <tr>
          <td>結論</td>
          <td>是前端 bug（漏刷新），不是後端違約</td>
          <td>撤銷回報、修復已被既有測試鎖住</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>「畫面顯示的」不等於「後端狀態」</strong>。前端畫面是「上次同步的後端狀態」的快取；任何會改變後端狀態的操作，若後續沒有同步，畫面就殘留舊值。這類 bug 的歸因第一步是：繞過畫面，直接讀後端。</p>
</li>
<li>
<p><strong>兩種行為都寫測試，而不是先賭一邊</strong>。假後端的開關讓「後端會釋放」與「不會釋放」兩個世界都存在；前端編排在兩個世界裡的正確行為不同（前者要畫面還原、後者要誠實顯示佔用）。分開鎖定後，無論真實後端是哪種，前端這半的責任都已被測試涵蓋。</p>
</li>
<li>
<p><strong>真實後端測試把「歸因」變成一次執行</strong>。紅＝後端違約（測試本身成為修復的驗收標準）、綠＝前端問題。不需要會議、不需要來回猜測——而且這次的結果與最初的懷疑相反，正說明沒有證據的歸因有多不可靠。</p>
</li>
<li>
<p><strong>延遲重讀是必要的容錯</strong>。後端可能非同步釋放（即刻讀還是佔用、幾秒後才釋放）；驗證測試即刻與延遲各讀一次，把「同步釋放／非同步釋放／未釋放」三種結果區分開，各自對應不同的前端處置。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>歸因順序</strong>：直接讀後端狀態 → 確認後端行為 → 再回頭檢查前端的同步時機。跳過第一步的歸因都是猜測。</li>
<li><strong>「改變後端狀態的操作」在前端編排的收尾必須有對應的同步</strong>。這條規則可以巡檢：列出所有會改變資源狀態的操作，逐一檢查操作後有沒有刷新。</li>
<li><strong>定案後收斂測試</strong>：真實行為確定後，把假後端的開關定死、移除錯誤行為那條測試分支——錯誤行為分支違反假後端「只固化已證實行為」的定位，後端未來變壞由配對的真實後端驗證測試接手。雙行為測試是歸因期的工具，不是永久資產。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>真實後端驗證測試的工程化 → <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>假後端的行為從哪裡來 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>同場景的另一種畫面殘留 → <a href="/blog/testing/cases/flow-test-first-run-ordering-catch/" data-link-title="T.C6 流程測試首跑抓到修復自己引入的順序 bug" data-link-desc="單元測試直接把狀態塞進被測物、繞過了真實的資料過濾鏈；流程測試讓資料走完整鏈路，第一次執行就抓到「刷新在列表同步之前」的編排順序錯誤 — 而這個錯誤正是前一個 bug 的修復引入的">T.C6 流程測試首跑抓到順序 bug</a></li>
</ul>
]]></content:encoded></item><item><title>Stub</title><link>https://tarrragon.github.io/blog/testing/knowledge-cards/stub/</link><pubDate>Mon, 20 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/testing/knowledge-cards/stub/</guid><description>&lt;p>Stub 是測試作者手動寫死回應資料的 test double：呼叫者拿到的每一筆資料，都是測試在建置階段逐條餵進去的固定值。它跟&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端&lt;/a>的分野在狀態的歸屬——stub 的回應由測試作者寫死，假後端自己持有狀態、隨操作演變。Stub 回放的是測試作者對後端行為的假設，假設錯了，測試照樣綠燈，這是它的結構性限制，不是實作疏忽。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>Stub 驗證的標的是「假設成立時、前端邏輯是否正確」，不是「假設本身是否成立」。假設與斷言出自同一人之手，永遠自洽——這一點讓 stub 沒有能力檢出「對後端行為的理解本身就錯了」這一類 bug。&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">Mock 遮蔽&lt;/a>描述的是協議層的結構性盲區，stub 的盲區發生在更上游：連需要驗證的行為假設都由同一人設計。兩種盲區疊加時，測試綠燈能證明的範圍比表面上小得多。Stub 在 test double 家族裡的位置（跟 dummy / spy / mock / fake 的分工全景）見 &lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">Test Double Taxonomy&lt;/a>。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>辨識訊號是「stub 資料與斷言預期出自同一次測試撰寫」——沒有獨立於測試作者的行為出處。典型案例：前端把後端資料的 id 凍結在本地記錄裡，單元測試建立記錄時順手把對應資料餵進 stub，凍結 id 與 stub 資料恆常一致；真實世界裡讓 id 失效的是後端的合併操作，stub 沒有這個行為，於是「id 失效」這個狀態在測試裡永遠不會出現，功能全壞、測試全綠（&lt;a href="https://tarrragon.github.io/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5&lt;/a>）。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>判斷「stub 夠不夠」的問句是：如果把 stub 換成真實服務，斷言結果會不會改變？只驗證程式碼內部邏輯（狀態機轉換、錯誤處理分支）時，stub 夠用；驗證對象是「對後端行為的假設」本身時，stub 結構上驗證不出來，需要換成持有狀態、行為有實測出處的&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端&lt;/a>。凡是前端持有後端 id 的地方，都要追問後端的哪些操作會讓這個 id 死亡，並把「id 失效」的劇本納入測試範圍——見&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/frozen-vs-live-reference/" data-link-title="Frozen vs Live Reference（凍結參照與活解析）" data-link-desc="下游持有上游 id 的兩種策略——寫死 id vs 查詢時動態解引用；上游重建後凍結參照失效，stub 對此結構性盲目">凍結參照與活解析&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Stub 是測試作者手動寫死回應資料的 test double：呼叫者拿到的每一筆資料，都是測試在建置階段逐條餵進去的固定值。它跟<a href="/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端</a>的分野在狀態的歸屬——stub 的回應由測試作者寫死，假後端自己持有狀態、隨操作演變。Stub 回放的是測試作者對後端行為的假設，假設錯了，測試照樣綠燈，這是它的結構性限制，不是實作疏忽。</p>
<h2 id="概念位置">概念位置</h2>
<p>Stub 驗證的標的是「假設成立時、前端邏輯是否正確」，不是「假設本身是否成立」。假設與斷言出自同一人之手，永遠自洽——這一點讓 stub 沒有能力檢出「對後端行為的理解本身就錯了」這一類 bug。<a href="/blog/testing/knowledge-cards/mock-masking/" data-link-title="Mock Masking（Mock 遮蔽）" data-link-desc="mock 模擬 API 層但不模擬協議層，造成的結構性驗證盲區">Mock 遮蔽</a>描述的是協議層的結構性盲區，stub 的盲區發生在更上游：連需要驗證的行為假設都由同一人設計。兩種盲區疊加時，測試綠燈能證明的範圍比表面上小得多。Stub 在 test double 家族裡的位置（跟 dummy / spy / mock / fake 的分工全景）見 <a href="/blog/testing/knowledge-cards/test-double-taxonomy/" data-link-title="Test Double Taxonomy（Fowler 分類）" data-link-desc="test double 依據回應資料的來源與驗證方式分成五種角色，各自對應不同的可測範圍與盲區">Test Double Taxonomy</a>。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>辨識訊號是「stub 資料與斷言預期出自同一次測試撰寫」——沒有獨立於測試作者的行為出處。典型案例：前端把後端資料的 id 凍結在本地記錄裡，單元測試建立記錄時順手把對應資料餵進 stub，凍結 id 與 stub 資料恆常一致；真實世界裡讓 id 失效的是後端的合併操作，stub 沒有這個行為，於是「id 失效」這個狀態在測試裡永遠不會出現，功能全壞、測試全綠（<a href="/blog/testing/cases/stale-reference-stub-blindspot/" data-link-title="T.C5 凍結參照失效被 stub 遮蔽 — 測試全綠、功能全壞" data-link-desc="前端把後端資料的 id 凍結在本地記錄裡，後端的合併操作會重建資料、舊 id 全部失效 — 單元測試的 stub 由測試自己餵資料，永遠不會經歷「id 死亡」，bug 存在期間所有測試綠燈">T.C5</a>）。</p>
<h2 id="設計責任">設計責任</h2>
<p>判斷「stub 夠不夠」的問句是：如果把 stub 換成真實服務，斷言結果會不會改變？只驗證程式碼內部邏輯（狀態機轉換、錯誤處理分支）時，stub 夠用；驗證對象是「對後端行為的假設」本身時，stub 結構上驗證不出來，需要換成持有狀態、行為有實測出處的<a href="/blog/testing/knowledge-cards/semantic-fake-backend/" data-link-title="Semantic Fake Backend（語意級假後端）" data-link-desc="持有狀態、只固化已證實後端行為的測試假件；與由測試餵資料的 stub 以狀態歸屬和行為出處劃界">語意級假後端</a>。凡是前端持有後端 id 的地方，都要追問後端的哪些操作會讓這個 id 死亡，並把「id 失效」的劇本納入測試範圍——見<a href="/blog/testing/knowledge-cards/frozen-vs-live-reference/" data-link-title="Frozen vs Live Reference（凍結參照與活解析）" data-link-desc="下游持有上游 id 的兩種策略——寫死 id vs 查詢時動態解引用；上游重建後凍結參照失效，stub 對此結構性盲目">凍結參照與活解析</a>。</p>
]]></content:encoded></item><item><title>有狀態假後端用真實模型序列化回應：手寫 JSON fixture 會重踩產品已解決的問題</title><link>https://tarrragon.github.io/blog/work-log/flutter_fake_backend_real_model_serialization/</link><pubDate>Fri, 17 Jul 2026 00:00:00 +0800</pubDate><guid>https://tarrragon.github.io/blog/work-log/flutter_fake_backend_real_model_serialization/</guid><description>&lt;blockquote>
&lt;p>&lt;strong>核心議題&lt;/strong>：假後端的回應資料從哪來？手寫 JSON 字串、還是建構真實模型物件再 &lt;code>toJson()&lt;/code>？POS App 的&lt;a href="https://tarrragon.github.io/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試&lt;/a>選了後者，理由不是美觀——是「後端回應形狀的知識」在專案裡應該只有一份（產品的模型解析層），fixture 自己再寫一份就會分岔。
&lt;strong>案例骨幹&lt;/strong>：有狀態假後端以 freezed 模型（單據、明細、記錄）持有狀態、handler 用 &lt;code>copyWith&lt;/code> 演變狀態、出口一律 &lt;code>toJson()&lt;/code>。同一時期另一批用 raw JSON 手刻請求的測試，重踩了「列表回應帶分頁包裝」的問題——產品的回應信封解析早就內建了這層 unwrap，手刻等於把已解決的問題再解一次。&lt;/p>&lt;/blockquote>
&lt;hr>
&lt;h2 id="1-假後端的回應出口物件--tojson不是字串樣板">1. 假後端的回應出口：物件 → toJson，不是字串樣板&lt;/h2>
&lt;p>有狀態假後端攔在 HTTP adapter 層，對每個端點回放狀態。回應的組裝方式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 狀態就是真實模型物件
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Cart&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">carts&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">List&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">Record&lt;/span>&lt;span class="o">&amp;gt;&lt;/span> &lt;span class="n">records&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="c1">// 出口統一序列化——服務層拿到的 JSON 與真實後端同構
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">isCartList&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">envelope&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">c&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="n">carts&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">toJson&lt;/span>&lt;span class="p">()]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>freezed 模型天生雙向（&lt;code>fromJson&lt;/code>/&lt;code>toJson&lt;/code>），這保證了一個閉環：&lt;strong>假後端 seed 的物件 → toJson → 服務層 fromJson → 前端邏輯&lt;/strong>，服務層走的解析路徑與生產環境完全相同。手寫 JSON 樣板跳過的正是這個閉環的前半段——樣板對不對，靠人眼比對 API 文件。&lt;/p>
&lt;h2 id="2-對照組事故raw-手刻重踩分頁包裝">2. 對照組事故：raw 手刻重踩分頁包裝&lt;/h2>
&lt;p>同一時期，另一批對真實後端發請求的測試最初用 raw JSON 手刻（自組請求、手挖回應欄位）。首跑失敗：&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">type &amp;#39;_Map&amp;lt;String, dynamic&amp;gt;&amp;#39; is not a subtype of type &amp;#39;List&amp;lt;dynamic&amp;gt;&amp;#39;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>真實後端的列表端點帶分頁包裝——&lt;code>data&lt;/code> 不是陣列，是 &lt;code>{ data: [...], previousPage, nextPage, ... }&lt;/code>。而產品的回應信封模型&lt;strong>早就內建&lt;/strong>了「嘗試解分頁包裝」的邏輯，生產 App 天天在正確處理這個形狀。raw 寫法等於把一個已解決的問題重新發現、重新修補（在測試裡加了一個 &lt;code>_listData&lt;/code> helper）——直到改用產品的 API client 與模型解析，這個 helper 連同它代表的重複知識一起刪除。&lt;/p>
&lt;p>教訓一句話：&lt;strong>回應形狀的知識只該存在一份&lt;/strong>。fixture 或測試裡出現第二份（手寫 JSON、手挖欄位），它與第一份的分岔只是時間問題。&lt;/p>
&lt;h2 id="3-狀態演變用-copywith行為住在-handler">3. 狀態演變用 copyWith，行為住在 handler&lt;/h2>
&lt;p>有狀態假後端與「回放固定回應的 stub」的分水嶺在於它模擬後端動詞的效果。freezed 的 &lt;code>copyWith&lt;/code> 讓這件事保持宣告式：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 「合併」的效果：明細全部換新 id、記錄改掛新單據
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">newDetails&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">d&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="n">old&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">details&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">d&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">id:&lt;/span> &lt;span class="n">newId&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">records&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kd">final&lt;/span> &lt;span class="n">r&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="n">records&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">copyWith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">parentId:&lt;/span> &lt;span class="n">mergedId&lt;/span>&lt;span class="p">)];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 handler 頭上一句話描述它模擬的後端行為（來源是實測證實，不是猜測）；測試 seed 初始物件、跑真實服務鏈、斷言假後端的狀態變化與前端的對齊結果。方法論層的完整討論見&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>。&lt;/p>
&lt;h2 id="4-邊界tojson-回放的前提是模型忠實">4. 邊界：toJson 回放的前提是模型忠實&lt;/h2>
&lt;p>這個做法有一個隱含前提：&lt;strong>產品模型的 &lt;code>fromJson&lt;/code>/&lt;code>toJson&lt;/code> 對真實後端是忠實的&lt;/strong>。若模型本身漏了欄位、轉換器不對稱，假後端的閉環會把錯誤一起閉進去——測試綠、對真實後端壞。補這個洞的是配對的&lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>：它走同一套模型打真實環境，模型與後端的形狀分岔會在那裡現形（分頁包裝那次事故正是被它抓到的）。&lt;/p>
&lt;h2 id="5-可複用的判準">5. 可複用的判準&lt;/h2>
&lt;ol>
&lt;li>假後端／fixture 的回應一律「建構模型物件 → toJson」，不手寫 JSON 字串。&lt;/li>
&lt;li>測試裡出現「手挖回應欄位」的 helper（&lt;code>data['data']&lt;/code> 之類）＝重複知識的訊號，改走產品解析層。&lt;/li>
&lt;li>有狀態假後端的狀態演變用 &lt;code>copyWith&lt;/code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。&lt;/li>
&lt;li>toJson 回放閉環的忠實性由真實後端驗證測試把關——兩者是配對關係，不是二選一。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步">下一步&lt;/h2>
&lt;ul>
&lt;li>方法論層 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試&lt;/a>&lt;/li>
&lt;li>配對的驗證層 → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試&lt;/a>&lt;/li>
&lt;li>freezed 模型設計的機制層 → &lt;a href="https://tarrragon.github.io/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<blockquote>
<p><strong>核心議題</strong>：假後端的回應資料從哪來？手寫 JSON 字串、還是建構真實模型物件再 <code>toJson()</code>？POS App 的<a href="/blog/testing/knowledge-cards/flow-test/" data-link-title="Flow Test（流程測試）" data-link-desc="在假後端上驅動真實前端服務鏈、斷言散佈於業務旅程各階段的測試形態；與 unit / integration / E2E 的邊界劃分">流程測試</a>選了後者，理由不是美觀——是「後端回應形狀的知識」在專案裡應該只有一份（產品的模型解析層），fixture 自己再寫一份就會分岔。
<strong>案例骨幹</strong>：有狀態假後端以 freezed 模型（單據、明細、記錄）持有狀態、handler 用 <code>copyWith</code> 演變狀態、出口一律 <code>toJson()</code>。同一時期另一批用 raw JSON 手刻請求的測試，重踩了「列表回應帶分頁包裝」的問題——產品的回應信封解析早就內建了這層 unwrap，手刻等於把已解決的問題再解一次。</p></blockquote>
<hr>
<h2 id="1-假後端的回應出口物件--tojson不是字串樣板">1. 假後端的回應出口：物件 → toJson，不是字串樣板</h2>
<p>有狀態假後端攔在 HTTP adapter 層，對每個端點回放狀態。回應的組裝方式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 狀態就是真實模型物件
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">List</span><span class="o">&lt;</span><span class="n">Cart</span><span class="o">&gt;</span> <span class="n">carts</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">List</span><span class="o">&lt;</span><span class="n">Record</span><span class="o">&gt;</span> <span class="n">records</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">// 出口統一序列化——服務層拿到的 JSON 與真實後端同構
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span><span class="k">if</span> <span class="p">(</span><span class="n">isCartList</span><span class="p">(</span><span class="n">request</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="n">envelope</span><span class="p">([</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">c</span> <span class="k">in</span> <span class="n">carts</span><span class="p">)</span> <span class="n">c</span><span class="p">.</span><span class="n">toJson</span><span class="p">()]);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>freezed 模型天生雙向（<code>fromJson</code>/<code>toJson</code>），這保證了一個閉環：<strong>假後端 seed 的物件 → toJson → 服務層 fromJson → 前端邏輯</strong>，服務層走的解析路徑與生產環境完全相同。手寫 JSON 樣板跳過的正是這個閉環的前半段——樣板對不對，靠人眼比對 API 文件。</p>
<h2 id="2-對照組事故raw-手刻重踩分頁包裝">2. 對照組事故：raw 手刻重踩分頁包裝</h2>
<p>同一時期，另一批對真實後端發請求的測試最初用 raw JSON 手刻（自組請求、手挖回應欄位）。首跑失敗：</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">type &#39;_Map&lt;String, dynamic&gt;&#39; is not a subtype of type &#39;List&lt;dynamic&gt;&#39;</span></span></code></pre></div><p>真實後端的列表端點帶分頁包裝——<code>data</code> 不是陣列，是 <code>{ data: [...], previousPage, nextPage, ... }</code>。而產品的回應信封模型<strong>早就內建</strong>了「嘗試解分頁包裝」的邏輯，生產 App 天天在正確處理這個形狀。raw 寫法等於把一個已解決的問題重新發現、重新修補（在測試裡加了一個 <code>_listData</code> helper）——直到改用產品的 API client 與模型解析，這個 helper 連同它代表的重複知識一起刪除。</p>
<p>教訓一句話：<strong>回應形狀的知識只該存在一份</strong>。fixture 或測試裡出現第二份（手寫 JSON、手挖欄位），它與第一份的分岔只是時間問題。</p>
<h2 id="3-狀態演變用-copywith行為住在-handler">3. 狀態演變用 copyWith，行為住在 handler</h2>
<p>有狀態假後端與「回放固定回應的 stub」的分水嶺在於它模擬後端動詞的效果。freezed 的 <code>copyWith</code> 讓這件事保持宣告式：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 「合併」的效果：明細全部換新 id、記錄改掛新單據
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kd">final</span> <span class="n">newDetails</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">d</span> <span class="k">in</span> <span class="n">old</span><span class="p">.</span><span class="n">details</span><span class="p">)</span> <span class="n">d</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">id:</span> <span class="n">newId</span><span class="p">())];</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="n">records</span> <span class="o">=</span> <span class="p">[</span><span class="k">for</span> <span class="p">(</span><span class="kd">final</span> <span class="n">r</span> <span class="k">in</span> <span class="n">records</span><span class="p">)</span> <span class="n">r</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">parentId:</span> <span class="n">mergedId</span><span class="p">)];</span></span></span></code></pre></div><p>每個 handler 頭上一句話描述它模擬的後端行為（來源是實測證實，不是猜測）；測試 seed 初始物件、跑真實服務鏈、斷言假後端的狀態變化與前端的對齊結果。方法論層的完整討論見<a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a>。</p>
<h2 id="4-邊界tojson-回放的前提是模型忠實">4. 邊界：toJson 回放的前提是模型忠實</h2>
<p>這個做法有一個隱含前提：<strong>產品模型的 <code>fromJson</code>/<code>toJson</code> 對真實後端是忠實的</strong>。若模型本身漏了欄位、轉換器不對稱，假後端的閉環會把錯誤一起閉進去——測試綠、對真實後端壞。補這個洞的是配對的<a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a>：它走同一套模型打真實環境，模型與後端的形狀分岔會在那裡現形（分頁包裝那次事故正是被它抓到的）。</p>
<h2 id="5-可複用的判準">5. 可複用的判準</h2>
<ol>
<li>假後端／fixture 的回應一律「建構模型物件 → toJson」，不手寫 JSON 字串。</li>
<li>測試裡出現「手挖回應欄位」的 helper（<code>data['data']</code> 之類）＝重複知識的訊號，改走產品解析層。</li>
<li>有狀態假後端的狀態演變用 <code>copyWith</code> 在 handler 裡宣告式完成，一個 handler 對應一條已證實的後端行為。</li>
<li>toJson 回放閉環的忠實性由真實後端驗證測試把關——兩者是配對關係，不是二選一。</li>
</ol>
<h2 id="下一步">下一步</h2>
<ul>
<li>方法論層 → <a href="/blog/testing/01-test-strategy-layers/semantic-fake-backend/" data-link-title="語意級假後端與流程測試" data-link-desc="bug 的成因是對後端行為的假設錯誤、由測試餵資料的 stub 驗證不出來時：建一個持有狀態、模擬已證實後端行為的假後端（test double 分類的 fake），讓流程測試走完整的多服務互動鏈">語意級假後端與流程測試</a></li>
<li>配對的驗證層 → <a href="/blog/testing/03-protocol-integration-test/real-backend-verification/" data-link-title="真實後端驗證測試" data-link-desc="服務無法本機啟動、只有共用測試環境（staging）時，把對真實後端的行為驗證寫成常駐測試：離線降級為跳過、憑證失效必須紅燈，讓假後端固化的行為假設有地方對真實後端驗證">真實後端驗證測試</a></li>
<li>freezed 模型設計的機制層 → <a href="/blog/work-log/dart_freezed_anatomy/" data-link-title="Freezed 的三層結構解剖：with、_$、以及更好懂的替代路徑" data-link-desc="freezed `class X with _$X implements Y` 的分層結構解剖：`with` 與 `_$` 各自的角色、沒有 freezed 怎麼手做、中間投影物件 vs DTO 直接 implements 的維護取捨。">Freezed 三層結構解剖</a></li>
</ul>
]]></content:encoded></item></channel></rss>