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