測試與程式碼品質

Singleton 為何阻礙測試?用相依性注入解決看看

是否曾因為 Singleton 寫不出測試,苦惱到整晚?

閱讀 3 分鐘
Singleton 為何阻礙測試?用相依性注入解決看看 封面圖

是否曾因為 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. 參數太多時,不會變得很凌亂嗎?

沒錯。所以通常會透過建構函式注入一次接收,或把相關項目分組。如果參數持續增加,也可能表示該類別負責太多工作。

用假的 DB 取代真的 DB,這就是核心
用假的 DB 取代真的 DB,這就是核心

如果測試總是被實際資源拖住,就按照今天的方向只改一個地方。

把直接取得物件的程式碼,改成從外部接收。

這個小習慣會讓你更願意撰寫測試。祝今天也能愉快寫程式!

延伸閱讀