測試全過但有 Bug
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後來補了模擬元件:接收訂單 → 模擬收據產生器 → 列印中心 → 模擬印表機 → 底層
5 ↑
6 但模擬的收據只有純文字行
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) 攔住,在開發和測試階段都沒有任何異常跡象。
怎麼修的: 做了三件事:
- 定義
PrinterException,專門代表印表機硬體/連線錯誤 - 在列印中心(IO 邊界)把印表機拋出的
Exception包成PrinterException,但不攔截Error(程式碼 bug) - 列印方法改為
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 裡面,讓錯誤直接拋出
自我檢查清單
寫完測試後可以對照的問題:
- 這個測試有走過真實的呼叫路徑嗎?還是只測了資料搬運?
- 斷言是驗證「值」還是只驗證「存在」?
- 所有依賴都有提供嗎?缺少的依賴會不會被 try-catch 吞掉?
- 模擬子類別覆寫的方法之外,繼承的方法有被測到嗎?
- 模擬元件回傳的資料有觸發下游的所有分支嗎?
- 邊界值(空字串、空列表、null)有出現在測試資料中嗎?
- try-catch 的範圍是否只包含 IO 操作?資料準備和邏輯運算是否在 try 外面?
- 有寫反向測試(故意觸發錯誤)來確認理解了 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 不再被靜默吞掉 |