- 原文地址:Avoiding force unwrapping in Swift unit tests
- 原文作者:John
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:RickeyBoy
- 校對者:YinTokey
避免 Swift 單元測試中的強制解析
強制解析(使用 !
)是 Swift 語言中不可或缺的一個重要特點(特別是和 Objective-C 的介面混合使用時)。它迴避了一些其他問題,使得 Swift 語言變得更加優秀。比如 處理 Swift 中非可選的可選值型別 這篇文章中,在專案邏輯需要時使用強制解析去處理可選型別,將導致一些離奇的情況和崩潰。
所以儘可能地避免使用強制解析,將有助於搭建更加穩定的應用,並且在發生錯誤時提供更好的報錯資訊。那麼如果是編寫測試時,情況會怎麼樣呢?安全地處理可選型別和未知型別需要大量的程式碼,那麼問題就在於我們是否願意為編寫測試做所有的額外工作。這就是我們這周將要探討的問題,讓我們開始深入研究吧!
測試程式碼 vs 產品程式碼
當編寫測試程式碼時,我們經常明確區分測試程式碼和產品程式碼。儘管保持這兩部分程式碼的分離十分重要(我們不希望意外地讓我們的模擬測試物件成為 App Store 上架的部分?),但就程式碼質量來說,沒有必要進行明顯區分。
如果你思考一下的話,我們想要對移交給使用者的程式碼進行高標準的要求,原因是什麼呢?
- 我們想要我們的 app 為使用者穩定、流暢地執行。
- 我們想要我們的 app 在未來易於維護和修改。
- 我們想要更容易讓新人融入我們的團隊。
現在如果反過來考慮我們的測試,我們想要避免哪些事情呢?
- 測試不穩定、脆弱、難於除錯。
- 當我們的 app 增加了新功能時,我們的測試程式碼需要花費大量時間來維護和升級。
- 測試程式碼對於加入團隊的新人來說難於理解。
你可能已經理解我所講的內容了 ?。
之前很長的時間,我曾認為測試程式碼只是一些我快速堆砌的程式碼,因為有人告訴我必須要編寫測試。我不那麼在乎它們的質量,因為我將它視為一件瑣事,並不將它放在首位。然而,一旦我因為編寫測試而發現驗證自己的程式碼有多麼快,以及對自己有多麼自信 —— 我對測試的態度就開始了轉變。
所現在我相信對於測試程式碼,和將要移交的產品程式碼進行同等的高標準要求是非常重要的。因為我們配套的測試是需要我們長期使用、擴充和掌握的,我們理應讓這些工作更容易完成。
強制解析的問題
那麼這一切與 Swift 中的強制解析有什麼關係呢??
有時必須要強制解析,很容易編寫一個 “go-to solution” 的測試。讓我們來看一個例子,測試 UserService
實現的登陸機制是否正常工作:
class UserServiceTests: XCTestCase {
func testLoggingIn() {
// 為了登陸終端
// 構建一個永遠返回成功的模擬物件
let networkManager = NetworkManagerMock()
networkManager.mockResponse(forEndpoint: .login, with: [
"name": "John",
"age": 30
])
// 構建 service 物件以及登入
let service = UserService(networkManager: networkManager)
service.login(withUsername: "john", password: "password")
// 現在我們想要基於已登陸的使用者進行斷言,
// 這是可選型別,所以我們對它進行強制解析
let user = service.loggedInUser!
XCTAssertEqual(user.name, "John")
XCTAssertEqual(user.age, 30)
}
}
複製程式碼
如你所見,在進行斷言之前,我們強制解析了 service 物件的 loggedInUser
屬性。像上面這樣的做法並不是絕對意義上的錯,但是如果這個測試因為一些原因開始失敗,就可能會導致一些問題。
假設某人(記住,“某人”可能就是“未來的你自己”?)改變了網路部分的程式碼,導致上述測試開始崩潰。如果這樣的事情發生了,錯誤資訊可能只會像下面這樣:
Fatal error: Unexpectedly found nil while unwrapping an Optional value
複製程式碼
儘管用 Xcode 本地執行時這不是個大問題(因為錯誤會被關聯地顯示 —— 至少在大多數時候 ?),但當連續地整體執行整個專案時,它可能問題重重。上述的錯誤資訊可能出現在巨大的“文字牆”中,導致難以看出錯誤的來源。更嚴重的是,它會阻止後續的測試被執行(因為測試程式會崩潰),這將導致修復工作進展緩慢並且令人煩躁。
Guard 和 XCTFail
一個潛在的解決上述問題的方式是簡單地使用 guard
宣告,優雅地解析問題中的可選型別,如果解析失敗再呼叫 XCTFail
即可,就像下面這樣:
guard let user = service.loggedInUser else {
XCTFail("Expected a user to be logged in at this point")
return
}
複製程式碼
儘管上述做法在某些情況下是正確的做法,但事實上我推薦避免使用它 —— 因為它向你的測試中增加了控制流。為了穩定性和可預測性,你通常希望測試只是簡單的遵循 given,when,then 結構,並且增加控制流會使得測試程式碼難於理解。如果你真的非常倒黴,控制流可能成為誤報的起源(對此之後的文章會有更多的相關內容)。
保持可選型別
另一個方法是讓可選型別一直保持可選。這在某些使用情況下完全可用,包括我們 UserManager
的例子。因為我們對已經登入的 user 的 name
和 age
屬性使用了斷言,如果任意一個屬性為 nil
,我們會自動得到錯誤提示。同時如果我們對 user 使用額外的 XCTAssertNotNil
檢查,我們就能得到一個非常完整的診斷資訊。
let user = service.loggedInUser
XCTAssertNotNil(user, "Expected a user to be logged in at this point")
XCTAssertEqual(user?.name, "John")
XCTAssertEqual(user?.age, 30)
複製程式碼
現在如果我們的測試開始出錯了,我們就能得到如下資訊:
XCTAssertNotNil failed - Expected a user to be logged in at this point
XCTAssertEqual failed: ("nil") is not equal to ("Optional("John")")
XCTAssertEqual failed: ("nil") is not equal to ("Optional(30)")
複製程式碼
這讓我們能夠更加容易地知道發生錯誤的地方,以及該從哪裡入手去除錯、解決這個錯誤 ?。
使用 throw 的測試
第三個選擇在某些情況下是非常有用的,就是將返回可選型別的 API 替換為 throwing API。Swift 中的 throwing API 的優雅之處在於,需要時它能夠非常容易地被當成可選型別使用。所以很多時候選擇採用 throwing 方法,不需要犧牲任何的可用性。比如說,假設我們有一個 EndpointURLFactory
類,被用來在我們的 app 中生成特定終端的 URL,這顯然會返回可選型別:
class EndpointURLFactory {
func makeURL(for endpoint: Endpoint) -> URL? {
...
}
}
複製程式碼
現在我們將其轉換為採用 throwing API,像這樣:
class EndpointURLFactory {
func makeURL(for endpoint: Endpoint) throws -> URL {
...
}
}
複製程式碼
當我們仍然想得到一個可選型別的 URL 時,我們只需要使用 try?
命令去呼叫它:
let loginEndpoint = try? urlFactory.makeURL(for: .login)
複製程式碼
就測試而言,上述這種做法的最大好處在於可以在測試中輕鬆地使用 try
,並且使用 XCTest runner 完全可以毫無代價地處理無效值。這是鮮為人知的,但事實上 Swift 測試可以是 throwing 函式,看看這個:
class EndpointURLFactoryTests: XCTestCase {
func testSearchURLContainsQuery() throws {
let factory = EndpointURLFactory()
let query = "Swift"
// 因為我們的測試函式是 throwing,這裡我們可以簡單地採用 `try`
let url = try factory.makeURL(for: .search(query))
XCTAssertTrue(url.absoluteString.contains(query))
}
}
複製程式碼
沒有可選型別,沒有強制解析,某些發生錯誤的時候也能完美地做出診斷 ?。
使用 require 的可選型別
然而,並不是所有返回可選型別的 API 都可以被替換為 throwing。不過在寫包含可選型別的測試時,有一個和 throwing API 同樣好的方法。
讓我們回到最開始 UserManager
的例子。如果既不對 loggedInUser
進行強制解析,又不把它看作可選型別,那麼我們可以簡單地這樣做:
let user = try require(service.loggedInUser)
XCTAssertEqual(user.name, "John")
XCTAssertEqual(user.age, 30)
複製程式碼
這實在是太酷了!?這樣我們可以擺脫大量的強制解析,同時避免讓我們的測試程式碼難於編寫、難於上手。那麼為了達到上述效果我們應該怎麼做呢?這很簡單,我們只需要對 XCTestCase
增加一個擴充,讓我們分析任何可選型別表示式,並且返回非可選的值或者丟擲一個錯誤,像這樣:
extension XCTestCase {
// 為了能夠輸出優雅的錯誤資訊
// 我們遵循 LocallizedErrow
private struct RequireError<T>: LocalizedError {
let file: StaticString
let line: UInt
// 實現這個屬性非常重要
// 否則測試失敗時我們無法在記錄中優雅地輸出錯誤資訊
var errorDescription: String? {
return "? Required value of type (T.self) was nil at line (line) in file (file)."
}
}
// 使用 file 和 line 使得我們能夠自動捕獲
// 原始碼中出現的相對應的表示式
func require<T>(_ expression: @autoclosure () -> T?,
file: StaticString = #file,
line: UInt = #line) throws -> T {
guard let value = expression() else {
throw RequireError<T>(file: file, line: line)
}
return value
}
}
複製程式碼
現在有了上述內容,如果我們 UserManager
登入測試發生失敗,我們也能得到一個非常優雅的錯誤資訊,告訴我們錯誤發生的準確位置。
[UserServiceTests testLoggingIn] : failed: caught error: ? Required value of type User was nil at line 97 in file UserServiceTests.swift.
複製程式碼
你可能意識到這個技巧來源於我的迷你框架 Require, 它對所有可選型別增加了一個 require() 方法,以提高對無法避免的強制解析的診斷效果。
總結
以同樣謹慎的態度對待你的應用程式碼和測試程式碼,在最開始可能有些不適應,但可以讓長期維護測試變的更加簡單 —— 不論是獨立開發還是團隊開發。良好的錯誤診斷和錯誤資訊是其中特別重要的一部分,使用本文中的一些技巧或許能夠讓你在未來避免很多奇怪的問題。
我在測試程式碼中唯一使用強制解析的時候,就是在構建測試案例的屬性時。因為這些總是在 setUp
中被建立、tearDown
中被銷燬,我並不把他們當作真正的可選型別。正如以往,你同樣需要檢視你自己的程式碼,根據你自己的喜好,來權衡決定。
所以你覺得呢?你會採用一些本文中的技巧,還是你已經用了一些相關的方式?請讓我知道,包括你可能有的任何的問題、評價和反饋 —— 可以在下面回覆欄直接回復或者在 Twitter @johnsundell 上回復我。
感謝閱讀!?
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。