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

RickeyBoy發表於2019-02-28

避免 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 的 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章