[譯] 避免 Swift 單元測試中的強制解析

避免 Swift 單元測試中的強制解析

強制解析(使用 !)是 Swift 語言中不可或缺的一個重要特色(特別是和 Objective-C 的接口混合使用時)。它迴避了一些其餘問題,使得 Swift 語言變得更加優秀。好比 處理 Swift 中非可選的可選值類型 這篇文章中,在項目邏輯須要時使用強制解析去處理可選類型,將致使一些離奇的狀況和崩潰。前端

因此儘量地避免使用強制解析,將有助於搭建更加穩定的應用,而且在發生錯誤時提供更好的報錯信息。那麼若是是編寫測試時,狀況會怎麼樣呢?安全地處理可選類型和未知類型須要大量的代碼,那麼問題就在於咱們是否願意爲編寫測試作全部的額外工做。這就是咱們這周將要探討的問題,讓咱們開始深刻研究吧!android

測試代碼 vs 產品代碼

當編寫測試代碼時,咱們常常明確區分測試代碼產品代碼。儘管保持這兩部分代碼的分離十分重要(咱們不但願意外地讓咱們的模擬測試對象成爲 App Store 上架的部分😅),但就代碼質量來講,沒有必要進行明顯區分。ios

若是你思考一下的話,咱們想要對移交給使用者的代碼進行高標準的要求,緣由是什麼呢?git

  • 咱們想要咱們的 app 爲使用者穩定、流暢地運行。
  • 咱們想要咱們的 app 在將來易於維護和修改。
  • 咱們想要更容易讓新人融入咱們的團隊。

如今若是反過來考慮咱們的測試,咱們想要避免哪些事情呢?github

  • 測試不穩定、脆弱、難於調試。
  • 當咱們的 app 增長了新功能時,咱們的測試代碼須要花費大量時間來維護和升級。
  • 測試代碼對於加入團隊的新人來講難於理解。

你可能已經理解我所講的內容了 😉。express

以前很長的時間,我曾認爲測試代碼只是一些我快速堆砌的代碼,由於有人告訴我必需要編寫測試。我不那麼在意它們的質量,由於我將它視爲一件雜事,並不將它放在首位。然而,一旦我由於編寫測試而發現驗證本身的代碼有多麼快,以及對本身有多麼自信 —— 我對測試的態度就開始了轉變。swift

所如今我相信對於測試代碼,和將要移交的產品代碼進行同等的高標準要求是很是重要的。由於咱們配套的測試是須要咱們長期使用、拓展和掌握的,咱們理應讓這些工做更容易完成。後端

強制解析的問題

那麼這一切與 Swift 中的強制解析有什麼關係呢?🤔安全

有時必需要強制解析,很容易編寫一個 「go-to solution」 的測試。讓咱們來看一個例子,測試 UserService 實現的登錄機制是否正常工做:bash

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 的 nameage 屬性使用了斷言,若是任意一個屬性爲 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 上回復我。

感謝閱讀!🚀


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索