2026-03,開發線上點單多廚房印表機列印功能。 寫了 28 個測試,全部通過,上實機後陸續發現四個 Bug。


發生了什麼事

功能的核心流程:

1收到追加點單
2  → 把品項分派到對應的廚房印表機
3  → 組裝收據內容(標題、品名+數量、備註等)
4  → 逐行列印
5    → 單行文字(標題、桌號)
6    → 多欄表格(品名 + 數量)

上實機後,四個 Bug 依序出現——因為它們在同一條執行路徑的不同深度,每修一個,程式才能走到下一個:

1Bug 1(印表機內部元件未初始化)
2  ↓ 修好,程式碼走得更遠
3Bug 2(品項分派邏輯錯誤,全部送到同一台)
4  ↓ 修好,兩台印表機都有被呼叫
5Bug 3(多欄表格的欄位寬度不符合規定)
6  ↓ 修好,表格列印通過驗證,繼續往下
7Bug 4(空行觸發第三方 library 的越界錯誤)
Bug出了什麼事測試沒抓到的原因
Bug 1: 印表機元件未初始化模擬印表機的初始化漏掉了內部元件只測了「送出資料」,沒測「組裝列印指令」這個步驟
Bug 2: 品項全分到同一台分派邏輯找到第一台可用的印表機就全部送過去手動構造預期結果,分派邏輯沒有被執行
Bug 3: 欄位寬度錯誤欄位比例(3:1=4)不符合 library 要求的總和 12模擬的收據內容只有純文字行,沒有多欄表格行
Bug 4: 空行越界錯誤第三方 library 沒有處理空字串被 Bug 3 擋住,程式從未執行到這一行

為什麼一次只能發現一個 Bug

四個 Bug 都在同一條執行路徑上,只是深度不同。程式走到第一個錯誤就中斷了,後面的都被遮蔽。

而測試沒有發現這些問題,是因為測試沒有走過這條完整路徑。

1真實路徑:  接收訂單 → 組裝收據 → 列印中心 → 表格列印/文字列印 → 印表機底層
2測試路徑:  接收訂單 → 中斷(手動構造結果,後面都沒跑)
3
4後來補了模擬元件:接收訂單 → 模擬收據產生器 → 列印中心 → 模擬印表機 → 底層
56                           但模擬的收據只有純文字行
7                           → 文字列印有被覆蓋,表格列印沒有
8                           → Bug 3, 4 仍然隱藏

這次的問題在測試覆蓋的路徑深度。


回顧:這次遇到的五個事故

1. 測試的是「手動構造的結果」,不是「程式的行為」

怎麼發現的: 實機上兩台廚房印表機只有一台收到品項,另一台完全沒動。但測試裡「品項分派邏輯」的測試案例是通過的。

怎麼找到原因的: 回頭看測試程式碼,發現測試裡的分派結果是手動寫死的,不是由分派邏輯算出來的:

1test('品名長度奇數分配到第一台', () {
2  final result = OnlineOrderPrintResult(
3    itemPrinterMapping: {'item-1': 'kitchen-2'}, // 寫死的值
4  );
5  record.applyPrintResult(result);
6  expect(record.kitchenItemPrintJobs['item-1']!.printerId, 'kitchen-2');
7});

這個測試驗證的是「把結果存進去再讀出來,資料有沒有一致」,但品項分派的程式碼從頭到尾沒有被執行過。測試名稱寫的是分派邏輯,實際測的是資料儲存。

怎麼修的: 改成從入口方法開始呼叫,讓品項分派的邏輯實際跑一遍。跑完之後 Bug 2 就出現了——分派邏輯的 fallback 條件寫錯,所有品項都被送到同一台。


2. 只測了子類別自己的方法,沒測從父類別繼承的方法

怎麼發現的: 實機上廚房印表機列印全部失敗,log 顯示內部元件未初始化的錯誤,但測試裡模擬印表機的初始化和列印測試都是通過的。

怎麼找到原因的: 比對測試和實際程式碼的呼叫路徑。測試裡呼叫的是模擬印表機自己覆寫的「送出資料」方法(改成什麼都不做),但實際列印時上層呼叫的是從父類別繼承的「組裝列印指令」方法,這個方法內部依賴一個需要初始化的元件。測試覆蓋到的方法,和實際執行路徑走到的方法不是同一個。

怎麼修的: 在測試中加入對繼承方法的測試——初始化後呼叫「組裝列印指令」確認不報錯,以及未初始化時呼叫確認會拋出錯誤。同時修正模擬印表機的初始化方法,補上內部元件的建立。


3. 斷言只檢查「有沒有」,沒檢查「對不對」

怎麼發現的: 修完 Bug 1 和 2 之後重跑測試,整合測試通過了。但看 log 發現廚房 1 和廚房 2 的列印結果都是 false(失敗),和測試通過的結果矛盾。

怎麼找到原因的: 回頭看斷言,發現寫的是 containsKey

1expect(result.kitchenResults.containsKey('kitchen-1'), isTrue);

這個斷言只檢查「有沒有這台印表機的結果」,不管結果是成功還是失敗。列印在 try-catch 裡失敗後回傳 false,但 key 存在,所以斷言通過。

怎麼修的: 改成直接檢查值 expect(result.kitchenResults['kitchen-1'], isTrue)。改完之後測試立刻失敗,顯示列印結果確實是 false。這才發現測試環境缺少收據產生器的依賴,列印路徑在組裝收據的步驟就斷了,被 try-catch 吞掉回傳 false


4. 模擬元件的回傳資料只覆蓋了部分分支

怎麼發現的: 修完 Bug 1、2,也修正了斷言(坑 3)之後,測試全過,列印結果也都是 true。但上實機測試時,廚房印表機仍然全部失敗,log 顯示「欄位寬度總和必須等於 12」。

怎麼找到原因的: 測試環境和實機的差異在於收據的內容。實機用的是真實的廚房收據模板,包含多欄表格(品名+數量)。測試用的是模擬的收據產生器,只回傳一行純文字:

1class FakeReceiptBuilderService extends ReceiptBuilderService {
2  Future<List<ReceiptLine>> buildReceiptLines(...) async {
3    return [ReceiptLine.singleLine(data.title)]; // 只有標題
4  }
5}

純文字走的是「文字列印」,多欄表格走的是「表格列印」——這是兩條不同的分支。模擬的資料只觸發了文字列印,表格列印從未被測試執行過。

  • 純文字列印有被覆蓋 → 沒問題
  • 多欄表格列印沒有被觸發 → Bug 3 仍然隱藏
  • 空行列印沒有被觸發 → Bug 4 仍然隱藏

怎麼修的: 在印表機的表格列印方法中加入自動正規化,將欄位比例換算為符合 library 要求的總和 12。這是適配層的修復,所有收據模板都不需要修改。


5. 第三方 library 的地雷——前面都做對了才踩到

怎麼發現的: 修完 Bug 3 之後再上實機,廚房印表機仍然失敗,但錯誤訊息不同了——從「欄位寬度總和必須等於 12」變成「RangeError: Valid value range is empty: 0」。

怎麼找到原因的: 從 log 看到列印標題和桌號成功(兩次資料送出),在第三行(空行)就失敗了。追蹤到第三方 library 的原始碼,發現它在解析文字時會取第一個字元來判斷是否為中文字,但沒有處理空字串的情況,直接對空字串取 text[0] 導致越界。

這個問題一直存在,但之前 Bug 3 擋在前面(程式在表格列印就失敗了,走不到後面的空行列印),前三個 Bug 都修好之後,執行路徑才真正打通,觸發了這個潛在問題。

從另一個角度看,能走到 Bug 4 代表前面的修復都是有效的。

怎麼修的: 在呼叫 library 之前加了空字串的前置檢查,遇到空字串時改用換行指令代替,繞過 library 的問題。


6. try-catch 的範圍太大,把程式碼 bug 和硬體故障混在一起處理

怎麼發現的: 回顧整個除錯過程,Bug 1、3、4 都有一個共同特徵——錯誤被 try-catch 吞掉,回傳 false,沒有任何明顯的異常。在測試中,缺少依賴的情況也被同樣的 try-catch 吞掉,測試照樣通過。

怎麼找到原因的: 看列印方法的 catch 區塊:

 1Future<bool> _printKitchenReceipt(...) async {
 2  try {
 3    // 組裝收據資料
 4    // 組裝收據行
 5    // 呼叫印表機列印
 6    return true;
 7  } catch (e) {
 8    debugPrint('failed: $e');
 9    return false;
10  }
11}

catch (e) 攔截了所有錯誤,不區分類型。但這裡面混了兩種性質不同的錯誤:

  • 印表機故障(連線逾時、無紙、裝置離線)→ 執行期的預期狀況,應該攔截,回傳失敗讓 UI 顯示重印按鈕
  • 程式碼 bug(未初始化的元件、欄位寬度不合法、空字串越界)→ 開發階段就該被發現的問題,不應該被靜默吞掉

四個 Bug 裡有三個屬於後者,全部被同一個 catch (e) 攔住,在開發和測試階段都沒有任何異常跡象。

怎麼修的: 做了三件事:

  1. 定義 PrinterException,專門代表印表機硬體/連線錯誤
  2. 在列印中心(IO 邊界)把印表機拋出的 Exception 包成 PrinterException,但不攔截 Error(程式碼 bug)
  3. 列印方法改為 on PrinterException catch,只處理印表機故障

改動前後的對比:

 1// 改動前:所有錯誤都被吞掉
 2try {
 3  final lines = await receiptBuilder.buildReceiptLines(data, template);  // ← 出錯也被吞
 4  await printCenter.printReceiptLines(lines: lines, printer: printer);   // ← 出錯也被吞
 5  return true;
 6} catch (e) {
 7  return false;
 8}
 9
10// 改動後:資料準備不在 try 裡,只攔截印表機錯誤
11final lines = await receiptBuilder.buildReceiptLines(data, template);  // ← 出錯直接拋出
12
13try {
14  await printCenter.printReceiptLines(lines: lines, printer: printer);
15  return true;
16} on PrinterException catch (e) {  // ← 只攔截印表機故障
17  return false;
18}

改完之後,測試裡缺少依賴的情況不再被吞掉——之前有一組測試預期列印結果是 false(因為缺收據產生器被 catch 吞掉),現在補上依賴後預期改為 true,測試驗證的是真正的列印行為。


從這次經驗歸納的測試方法

一、從呼叫路徑出發,而非從程式碼結構出發

這次犯的最大錯誤是按照「這個 class 有哪些方法」來分配測試,結果每個方法各自通過,但串在一起就出問題。後來改成按「使用者操作觸發了什麼路徑」來規劃:

 1使用者操作                    要測試的完整路徑
 2─────────                    ──────────────
 3追加點餐送出     →  handler.printAppendedOrder
 4                      → _buildItemPrinterMapping  ← 分派邏輯
 5                      → buildReceiptLines          ← 收據組裝
 6                      → printReceiptLines           ← 實際列印
 7                      → printText / printRow        ← 印表機操作
 8
 9點擊重印按鈕     →  retryItemKitchenPrint
10                      → printAppendedOrder(kitchenItemIds: {itemId})

按路徑規劃之後,每個測試案例都會走過完整的鏈路,中間環節的問題自然會被觸發。

二、整合測試與單元測試的分工

這次的六個坑裡,有四個(坑 1、3、4、6)屬於「元件之間銜接」的問題,單元測試各自通過但串接失敗。回頭看分工:

 1                    單元測試                     整合測試
 2─────────────────────────────────────────────────────────
 3測什麼?          單一方法的輸入輸出             多個元件串接的結果
 4假設什麼?        其他元件是正確的               驗證元件之間的銜接
 5能抓到什麼 Bug?  演算法邏輯錯誤                 初始化遺漏、依賴缺失、
 6                                                介面不匹配、狀態傳遞錯誤
 7本案例中          KitchenPrinterConfig           printAppendedOrder +
 8                  .handlesProduct                PrintCenter + FakePrinter +
 9                  → 匹配邏輯正確                 ReceiptBuilderService
10                                                → 端到端路徑正確

這次的經驗是:功能涉及多個元件協作時,只有單元測試是不夠的。整合測試才能抓到元件之間的銜接問題。

三、替 try-catch 設計專門的測試

try-catch 在這次經驗裡反覆出現——Bug 1、3、4 被它吞掉,坑 3 的斷言因為它而失效,坑 6 則是根本性的設計問題。

回顧後歸納的三個對策:

  • 斷言成功路徑的值:不只檢查「沒拋錯」,要檢查回傳值是 true。坑 3 就是因為只檢查 key 存在,沒檢查值
  • 提供完整的依賴:讓 try 區塊能完整執行,而非依賴 catch 來「通過」測試。坑 3 的根因就是缺少收據產生器
  • 寫專門的失敗測試:故意製造失敗條件(如模擬印表機拋出 PrinterException),驗證錯誤處理行為符合預期

四、Fake / Mock 的設計原則

1                    Fake(假實作)               Mock(模擬物件)
2─────────────────────────────────────────────────────────
3適用場景          需要跑通完整路徑               只需驗證互動次數/參數
4本案例            FakeReceiptBuilderService      不適用(需要驗證端到端結果)
5                  FakePrinterAdapter

這次 FakePrinterAdapter 的設計漏掉了父類別繼承方法依賴的內部狀態(坑 2),FakeReceiptBuilderService 的回傳資料只覆蓋了部分分支(坑 4)。後來整理出設計 Fake 時的確認項目:

  • 繼承/實作的方法中,有哪些是上層呼叫者實際會用到的?
  • 這些方法依賴哪些內部狀態(如 late 變數)?
  • Fake 的初始化是否正確建立了這些內部狀態?
  • Fake 回傳的資料是否足以讓下游所有分支都被觸發?

之後可以改善的地方

寫測試時

  • 測試應從入口方法開始驅動,讓中間的邏輯實際執行,避免手動構造中間結果
  • 使用模擬子類別時,確認上層實際呼叫到的繼承方法也有被測試覆蓋
  • 斷言驗證值本身,而非只驗證存在性(containsKey → 直接檢查值)
  • 設計模擬元件的回傳資料時,先確認下游有哪些分支,確認回傳資料能觸發這些分支
  • 回傳資料中加入邊界值——空字串、空列表等

修 Bug 時

  • 修完後確認有測試會實際走到修改的程式碼,否則測試通過不代表修改生效
  • 沿著執行路徑往下看——之前被擋住的程式碼現在可以執行了,那些區段可能存在未發現的問題
  • 如果修改讓新的資料流入第三方 library,檢查那些資料是否有 edge case

設計 try-catch 時

  • 區分「預期的執行期錯誤」和「程式碼 bug」,只攔截前者
  • 定義專用的 exception 類型(如 PrinterException),在 IO 邊界包裝,上層只 catch 這個類型
  • 資料準備、邏輯運算等步驟不要放在 try-catch 裡面,讓錯誤直接拋出

自我檢查清單

寫完測試後可以對照的問題:

  1. 這個測試有走過真實的呼叫路徑嗎?還是只測了資料搬運?
  2. 斷言是驗證「值」還是只驗證「存在」?
  3. 所有依賴都有提供嗎?缺少的依賴會不會被 try-catch 吞掉?
  4. 模擬子類別覆寫的方法之外,繼承的方法有被測到嗎?
  5. 模擬元件回傳的資料有觸發下游的所有分支嗎?
  6. 邊界值(空字串、空列表、null)有出現在測試資料中嗎?
  7. try-catch 的範圍是否只包含 IO 操作?資料準備和邏輯運算是否在 try 外面?
  8. 有寫反向測試(故意觸發錯誤)來確認理解了 Bug 的根因嗎?

本案例的最終測試結構

 128 tests
 2
 3無廚房印表機時的基本行為(4 tests)
 4  └── 基本的 handler 行為,不需要廚房印表機
 5
 6OnlineOrderRecord 模型(7 tests)
 7  └── 單元測試:狀態管理、applyPrintResult
 8
 9FakePrinterAdapter(6 tests)
10  ├── init / sendBytes — 基本功能
11  ├── printText after init — 驗證內部元件初始化(抓 Bug 1)
12  └── printText without init — 反向驗證
13
14KitchenPrinterConfig(2 tests)
15  └── 單元測試:品名匹配邏輯
16
17品項分派邏輯 — 整合測試(4 tests)     ← 全部重寫
18  ├── 2 台空 mapping → odd/even 分配     抓 Bug 2
19  ├── 1 台空 mapping → fallback
20  ├── 明確 mapping 優先匹配
21  └── kitchenItemIds 篩選
22  (全部透過 printAppendedOrder 驅動,有 FakeReceiptBuilderService)
23
24PrintCenter 廚房印表機管理(5 tests)
25  └── 註冊、移除、初始化、向後兼容

最終的修復

Bug修復方式
Bug 1模擬印表機的初始化方法補上內部元件的建立
Bug 2品項分派的 fallback 邏輯改為:唯一一台無對應表 → 全部給它,多台 → 依規則分配
Bug 3多欄表格列印前,自動將欄位比例正規化為符合 library 要求的總和 12
Bug 4文字列印前加入空字串檢查,遇到空字串改用換行指令繞過 library 的問題
設計改善定義 PrinterException,列印方法改為只攔截印表機故障,程式碼 bug 不再被靜默吞掉