避免 Swift 單元測試中的強(qiáng)制解析
本文轉(zhuǎn)載自微信公眾號「網(wǎng)羅開發(fā)」,作者Rickey王小吉。轉(zhuǎn)載本文請聯(lián)系網(wǎng)羅開發(fā)公眾號。
前言
強(qiáng)制解析(使用 !)是 Swift 語言中不可或缺的一個重要特點(diǎn)(特別是和 Objective-C 的接口混合使用時(shí))。它回避了一些其他問題,使得 Swift 語言變得更加優(yōu)秀。比如 處理 Swift 中非可選的可選值類型[1] 這篇文章中,在項(xiàng)目邏輯需要時(shí)使用強(qiáng)制解析去處理可選類型,將導(dǎo)致一些離奇的情況和崩潰。
所以盡可能地避免使用強(qiáng)制解析,將有助于搭建更加穩(wěn)定的應(yīng)用,并且在發(fā)生錯誤時(shí)提供更好的報(bào)錯信息。那么如果是編寫測試時(shí),情況會怎么樣呢?安全地處理可選類型和未知類型需要大量的代碼,那么問題就在于我們是否愿意為編寫測試做所有的額外工作。這就是我們這周將要探討的問題,讓我們開始深入研究吧!
測試代碼 vs 產(chǎn)品代碼
當(dāng)編寫測試代碼時(shí),我們經(jīng)常明確區(qū)分測試代碼和產(chǎn)品代碼。盡管保持這兩部分代碼的分離十分重要(我們不希望意外地讓我們的模擬測試對象成為 App Store 上架的部分??),但就代碼質(zhì)量來說,沒有必要進(jìn)行明顯區(qū)分。
如果你思考一下的話,我們想要對移交給使用者的代碼進(jìn)行高標(biāo)準(zhǔn)的要求,原因是什么呢?
我們想要我們的 app 為使用者穩(wěn)定、流暢地運(yùn)行。
- 我們想要我們的 app 在未來易于維護(hù)和修改。
- 我們想要更容易讓新人融入我們的團(tuán)隊(duì)。
- 現(xiàn)在如果反過來考慮我們的測試,我們想要避免哪些事情呢?
測試不穩(wěn)定、脆弱、難于調(diào)試。
- 當(dāng)我們的 app 增加了新功能時(shí),我們的測試代碼需要花費(fèi)大量時(shí)間來維護(hù)和升級。
- 測試代碼對于加入團(tuán)隊(duì)的新人來說難于理解。
- 你可能已經(jīng)理解我所講的內(nèi)容了 ??。
之前很長的時(shí)間,我曾認(rèn)為測試代碼只是一些我快速堆砌的代碼,因?yàn)橛腥烁嬖V我必須要編寫測試。我不那么在乎它們的質(zhì)量,因?yàn)槲覍⑺暈橐患嵤拢⒉粚⑺旁谑孜弧H欢坏┪乙驗(yàn)榫帉憸y試而發(fā)現(xiàn)驗(yàn)證自己的代碼有多么快,以及對自己有多么自信 —— 我對測試的態(tài)度就開始了轉(zhuǎn)變。
所現(xiàn)在我相信對于測試代碼,和將要移交的產(chǎn)品代碼進(jìn)行同等的高標(biāo)準(zhǔn)要求是非常重要的。因?yàn)槲覀兣涮椎臏y試是需要我們長期使用、拓展和掌握的,我們理應(yīng)讓這些工作更容易完成。
強(qiáng)制解析的問題
那么這一切與 Swift 中的強(qiáng)制解析有什么關(guān)系呢???
有時(shí)必須要強(qiáng)制解析,很容易編寫一個 “go-to solution” 的測試。讓我們來看一個例子,測試 UserService實(shí)現(xiàn)的登陸機(jī)制是否正常工作:
- class UserServiceTests: XCTestCase {
- func testLoggingIn() {
- // 為了登陸終端
- // 構(gòu)建一個永遠(yuǎn)返回成功的模擬對象
- let networkManager = NetworkManagerMock()
- networkManager.mockResponse(forEndpoint: .login, with: [
- "name": "John",
- "age": 30
- ])
- // 構(gòu)建 service 對象以及登錄
- let service = UserService(networkManager: networkManager)
- service.login(withUsername: "john", password: "password")
- // 現(xiàn)在我們想要基于已登陸的用戶進(jìn)行斷言,
- // 這是可選類型,所以我們對它進(jìn)行強(qiáng)制解析
- let user = service.loggedInUser!
- XCTAssertEqual(user.name, "John")
- XCTAssertEqual(user.age, 30)
- }
- }
如你所見,在進(jìn)行斷言之前,我們強(qiáng)制解析了 service 對象的 loggedInUser 屬性。像上面這樣的做法并不是絕對意義上的錯,但是如果這個測試因?yàn)橐恍┰蜷_始失敗,就可能會導(dǎo)致一些問題。
假設(shè)某人(記住,“某人”可能就是“未來的你自己”??)改變了網(wǎng)絡(luò)部分的代碼,導(dǎo)致上述測試開始崩潰。如果這樣的事情發(fā)生了,錯誤信息可能只會像下面這樣:
- Fatal error: Unexpectedly found nil while unwrapping an Optional value
盡管用 Xcode 本地運(yùn)行時(shí)這不是個大問題(因?yàn)殄e誤會被關(guān)聯(lián)地顯示 —— 至少在大多數(shù)時(shí)候 ??),但當(dāng)連續(xù)地整體運(yùn)行整個項(xiàng)目時(shí),它可能問題重重。上述的錯誤信息可能出現(xiàn)在巨大的“文字墻”中,導(dǎo)致難以看出錯誤的來源。更嚴(yán)重的是,它會阻止后續(xù)的測試被執(zhí)行(因?yàn)闇y試進(jìn)程會崩潰),這將導(dǎo)致修復(fù)工作進(jìn)展緩慢并且令人煩躁。
Guard 和 XCTFail
一個潛在的解決上述問題的方式是簡單地使用 guard 聲明,優(yōu)雅地解析問題中的可選類型,如果解析失敗再調(diào)用 XCTFail 即可,就像下面這樣:
- guard let user = service.loggedInUser else {
- XCTFail("Expected a user to be logged in at this point")
- return
- }
盡管上述做法在某些情況下是正確的做法,但事實(shí)上我推薦避免使用它 —— 因?yàn)樗蚰愕臏y試中增加了控制流。為了穩(wěn)定性和可預(yù)測性,你通常希望測試只是簡單的遵循 given,when,then 結(jié)構(gòu),并且增加控制流會使得測試代碼難于理解。如果你真的非常倒霉,控制流可能成為誤報(bào)的起源(對此之后的文章會有更多的相關(guān)內(nèi)容)。
保持可選類型
另一個方法是讓可選類型一直保持可選。這在某些使用情況下完全可用,包括我們 UserManager 的例子。因?yàn)槲覀儗σ呀?jīng)登錄的 user 的 name 和 age 屬性使用了斷言,如果任意一個屬性為 nil ,我們會自動得到錯誤提示。同時(shí)如果我們對 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)
現(xiàn)在如果我們的測試開始出錯了,我們就能得到如下信息:
- 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)")
這讓我們能夠更加容易地知道發(fā)生錯誤的地方,以及該從哪里入手去調(diào)試、解決這個錯誤 ??。
使用 throw 的測試
第三個選擇在某些情況下是非常有用的,就是將返回可選類型的 API 替換為 throwing API。Swift 中的 throwing API 的優(yōu)雅之處在于,需要時(shí)它能夠非常容易地被當(dāng)成可選類型使用。所以很多時(shí)候選擇采用 throwing 方法,不需要犧牲任何的可用性。比如說,假設(shè)我們有一個 EndpointURLFactory 類,被用來在我們的 app 中生成特定終端的 URL,這顯然會返回可選類型:
- class EndpointURLFactory {
- func makeURL(for endpoint: Endpoint) -> URL? {
- ...
- }
- }
現(xiàn)在我們將其轉(zhuǎn)換為采用 throwing API,像這樣:
- class EndpointURLFactory {
- func makeURL(for endpoint: Endpoint) throws -> URL {
- ...
- }
- }
當(dāng)我們?nèi)匀幌氲玫揭粋€可選類型的 URL 時(shí),我們只需要使用 try? 命令去調(diào)用它:
- let loginEndpoint = try? urlFactory.makeURL(for: .login)
就測試而言,上述這種做法的最大好處在于可以在測試中輕松地使用 try,并且使用 XCTest runner 完全可以毫無代價(jià)地處理無效值。這是鮮為人知的,但事實(shí)上 Swift 測試可以是 throwing 函數(shù),看看這個:
- class EndpointURLFactoryTests: XCTestCase {
- func testSearchURLContainsQuery() throws {
- let factory = EndpointURLFactory()
- let query = "Swift"
- // 因?yàn)槲覀兊臏y試函數(shù)是 throwing,這里我們可以簡單地采用 'try'
- let url = try factory.makeURL(for: .search(query))
- XCTAssertTrue(url.absoluteString.contains(query))
- }
- }
沒有可選類型,沒有強(qiáng)制解析,某些發(fā)生錯誤的時(shí)候也能完美地做出診斷 ??。
使用 require 的可選類型
然而,并不是所有返回可選類型的 API 都可以被替換為 throwing。不過在寫包含可選類型的測試時(shí),有一個和 throwing API 同樣好的方法。
讓我們回到最開始 UserManager 的例子。如果既不對 loggedInUser 進(jìn)行強(qiáng)制解析,又不把它看作可選類型,那么我們可以簡單地這樣做:
- let user = try require(service.loggedInUser)
- XCTAssertEqual(user.name, "John")
- XCTAssertEqual(user.age, 30)
這實(shí)在是太酷了!??這樣我們可以擺脫大量的強(qiáng)制解析,同時(shí)避免讓我們的測試代碼難于編寫、難于上手。那么為了達(dá)到上述效果我們應(yīng)該怎么做呢?這很簡單,我們只需要對 XCTestCase 增加一個拓展,讓我們分析任何可選類型表達(dá)式,并且返回非可選的值或者拋出一個錯誤,像這樣:
- extension XCTestCase {
- // 為了能夠輸出優(yōu)雅的錯誤信息
- // 我們遵循 LocallizedErrow
- private struct RequireError<T>: LocalizedError {
- let file: StaticString
- let line: UInt
- // 實(shí)現(xiàn)這個屬性非常重要
- // 否則測試失敗時(shí)我們無法在記錄中優(yōu)雅地輸出錯誤信息
- var errorDescription: String? {
- return "😱 Required value of type \(T.self) was nil at line \(line) in file \(file)."
- }
- }
- // 使用 file 和 line 使得我們能夠自動捕獲
- // 源代碼中出現(xiàn)的相對應(yīng)的表達(dá)式
- 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
- }
- }
現(xiàn)在有了上述內(nèi)容,如果我們 UserManager 登錄測試發(fā)生失敗,我們也能得到一個非常優(yōu)雅的錯誤信息,告訴我們錯誤發(fā)生的準(zhǔn)確位置。
- [UserServiceTests testLoggingIn] : failed: caught error: 😱 Required value of type User was nil at line 97 in file UserServiceTests.swift.
你可能意識到這個技巧來源于我的迷你框架 Require[2], 它對所有可選類型增加了一個 require() 方法,以提高對無法避免的強(qiáng)制解析的診斷效果。
總結(jié)
以同樣謹(jǐn)慎的態(tài)度對待你的應(yīng)用代碼和測試代碼,在最開始可能有些不適應(yīng),但可以讓長期維護(hù)測試變的更加簡單 —— 不論是獨(dú)立開發(fā)還是團(tuán)隊(duì)開發(fā)。良好的錯誤診斷和錯誤信息是其中特別重要的一部分,使用本文中的一些技巧或許能夠讓你在未來避免很多奇怪的問題。
我在測試代碼中唯一使用強(qiáng)制解析的時(shí)候,就是在構(gòu)建測試案例的屬性時(shí)。因?yàn)檫@些總是在 setUp 中被創(chuàng)建、tearDown 中被銷毀,我并不把他們當(dāng)作真正的可選類型。正如以往,你同樣需要查看你自己的代碼,根據(jù)你自己的喜好,來權(quán)衡決定。
所以你覺得呢?你會采用一些本文中的技巧,還是你已經(jīng)用了一些相關(guān)的方式?請讓我知道,包括你可能有的任何的問題、評價(jià)和反饋。