觸發場景:Flutter 書籍管理 App 的一個版本完成 113 張票、單元測試 100% 通過、收尾驗收通過;實機測試(Android 實體機)找出五個問題——其中三個是「功能做完了、使用者到不了」 疑問來源:規格審查、測試、版本收尾三道防線都在運作,為什麼五個問題全數漏網? 整理目的:記下佔位實作讓測試綠燈的機制、反向追溯提案與 use case 文件的結果、以及修補時「規格層/測試層/發版層」的分層落點 本文邊界:素材是該專案 v0.38.1 修復批次的分析記錄;DDD 觀念層的判準另見 組裝層的可達性


五個問題、三種佔位、兩種平台差異

實機日誌與使用者操作對出五個問題,斷裂點分成兩組:

問題現象斷裂點
Tag 管理崩壞provider 佔位 throw「requires override」在 production 被觸發,畫面連鎖報錯DI 組裝
掃描/匯入失聯首頁按鈕顯示「功能開發中」提示,路由表把 /scan/import 指向 ComingSoon 佔位頁——掃描與匯入的 MVVM 全套均已完成路由表 + UI callback
資料管理頁按鈕沒反應四顆按鈕的 onPressed 全是空實作UI callback
啟動框架警告binding 在 root zone 初始化、runApp 在 runZonedGuarded 子 zone,非同步例外可能逃出攔截框架初始化順序(平台層)
開庫失敗Android 上 PRAGMA journal_mode = WAL 以 execSQL 執行被拒、資料庫開啟直接失敗、全部持久化功能不可用SQLite Android 語意(平台層)

前三個是同一種形狀:功能單元全部存在、對應測試全部通過,斷的是「把功能接到入口」的那一段——DI 容器沒接上真實依賴、路由表沒指向真實頁面、按鈕沒接上導航。後兩個是另一種形狀:程式碼在 host 測試環境行為正確,在目標平台的語意下失效。

佔位讓測試綠燈的三層共振

單一防線失效不足以讓五個問題全數漏網,三層機制疊在一起才做到:

第一層在規格。use case 的成功保證寫的是功能行為——「成功匯入 X 本書籍」「實體書籍立即新增到書庫」——入口是否接上不在任何驗收條款裡。文件描述了使用者「點擊匯入按鈕」,但按鈕、路由、頁面這條鏈由誰負責接、接完長什麼樣,設計文件裡沒有一個字。

第二層在測試設計。從 use case 推導出的測試落在 unit 與 widget 層,用 ProviderScope(overrides: [...]) 注入 mock。override 是 Riverpod 給的正當測試 seam——它讓 domain 與 ViewModel 可以脫離 infrastructure 單獨驗證,這是分層架構承諾的兌現。代價在 seam 的另一面:override 換掉的正是 production 的組裝——組裝完沒完成,這套測試從頭到尾無人作證。

第三層在佔位本身。ComingSoon 頁是合法 widget、空 onPressed 是合法函式、throw 佔位的 provider 在 override 之下永遠不會被解析——佔位不觸發任何紅燈,測試斷言的是 mock 環境下的行為,佔位在測試的視野之外。三層疊加的結果:佔位通過了全部以 mock 為基礎的驗收,一路走到使用者手上。

override 的雙面性

override 同時是解藥跟盲點,而且是同一個機制。判讀訊號有一條可操作的分界——override 出現在個別測試裡是正當用法;整個專案找不到任何一個「無 override 環境解析 provider」的測試,才是組裝層裸奔的訊號。這個專案屬於後者:grep 全部測試,production 等效環境(真實路由表、零 override 的 ProviderScope)的案例數是零。mock 遮蔽的另一種病因——替身的協定語意與真實體不符、而非組裝缺席——見 192 個測試全過、實機全壞

反向追溯:設計文件裡找不到「入口」

修復批次先做了一件事:拿五個問題反向追溯提案(PROP)、use case(UC)、規格(SPEC),確認每個問題在設計文件裡的對應條目長什麼樣。結果分成兩型:

問題設計文件對應缺口型態
Tag provider 佔位提案定義了七個 CRUD 方法與 UI 形態有功能定義、驗收不含可達性
路由佔位UC 寫了「使用者點擊按鈕」有行為描述、接線無人認領
空 onPressedUC 寫了「進入資料管理頁面、點擊匯出」有操作描述、callback 實作不在驗收內
Zone 警告全文無對應平台層細節,設計文件構不到
PRAGMA 失敗全文無對應平台層細節,設計文件構不到

追溯給出的結論:提案端寫滿了能力(tag 管理要有七個 CRUD)、use case 端寫滿了行為(使用者點擊匯入按鈕),追到「按鈕由誰放上畫面、路由由誰指向真頁面」時,兩類文件都翻不到答案——三個佔位問題全部落在這條縫裡。

前置條件被當成免責條款

追溯裡有一個細節要單獨展開。匯出功能的 use case 前置條件寫著「書庫中存在至少一本書」——這條前置條件已經標出了一個狀態分支:書庫是空的時候會怎樣?匯出按鈕在哪?沒有任何文件回答。實機上使用者的書庫是空的(開庫失敗的下游效應),畫面走了空狀態分支、匯出按鈕只存在於正常狀態分支,使用者的回報是「匯出功能不見了」。

前置條件的每一條都隱含一個「不滿足時會怎樣」的分支。把前置條件當成限定範圍的免責條款用,分支就無人設計;把它當成狀態枚舉的線索用,空狀態的畫面就會在設計期被逼著給出答案。

修補的分層落點

五個問題各自的修法:三個接線問題把真實依賴、真實頁面、導航 callback 接回入口;zone 警告把 binding 初始化移進 runZonedGuarded、與 runApp 收在同一個 zone;PRAGMA 失敗把 journal mode 設定改走 rawQuery(回傳結果列的查詢路徑)。修掉之外,修補按層放了三組防線,涵蓋範圍超出「把五個問題修掉」本身:

規格層:use case 文件補上「端到端可達性」成功保證條款(路由指向真實頁面、callback 已接線、provider 在無 override 環境可解析),並新增 use case 撰寫檢核:名詞可定位、路徑連通、狀態完備、環境差異——問句全文與判定方式見 組裝層的可達性

測試層:區分行為測試與接線測試。行為測試維持 mock override(驗功能邏輯);接線測試零 override、用真實路由表與真實依賴,只驗「port 有沒有插上 adapter」——本案是 local-first 單機 app、零 override 做得到全量;有遠端依賴的專案,零 override 的範圍是組裝路徑、最外圈 infrastructure 在邊界替換。修復前先補了十三個案例,其中十一個對目標行為斷言、修復前確定性紅燈——修復票以這批測試變綠為驗收點。

發版層:兩道互補的防線。佔位掃描進發版前置檢查——ComingSoon、UnimplementedError、空 onPressed 都是靜態可 grep 的,掃到就警告;實機冒煙清單補掃描構不到的部分——平台語意差異只有在目標平台執行才會暴露,清單的三層(啟動健康、use case happy path 走查、平台敏感點)在打 tag 前人工走一輪。

平台層的兩個問題(zone、PRAGMA)在設計文件與 host 測試都無處可防:Android execSQL 拒絕有回傳列的 SQL 這件事,sqflite 的 ffi 測試環境重現不出來。能做的是把「這步只有實機能驗」在設計期標記出來,讓冒煙清單有明確來源——漏網的位置從使用者手上移到發版前的清單上。

判準收束

  • 綠燈的證言範圍:一個測試證明的是它執行環境下的行為。override 環境的綠燈對 production 組裝零證言——證言缺口要由接線測試補、而非把行為測試的 mock 拆掉。
  • 功能完成的定義:從入口可達才算完成。「MVVM 全套完成」與「使用者用得到」之間隔著組裝層,這段距離在票務上要有自己的驗收條款。
  • 佔位的紀律:佔位是合法的開發中間態,但它需要一個攔截點(發版掃描)。缺攔截點的佔位會通過所有以 mock 為基礎的驗收,終點站是使用者的回報。
  • 缺口分類先於對策:這批修復裡,三個接線問題走規格條款加接線測試,zone 與 PRAGMA 走冒煙清單。對五個問題一律用「補測試」回應,補到的只是 host 環境的綠燈——zone 與 PRAGMA 要目標平台實際執行才暴露。