80 lines
3.0 KiB
Markdown
80 lines
3.0 KiB
Markdown
|
|
---
|
|||
|
|
name: swift-protocol-di-testing
|
|||
|
|
description: 使用基於協定 (Protocol) 的依賴注入 (DI) 來編寫可測試的 Swift 程式碼 — 透過聚焦的協定與 Swift Testing 模擬檔案系統、網路與外部 API。
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# Swift 基於協定的依賴注入 (DI) 與測試實踐
|
|||
|
|
|
|||
|
|
這是透過將外部依賴(檔案系統、網路、iCloud)抽象化為小型、聚焦的協定,使 Swift 程式碼具備高測試性的模式。這能實現無 I/O 的確定性測試。
|
|||
|
|
|
|||
|
|
## 何時啟用
|
|||
|
|
|
|||
|
|
- 撰寫需存取檔案系統、網路或外部 API 的 Swift 程式碼時。
|
|||
|
|
- 需要測試在不觸發真實失敗的情況下,驗證錯誤處理路徑。
|
|||
|
|
- 建構需跨環境(App、測試、SwiftUI Preview)運行的模組。
|
|||
|
|
- 使用 Swift Concurrency(Actors, Sendable)設計可測試架構。
|
|||
|
|
|
|||
|
|
## 核心模式
|
|||
|
|
|
|||
|
|
### 1. 定義小型且聚焦的協定
|
|||
|
|
每個協定僅處理一個外部關注點。
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
// 檔案存取行為抽象化
|
|||
|
|
public protocol FileAccessorProviding: Sendable {
|
|||
|
|
func read(from url: URL) throws -> Data
|
|||
|
|
func write(_ data: Data, to url: URL) throws
|
|||
|
|
func fileExists(at url: URL) -> Bool
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 2. 實作生產環境版本與測試模擬版本
|
|||
|
|
- **生產版本 (Default)**:封裝真實的 `FileManager` 或 `URLSession` 操作。
|
|||
|
|
- **測試版本 (Mock)**:內部使用字典或記憶體狀態,模擬檔案操作與注入預期的 `Error`。
|
|||
|
|
|
|||
|
|
### 3. 利用預設參數進行依賴注入
|
|||
|
|
在建構子中使用預設參數,讓生產環境保持簡潔,同時允許測試案例傳入 Mock:
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
public actor SyncManager {
|
|||
|
|
private let fileAccessor: FileAccessorProviding
|
|||
|
|
|
|||
|
|
public init(fileAccessor: FileAccessorProviding = DefaultFileAccessor()) {
|
|||
|
|
self.fileAccessor = fileAccessor
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
public func load() async throws -> Data {
|
|||
|
|
try fileAccessor.read(from: someURL)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
### 4. 使用 Swift Testing 進行驗證
|
|||
|
|
利用 `Swift Testing` 的 `@Test` 巨集與 `#expect` 語法驗證邏輯與例外:
|
|||
|
|
|
|||
|
|
```swift
|
|||
|
|
@Test("驗證資料讀取錯誤處理")
|
|||
|
|
func testReadError() async {
|
|||
|
|
let mock = MockFileAccessor()
|
|||
|
|
mock.readError = CocoaError(.fileReadCorruptFile)
|
|||
|
|
|
|||
|
|
let manager = SyncManager(fileAccessor: mock)
|
|||
|
|
await #expect(throws: Error.self) {
|
|||
|
|
try await manager.load()
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## 實踐之最佳實踐
|
|||
|
|
|
|||
|
|
- **單一職責原則**:協定應儘可能微小,避免開發「上帝協定」。
|
|||
|
|
- **Sendable 順應性**:由於 Actor 運算,跨邊界傳遞的協定必須標註為 `Sendable`。
|
|||
|
|
- **僅針對邊界進行 Mock**:僅模擬外部資源(檔案、網路),內部的邏輯類型不需過度抽象化。
|
|||
|
|
- **模擬錯誤路徑**:這是 DI 最大的價值,確保系統在真實故障發生前已經過充分測試。
|
|||
|
|
|
|||
|
|
## 應避免的反模式
|
|||
|
|
|
|||
|
|
- 使用 `#if DEBUG` 條件編譯來切換邏輯,這會破壞程式碼的純粹性。
|
|||
|
|
- 過度設計 (Over-engineering):若該類型沒有外部依賴或不具備副作用,不需強行加上協定層。
|
|||
|
|
- 忘記 Actor 呼叫是非同步的,漏掉 `await` 關鍵字。
|