3 minutes
另外一種方式的依賴控制
什麼是依賴,為什麼要管理?
任何你不能控制的物件,都是隱藏的依賴
具體來說,如果程式完全不寫測試,那麼其實就不用考慮依賴的問題。
要管理依賴的理由在於給測試一個介入的途徑。
例如說有一個物件是用來追蹤聖誕老人,裡面判斷他有沒有上班。
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)
傳統的方法有三:
- 建構子注入
- 屬性注入
- 方法參數注入
名稱應該足以描述相對應的行為,就不多贅述了。
換言之,在程式碼中,只要是使用
的物件,都要記得可以替換。
舉個例子
(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
這邊先討論幾個問題
- Singleton不是不好,為什麼要用?
- 為什麼不用介面隔開?
- 全域變數很危險,不應該用不是嗎?
Singleton不是不好,為什麼要用?
Singleton並不是不好,不好的話Apple還到處放不是很奇怪?
URLSession.shared
UserDefaults.standard
FileManager.default
Singleton的問題在於它是一個實體型別,無法被抽象化。也就是無法在測試中被替換。當你今天在程式碼中放了URLSession.shared
以後,就註定了他一定會去走真實的網路(用intercepter擋掉另當別論),而沒辦法用MockURLSession
來進行替換
為什麼不用介面隔開?
在看這個影片之前,我也是用protocol
來進行區隔,這也有一個大神的文章可以參考歡迎來到真實世界
先來談談如果用介面隔開URLSession,要做多少動作?
- 製作一個介面
URLSessionProtocol
,方法簽章要跟URLSession中要呼叫的方法一致 - 讓URLSession擴充
URLSessionProtocol
,因為方法已經存在,所以不用另外實作。 - 製作一個測試在用的物件
MockURLSession
,實作介面URLSessionProtocol
,裡面不涉及網路存取,直接回傳值。 - 將原本程式碼呼叫
URLSession.shared
的地方全部抽換掉 - 在測試中將
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://www.codementor.io/koromiko/unit-test-for-networking-ahdpdqr5k