障礙不只是只有一個實例,而是呼叫端直接選擇具體儲存實作。把儲存角色表達成協定並注入實作,就能在沒有網路的情況下檢查服務的判斷。
原理與設計判斷
從外部接收物件,不代表必然能替換實作。參數若仍是具體Database型別,替身也必須滿足同一型別限制。此處用OrderStore建立小型契約,服務接收any OrderStore。符合契約的單例也可以被注入。 第一手資料
重現程式碼
完整範例定義了訂單、錯誤、協定、服務及記錄型儲存替身。測試正常傳入的訂單值、空ID是否在寫入前被拒絕,以及儲存失敗是否傳到呼叫端。替身兼具記錄呼叫的spy與設定失敗行為的stub角色。
這個獨立範例已於 2026-10-23 使用 Apple Swift 6.4,以 Swift 6 語言模式在 arm64 macOS 目標環境執行。不需要網路或額外套件。
請在下載檔案的資料夾執行。檢查不符時,precondition 會中止程式。請勿使用 -Ounchecked,因為它會移除這些檢查。
swift -swift-version 6 singleton-testing-dependency-injection.swift
struct Order: Equatable { let id: String }
enum SaveError: Error { case invalidID, unavailable }
protocol OrderStore {
func insert(_ order: Order) throws
}
struct OrderService {
let store: any OrderStore
func save(_ order: Order) throws {
guard !order.id.isEmpty else { throw SaveError.invalidID }
try store.insert(order)
}
}
final class RecordingStore: OrderStore {
var calls: [Order] = []
var shouldFail = false
func insert(_ order: Order) throws {
calls.append(order)
if shouldFail { throw SaveError.unavailable }
}
}
let store = RecordingStore()
let service = OrderService(store: store)
let order = Order(id: "order-42")
try service.save(order)
precondition(store.calls == [order])
do {
try service.save(Order(id: ""))
preconditionFailure("invalid input was accepted")
} catch SaveError.invalidID {}
precondition(store.calls.count == 1)
store.shouldFail = true
do {
try service.save(order)
preconditionFailure("storage failure was swallowed")
} catch SaveError.unavailable {}
precondition(store.calls.count == 2)
print("saved=order-42 invalid-input-writes=0 storage-error=propagated")
執行結果與解讀
saved=order-42 invalid-input-writes=0 storage-error=propagated
正常請求傳入order-42。空ID沒有增加儲存呼叫,儲存錯誤也沒有被吞掉。這裡除了呼叫次數,也檢查了傳入值與兩種失敗路徑。
適用範圍與限制
協定無法證明真實資料庫的交易、唯一性限制或網路行為正確,仍須另外做整合測試。不是所有依賴都需要抽象化,注入全域共用狀態也不會自動讓測試彼此隔離。
接下來要確認的事
從一個外部I/O邊界開始建立小型契約。確認正式儲存實作與替身對錯誤的定義一致,再於組裝應用程式時選擇實作。若改動業務規則,請與保留行為的重構分開處理。
來源與驗證
- Inversion of Control Containers and the Dependency Injection PatternMartin Fowler · 作者原文 · 查核 2026年10月23日依據: 實作契約與說明的依據。執行輸出另外透過本文所附實驗確認。

