什麼是依賴,為什麼要管理?

任何你不能控制的物件,都是隱藏的依賴

具體來說,如果程式完全不寫測試,那麼其實就不用考慮依賴的問題。

要管理依賴的理由在於給測試一個介入的途徑。

例如說有一個物件是用來追蹤聖誕老人,裡面判斷他有沒有上班。

struct SantaClausTracker {
  
  func isWorking() -> Bool {
    let today = Date()
    let month = Calendar.current.components(.month, today)
    let day = Calendar.current.components(.day, today)
    return month == 12 && day == 25
  }

}

顯然,要這個方法回傳true的話,就只能在聖誕節當天進行測試。 其他日子回傳都是false

這個方法裡面有兩個依賴,一個是Date(),另一個則是Calendar.current。 如果想要把這些依賴變得可控,那麼就是要透過外部傳入。也就是依賴注入(dependency injection)

傳統的方法有三:

  1. 建構子注入
  2. 屬性注入
  3. 方法參數注入

名稱應該足以描述相對應的行為,就不多贅述了。

換言之,在程式碼中,只要是使用的物件,都要記得可以替換。 舉個例子

(X
func somefunction() {
    Date() 
}

(O
func somefunction(date: Date) {
    date 
}

(X
func somefunction() {
    URLSession.share
}

(🔺
func somefunction(session: URLSession) {
    session
}

那麼,顯然會出現的問題就是「依賴要怎麼來

例如說一個App畫面堆疊是這樣

先不討論畫面層級太深的問題這可以看一個精彩的影片「至簡暢銷

graph LR
View1 --> View2 --> View3 --> View4

今天View4有呼叫API的需要,View2 , 3沒有。 要present View4只能夠過View3(MVC)或是View3的Coordinator(MVVM C) 勢必View2, 3都必須要攜帶一個用不到的物件。

當今天底層越多,依賴拆分的越細,就中間的層級就容易要傳遞一些用不到的物件。

久了就不容易維護,不容易維護就等於隱藏的Bug

以前寫過類似的程式碼,中間傳遞的參數多了,就需要使用DTO來打包,多一層又顯得冗余。

這時候就看到一個突破天際的想法How to control the World

強烈建議看影片

強烈建議看影片

強烈建議看影片

先來看看實作,把需要的依賴宣告在一個struct中

struct World {
  var date = { Date() }
}

這邊要注意的是並不是透過「方法」來取值,例如說 func getDate() -> Date { ... } 因為方法的實作是不可異動的,本質上還是依賴,只是把依賴集中到一個地方管理。

也不是直接是一個Date屬性 因為當你存取Current.date時,每次取的都是不一樣的值 如果想要每次都取得同一個時間的話,就做不到這個需求

當然這邊也可以把這個struct改寫成protocol,然後實作不同的物件來提供不同的測試需要,不過那就是另外一回事了。

說回正題,當你宣告了這樣的struct,你還需要一個全域變數

var Current = World()

這是一個Singleton皮諾可,這個直接電死

別急,這時候這個設計神的地方來了。

用上面的範例來實作的話就會變這樣

struct SantaClusTracker {
  
  func isWorking() -> Bool {
    //暫時先改一個,baby step
    let today = Current.date()
    let month = Calendar.current.components(.month, today)
    let day = Calendar.current.components(.day, today)
    return month == 12 && day == 25
  }

}

因為Current是一個var,所以裡面的屬性是可以異動的。 因為date是一個回傳Date的function,所以實作內容也是可以異動的

今天需要測試的時候,可以直接對Current設值。

func test_Date_Is_Christmas_SantaClausTracker_isWorking_should_return_true() throws {
  /* 3A原則,具體可以參考91的文章或是聽他的課!獲益良多 */
  //Arrange
  let target = SantaClausTracker()
  Current.date = { 
     let formatter = DateFormatter()
     formatter.dateFormat = "yyyy/MM/dd"
     return formatter.date(string: "2021/12/25")!
  }
  
  //Action
  let actual = target.isWorking()
  
  //Assert
  let expect = true
  XCTAssertEqual(actual, expect)
}

如此,就算是在其他日子,也能檢查在聖誕節的時候是不是會回傳true

這邊先討論幾個問題

  1. Singleton不是不好,為什麼要用?
  2. 為什麼不用介面隔開?
  3. 全域變數很危險,不應該用不是嗎?

Singleton不是不好,為什麼要用?

Singleton並不是不好,不好的話Apple還到處放不是很奇怪?

URLSession.shared

UserDefaults.standard

FileManager.default

Singleton的問題在於它是一個實體型別,無法被抽象化。也就是無法在測試中被替換。當你今天在程式碼中放了URLSession.shared以後,就註定了他一定會去走真實的網路(用intercepter擋掉另當別論),而沒辦法用MockURLSession來進行替換

為什麼不用介面隔開?

在看這個影片之前,我也是用protocol來進行區隔,這也有一個大神的文章可以參考歡迎來到真實世界

先來談談如果用介面隔開URLSession,要做多少動作?

  1. 製作一個介面URLSessionProtocol,方法簽章要跟URLSession中要呼叫的方法一致
  2. 讓URLSession擴充URLSessionProtocol,因為方法已經存在,所以不用另外實作。
  3. 製作一個測試在用的物件MockURLSession,實作介面URLSessionProtocol,裡面不涉及網路存取,直接回傳值。
  4. 將原本程式碼呼叫URLSession.shared的地方全部抽換掉
  5. 在測試中將MockURLSession注入到測試目標中

首先,這個做法對程式碼改動幅度比較大,當早期沒做,後面要抽換的時候改動幅度就會很大,也是存在風險。

其次,如果介面裡面的方法開始變多以後,一個新的Mock物件實作起來不容易。

例如說一個資料庫存取介面,有四個方法CRUD

那麼建立一個新的Mock物件就要實作四個方法。

而一個正常的介面方法都不止四個

全域變數很危險,不應該用不是嗎?

全域變數的危險之處在於「你不知道什麼時候被改動

誠然,設定為變數是存在風險的,但是不應該把Current當成是一個傳遞、暫存參數的地方。而是一個依賴保管區(Production時)、依賴注入區(Develop時)

說回這個方法

如果今天要測試使用者登入「成功」與「失敗」的情境


struct UserAPIClient {
    func login(account: String, password: String, complectionHandler: @escaping (Bool) -> Void ) {
        let url = URL(string: "API Path")! // <-- 這裡一定run time error
        URLSession.shared.dataTask(with: url) { data, _, _ in
            complectionHandler(data == nil)
        }
    }
}

struct ContentView: View {
  
    @State private var loginResult: Bool = false
    
    var body: some View {
        Text("Login \(loginResult ? "Success" : "Failure")")
            .onAppear {
                UserAPIClient().login(account: "account", password: "password") { result in
                    loginResult = result
                }
            }
    }
}

UserAPIClient有一個一定會在run time炸掉的地方,那就是URL(string: "API Path")!

假想這是Server暫時不可用,或是網路斷線的情境吧。

那這種時候就不用測試了嗎?沒錯!後台修好再叫我

當然不可能,所以這時候就需要脫離實際環境來測試功能是否正常。

protocol的方式來進行脫鉤是這樣實作的

//1. 定義協議
protocol UserAPIClientProtocol {
  func login(account: String, password: String, complectionHandler: @escaping (Bool) -> Void )
}


//2. 讓原本的物件擴充這個協議
extension UserAPIClient: UserAPIClientProtocol { }


//3. 建立測試物件,因為測試情境有「成功」「失敗」,所以回傳值用建構注入
struct MockUserAPIClient: UserAPIClientProtocol {
  
  var loginResult: Bool
  
  func login(account: String, password: String, complectionHandler: @escaping (Bool) -> Void ) {
      complectionHandler(loginResult)
  }
}


//4. 將原本的型別用介面抽換(呼叫的時候要注入)
struct ContentView: View {
  
    var userAPIClient: UserAPIClientProtocol
    
    @State private var loginResult: Bool = false
    
    var body: some View {
        Text("Login \(loginResult ? "Success" : "Failure")")
            .onAppear {
                userAPIClient.login(account: "acc", password: "pass") { result in
                    loginResult = result
                }
            }
    }
}

//5. 修改呼叫端
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView(userAPIClient: MockUserAPIClient(loginResult: true))
    }
}

如果用World來控制的話,是這個樣子的

struct UserAPI {
    var login = UserAPIClient().login(account:password:complectionHandler:)
}

struct World {
    var userAPI = UserAPI()
}

var Current = World()

struct ContentView: View {
  
    @State private var loginResult: Bool = false
    
    var body: some View {
        Text("Login \(loginResult ? "Success" : "Failure")")
            .onAppear {
                Current.userAPI.login("account", "password") { result in
                    loginResult = result
                }
            }
    }
}


//修改呼叫端
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        //正常不會在這邊修改,這邊只是懶得把資料切乾淨所以放著舉例一下
        Current.userAPI.login = { _, _, callback in
            callback(true)
        }
        return ContentView()
    }
}

看起來有點差異,但是好像又差異不大? 當你再遇到這樣的需求時

graph LR
View1 --> View2 --> View3 --> View4

View2, 3就不需要攜帶UserAPIClient跑來跑去了

最重要的是 當你的測試情境需要不同的回傳值時,不再需要像上面的MockUserAPIClient一樣透過各種注入,考慮各種測試情境要呼叫方法時的通用性,而是可以直接針對特定方法改寫方法實作內容。

參考文章:

https://vimeo.com/291588126

https://www.codementor.io/koromiko/unit-test-for-networking-ahdpdqr5k