# Dependency Injection
## 🎬 Point・Free
* [video](https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy)
* [source code](https://github.com/pointfreeco/episode-code-samples/blob/main/0016-dependency-injection/Environment.playground/Contents.swift)
## GitHub
<img width="300" src="https://i.imgur.com/kECfNXs.png" style="display: block;margin-left: auto;margin-right: auto;width: 50%;" />
1. 串接 GitHub API
2. TableView 呈現結果
3. 右側顯示發佈時間與現在的時間差
[🔗 Demo link](https://github.com/bing-Guo/DependencyInjectionDemo)
## DI with Protocol
```swift=
protocol GitHubProtocol {
func fetchRepos(onComplete completionHandler: (@escaping (Result<[GitHub.Repo], Error>) -> Void))
}
// Live GitHub
struct GitHub: GitHubProtocol {
struct Repo: Decodable {
var archived: Bool
var description: String?
var htmlUrl: URL
var name: String
var pushedAt: Date?
}
func fetchRepos(onComplete completionHandler: (@escaping (Result<[GitHub.Repo], Error>) -> Void)) {
dataTask("orgs/pointfreeco/repos", completionHandler: completionHandler)
}
}
```
```swift=
// ViewModel
class ReposViewModel {
// Inject dependency
let date: () -> Date // Date.init
let gitHub: GitHubProtocol
init(date: @escaping () -> Date = Date.init, gitHub: GitHubProtocol = GitHub()) {
self.date = date
self.gitHub = gitHub
}
}
```
```swift=
// Mock GitHub
struct GitHubMock: GitHubProtocol {
let result: Result<[GitHub.Repo], Error>
// happy path: default return .success case
init(result: Result<[GitHub.Repo], Error> = .success([
GitHub.Repo(
archived: false,
description: "Blob's blog",
htmlUrl: URL(string: "https://www.pointfree.co")!,
name: "Bloblog",
pushedAt: Date(timeIntervalSinceReferenceDate: 547152021)
)
])) {
self.result = result
}
func fetchRepos(onComplete completionHandler: @escaping (Result<[GitHub.Repo], Error>) -> Void) {
completionHandler(result)
}
}
```
使用 protocol 做依賴注入會需要許多樣板,每加入新的方法就需要:
1. 新增新方法到 GitHubProtocol
2. 新增並實作方法到 live GitHub
3. 新增 result 到 mock
4. 更新 mock 的 init 並給定預設值(happy path)
## Without Protocol
文中提供另一種方法解決依賴注入,不需要 protocol
```swift=
// App code
struct Environment {
var date: () -> Date = Date.init
var gitHub = GitHub()
}
var Current = Environment()
```
1. Environment: 結合所有的 dependencies
2. Current: 表示目前的環境,如在 App code 下,稱作為 live
---
```swift=
struct GitHub {
struct Repo: Decodable {
var archived: Bool
var description: String?
var htmlUrl: URL
var name: String
var pushedAt: Date?
}
var fetchRepos = GitHub.fetchRepos(onComplete:)
// Use static
private static func fetchRepos(onComplete completionHandler: (@escaping (Result<[GitHub.Repo], Error>) -> Void)) {
GitHub.dataTask("orgs/pointfreeco/repos", completionHandler: completionHandler)
}
}
```
1. 移除 protocol
2. 取而代之是開放所需要對外的 properties,並將 `GitHub.fetchRepos(onComplete:)` 設為在 live 的預設執行內容
3. 把 fetchRepos() 設為 private,這是 live 的實作內容,不需要開放
4. 把 fetchRepos() 設為 static,文中沒特別說明,但如果不這樣做的話,型態會不正確,會被認定為 GitHub instance 下的 func,後續 mock 時就無法覆寫該 properties。(我理解是這樣,有誤麻煩補充 🧐 )
```swift=
// Static: (@escaping ((Result<[GitHub.Repo], Error>) -> Void)) -> ()
// non-Static: (GitHub) -> (@escaping ((Result<[GitHub.Repo], Error>) -> Void)) -> ()
var fetchRepos = GitHub.fetchRepos(onComplete:)
```
---
```swift=
// ViewModel
class ReposViewModel {
// Don't need to inject properties
func loadRepos() {
// Using Current singleton
Current.gitHub.fetchRepos { [weak self] result in
// do something...
}
}
```
1. 不需要在 init 注入
2. 相依的部分換成 Current
---
```swift=
// Test code
extension Environment {
static let mock = Environment(
date: { Date(timeIntervalSinceReferenceDate: 547152051) },
gitHub: .mock
)
}
extension GitHub {
static let mock = GitHub(
fetchRepos: { callback in
callback(.success([
GitHub.Repo(
archived: false,
description: "Blob's blog",
htmlUrl: URL(string: "https://www.pointfree.co")!,
name: "Bloblog",
pushedAt: Date(timeIntervalSinceReferenceDate: 547152021)
)
]))
}
)
}
```
1. 利用 extension 建立 mock,並給予預設值 (happy path)
---
```swift=
class DependencyInjectionDemoTests: XCTestCase {
var viewModel: ReposViewModel!
override func setUpWithError() throws {
super.setUp()
Current = .mock
viewModel = ReposViewModel()
}
```
1. 在 test code 當中切換 Current
---
```swift=
func testFailureCase() throws {
// given
let expectation = expectation(description: "callback happend")
Current.gitHub.fetchRepos = { callback in
callback(.failure(
NSError(
domain: "co.pointfree",
code: -1,
userInfo: [NSLocalizedDescriptionKey: "Ooops!"]
)
))
}
viewModel.errorOccurredClosure = { _ in
expectation.fulfill()
}
// when
viewModel.loadRepos()
// then
wait(for: [expectation], timeout: 1)
}
```
1. 因為 Current 是 mutable,我們可以針對 Current 直接模擬現實中可能的狀況,比如說 failure 狀況
## Q&A
**以下擷取影片中的對話...**
> 教科書上寫說 Singleton 好壞壞,這裡怎麼創造一個 mega-singleton?還用奇怪的大寫命名?
Singletons 通常不太好測試,導致我們用更複雜的依賴注入方法去解決它,比如說在初始化時注入 protocol type。
這裡提供的解法是用 singleton 去解決 singleton 的問題,它有一系列的 mutable properties,是一個我們可以控制的 singleton,可以依照實際情況去替換內容。
要注意的是,目前為止所提到的 Environment 在 production code 都不應該去修改它,這種把所有 dependencies 放在一起的做法是為了方便測試,並且工作量更小。
大寫的部分,影片中沒特別解釋,個人認為是強調它是 global & mutable,比較像是提醒使用者。
## Conclusion
當初想導入這個寫法,是實際遇到一些狀況
1. 想寫 unit test,但有 dependency (API object)
3. API object 是 objective-C 撰寫且歷史久遠,用上述 protocol 解決的話會遇到許多 swift <-> objective-C 型態不支援的問題
4. 撰寫 protocol 會動到 legacy code,洞可能越挖越大
使用本篇的方法體驗不錯,不用動到原本的 legacy code,也不用建立 protocol,核心想法應該是把 function 當作變數丟來丟去(這似乎在 FP 是個很常見的做法!?),需要的時候就把 function 實作內容更換掉。
## Feedback
1. 測試可能會有平行運行問題,所以覆蓋寫可能要在 setUpWithError, finish 處理
2. 也許可以用 compiler directives 去把 var 變成 let