是否曾因為 Singleton 寫不出測試,苦惱到整晚?
明明邏輯沒問題,一加測試卻不斷存取實際資料庫、呼叫實際 API。
先說結論。
Singleton 會在程式碼中直接取得物件,因此測試時無法換成假的物件。
相反地,相依性注入會從外部提供所需物件,因此能在測試中自由替換。
今天就搭配實際程式碼,逐步說明兩者的差異。
Singleton 為什麼會阻礙測試?
Singleton 是整個程式中只存在一個的物件。
確實很方便,因為從任何地方只要一行 Database.shared 就能取用。
問題就從這裡開始。
如果在函式內直接取得物件,該函式就會緊密耦合到真正的資料庫。
測試時沒有辦法告訴它:「不要用真的,改用假的 DB。」
// 在函式內直接取用 Singleton
func saveOrder(_ order: Order) {
// 緊密耦合到真正的 DB
let db = Database.shared
db.insert(order) // 測試中沒有方法阻止它
}
要測試這段程式碼,真正的 DB 必須啟動。
DB 關閉時,測試也會一起失敗。
不是邏輯錯了,而是基礎設施讓測試亮起紅燈。
相依性注入要怎麼做?
相依性注入沒有想像中複雜。
不要在函式內取得物件,只要在外部建立後傳入即可。
就如字面意思,把需要的東西「注入」進去。
// DB從外部以參數接收
func saveOrder(_ order: Order, db: Database) {
db.insert(order) // 是哪一種 DB由呼叫端決定
}
差別就只有一行,對吧?
只是把原本透過 Database.shared 直接取得的物件,改成由參數接收。
但這個小改變會徹底改變測試。
正式程式碼傳入真正的 DB,測試則傳入假的 DB 即可。
使用由建構函式接收的方式也很常見。
class OrderService {
private let db: Database
// 建立物件時從外部接收並保存
init(db: Database) { self.db = db }
}
接收一次後,類別內任何地方都能以 self.db 使用,整潔許多。
在測試中替換成假的物件
現在進入真正有趣的部分。
既然改成從外部接收相依性,測試時只要放入假的 DB,取代真正的 DB(通常稱為 Mock)即可。
@Test func 儲存_訂單後,會將_DB傳給_() {
let fake = FakeDatabase() // 不是真的,而是假的
saveOrder(Order(), db: fake)
#expect(fake.savedCount == 1) // 只確認是否呼叫儲存
}
不需要啟動真正的 DB。
也不需要網路或外部伺服器,在記憶體中轉眼就能完成。
因此測試更快,更重要的是更穩定。
不受外部狀況影響,只驗證邏輯本身。
Singleton vs. 相依性注入,一眼看懂差異
我把兩者的差異整理成表格了。
| 分類 | 直接使用 Singleton | 相依性注入 |
|---|---|---|
| 取得物件的位置 | 在函式內直接取出 | 從外部傳入 |
| 替換假的物件 | 實際上不可能 | 可以自由替換 |
| 測試速度 | 慢(需要實際資源) | 快(在記憶體中處理) |
| 測試穩定性 | 受外部狀況影響 | 只能驗證邏輯 |
| 耦合度 | 高 | 低 |
Singleton 本身不一定是壞事。
像設定值這種確實只需要一個的情況,就很方便。
但「在任何地方直接取用」的習慣才是問題,因為它會綁住測試的手腳。
常見問題(Q&A)
Q. 要做相依性注入,一定需要 Swinject 這類 DI 函式庫嗎?
不用。像今天的程式碼一樣,透過參數或建構函式傳入,就已經是相依性注入。函式庫只是自動管理這件事。
Q. 參數太多時,不會變得很凌亂嗎?
沒錯。所以通常會透過建構函式注入一次接收,或把相關項目分組。如果參數持續增加,也可能表示該類別負責太多工作。
如果測試總是被實際資源拖住,就按照今天的方向只改一個地方。
把直接取得物件的程式碼,改成從外部接收。
這個小習慣會讓你更願意撰寫測試。祝今天也能愉快寫程式!

