探索:測試 Swift 中的 ErrorType

原文鏈接:Testing Swift’s ErrorType: An Exploration

譯者:mmoaay

在本篇中,我們對 Swift 新錯誤類型的本質進行探究,觀察並測試錯誤處理實現的可能性和限制。最後我們以一個說明樣例、以及一些有用的資源結尾


如何實現 ErrorType 協議


如果跳轉到 Swift 標準庫中 ErrorType 定義的位置,我們就會發現它並沒有包含明顯的要求。

protocol ErrorType {
}

然而,當我們試着去實現 ErrorType 時,很快就會發現爲了滿足這個協議至少有一些東西是必須的。比如,如果以枚舉的方式實現它,一切OK。

enum MyErrorEnum : ErrorType {
}

但是如果以結構體的方式實現它,問題來了。

struct MyErrorStruct : ErrorType {
}

我們最初的想法可能是,也許 ErrorType 是一種特殊類型,編譯器以特殊的方式來對它進行支持,而且只能用 Swift 原生的枚舉來實現。但隨後你又會想起 NSError 也滿足這個協議,所以它不可能有那麼特殊。所以我們下一步的嘗試就是:通過一個 NSObject 的派生類實現這個協議

@objc class MyErrorClass: ErrorType {
}

不幸滴是,仍然不行。

更新:從 Xcode 7 beta 5 版本開始,我們可能不需要花費其他精力就可以爲結構體和類實現 ErrorType 協議。所以下面的解決方法也不再需要了,但是仍然留作參考。

允許結構體和類實現 ErrorType 協議。(21867608)

怎麼會這樣?


通過 LLDB 進一步調查發現這個協議有一些隱藏的要求。

(lldb) type lookup ErrorType
protocol ErrorType {
  var _domain: Swift.String { get }
  var _code: Swift.Int { get }
}

這樣一來 NSError 滿足這個定義的原因就很明白了:它有這些屬性,在 ivars 的支持下,不用動態查找就可以被 Swift 訪問。還有一點不明白的是爲什麼 Swift 的一等公民(first class)枚舉可以自動滿足這個協議。也許其內部仍然存在一些魔法?

如果我們用我們新獲得的知識再去實現結構體和類,一切就OK了。

struct MyErrorStruct : ErrorType {
  let _domain: String
  let _code: Int
}

class MyErrorClass : ErrorType {
  let _domain: String
  let _code: Int

  init(domain: String, code: Int) {
    _domain = domain
    _code = code
  }
}

捕獲其他被拋出的錯誤


歷史上,Apple 的框架中的 NSErrorPointer 模式在錯誤處理中起到了重要作用。在 Objective-C 的 API 與 Swift 完美銜接的情況下,這些已經變得更加簡單。確定域的錯誤會以枚舉的方式暴露出來,這樣就可以簡單滴在不使用“魔法數字“的情況下捕獲它們。但是如果你需要捕獲一個沒有暴露出來的錯誤,該怎麼辦呢?

假設我們需要反序列化一個 JSON 串,但是不確定它是不是有效的。我們將使用 FoundationNSJSONSerialization 來做這件事情。當我們傳給它一個異常的 JSON 串時,它會拋出一個錯誤碼爲 3840 的錯誤。

當然,你可以用通用的錯誤來捕獲它,然後手動檢查 _domain_code 域,但是我們有更優雅的替代方案。

let json : NSString = "{"
let data = json.dataUsingEncoding(NSUTF8StringEncoding)
do {
    let object : AnyObject = try
     NSJSONSerialization.JSONObjectWithData(data!, options: [])
    print(object)
} catch let error {
    if   error._domain == NSCocoaErrorDomain
      && error._code   == 3840 {
        print("Invalid format")
    } else {
        throw error
    }
}

另外一個替代方案就是我們引入一個通用的錯誤結構體,這個結構體通過我們之前發現的方法滿足 ErrorType 協議。當我們爲它實現模式匹配操作符 ~= 時,我們就可以在 do … catch 分支中使用它。

struct Error : ErrorType {
    let domain: String
    let code: Int

    var _domain: String {
        return domain
    }
    var _code: Int {
        return code
    }
}

func ~=(lhs: Error, rhs: ErrorType) -> Bool {
    return lhs._domain == rhs._domain
        && rhs._code   == rhs._code
}

let json : NSString = "{"
let data = json.dataUsingEncoding(NSUTF8StringEncoding)
do {
    let object : AnyObject = try
     NSJSONSerialization.JSONObjectWithData(data!, options: [])
    print(object)
} catch Error(domain: NSCocoaErrorDomain, code: 3840) {
    print("Invalid format")
}

但在當前情況下,還可以用 NSCocoaError,這個輔助類包含大量定義了各種錯誤的靜態方法。

這裏所產生的叫做 NSCocoaError.PropertyListReadCorruptError 錯誤,雖然不是那麼明顯,但是它確實是有我們需要的錯誤碼的。不管你是通過標準庫還是第三方框架捕獲錯誤,如果有像這樣的東西,你就需要依賴給定的常數而不是自己再去定義一次。

let json : NSString = "{"
let data = json.dataUsingEncoding(NSUTF8StringEncoding)
do {
    let object : AnyObject = try NSJSONSerialization.JSONObjectWithData(data!, options: [])
    print(object)
} catch NSCocoaErrorDomain {
    print("Invalid format")
}

自定義錯誤處理的編寫規範


所以下一步做什麼呢?在用 Swift 的錯誤處理給我們的代碼加料之後,不管我們是替換所有那些讓人分心的 NSError 指針賦值,還是退一步到功能範式中的 Result 類型, 我們都需要確保我們所預期的錯誤會被正確拋出。邊界值永遠是測試時最有趣的場景,我們想要確認所有的保護措施都是到位的,而且在適當的時候會拋出相應的錯誤。

現在我們對這個錯誤類型在底層的工作方式有了一些基本的認識,同時對如何在測試時讓它遵循我們的意願也有了一些想法。所以我們來展示一個小的測試用例:我們有一個銀行 App,然後我們想在業務邏輯裏面爲現實活動建模型。我們創建了代表銀行帳號的結構體 Account,它包含一個接口,這個接口暴露了一個方法用來在預算範圍內進行交易。

public enum Error : ErrorType {
    case TransactionExceedsFunds
    case NonPositiveTransactionNotAllowed(amount: Int)
}

public struct Account {
    var fund: Int

    public mutating func withdraw(amount: Int) throws {
        guard amount < fund else {
            throw Error.TransactionExceedsFunds
        }
        guard amount > 0 else {
            throw Error.NonPositiveTransactionNotAllowed(amount: amount)
        }
        fund -= amount
    }
}

class AccountTests {
    func testPreventNegativeWithdrawals() {
        var account = Account(fund: 100)
        do {
            try account.withdraw(-10)
            XCTFail("Withdrawal of negative amount succeeded, 
            but was expected to fail.")
        } catch Error.NonPositiveTransactionNotAllowed(let amount) {
            XCTAssertEqual(amount, -10)
        } catch {
            XCTFail("Catched error \"\(error)\", 
            but not the expected: \"\(Error.NonPositiveTransactionNotAllowed)\"")
        }
    }

    func testPreventExceedingTransactions() {
        var account = Account(fund: 100)
        do {
            try account.withdraw(101)
            XCTFail("Withdrawal of amount exceeding funds succeeded, 
            but was expected to fail.")
        } catch Error.TransactionExceedsFunds {
            // 預期結果
        } catch {
            XCTFail("Catched error \"\(error)\", 
            but not the expected: \"\(Error.TransactionExceedsFunds)\"")
        }
    }
}

現在假想我們有更多的方法和更多的錯誤場景。在以測試爲導向的開發方式下,我們想對它們都進行測試,從而保證所有的錯誤都被正確滴拋出來——我們當然不想把錢轉到錯誤的地方去!理想情況下,我們不想在所有的測試代碼中都重複這個 do-catch 。實現一個抽象,我們可以把它放到一個高階函數中。

/// 爲 ErrorType 實現模式匹配
public func ~=(lhs: ErrorType, rhs: ErrorType) -> Bool {
    return lhs._domain == rhs._domain
        && lhs._code   == rhs._code
}

func AssertThrow<R>(expectedError: ErrorType, @autoclosure _ closure: () throws -> R) -> () {
    do {
        try closure()
        XCTFail("Expected error \"\(expectedError)\", "
            + "but closure succeeded.")
    } catch expectedError {
        // 預期結果.
    } catch {
        XCTFail("Catched error \"\(error)\", "
            + "but not from the expected type "
            + "\"\(expectedError)\".")
    }
}

這段代碼可以這樣使用:

class AccountTests : XCTestCase {
    func testPreventExceedingTransactions() {
        var account = Account(fund: 100)
        AssertThrow(Error.TransactionExceedsFunds, try account.withdraw(101))
    }

    func testPreventNegativeWithdrawals() {
        var account = Account(fund: 100)
        AssertThrow(Error.NonPositiveTransactionNotAllowed(amount: -10), try account.withdraw(-20))
    }
}

但你可能會發現, 預期出現的參數化錯誤 NonPositiveTransactionNotAllowed 比這裏所用到的參數要多個 amount。我們該如何對錯誤場景和它們相關的值做出強有力的假設呢? 首先,我們可以爲錯誤類型實現 Equatable 協議, 然後在相等操作符的實現中添加對相關場景的參數個數的檢查。

/// 對我們的錯誤類型進行擴展然後實現 `Equatable`。
/// 這必須是對每一個具體的類型來做的,
/// 而不是爲 `ErrorType` 統一實現。
extension Error : Equatable {}

/// 爲協議 `Equatable` 以 required 的方式實現 `==` 操作符。
public func ==(lhs: Error, rhs: Error) -> Bool {
    switch (lhs, rhs) {
    case (.NonPositiveTransactionNotAllowed(let l), .NonPositiveTransactionNotAllowed(let r)):
        return l == r
    default:
        // 我們需要在默認場景,爲各種組合場景返回 false。
        // 通過根據 domain 和 code 進行比較的方式,我們可以保證
        // 一旦我們添加了其他的錯誤場景,如果這個場景有相應的值
        // 我只需要回到並修改 Equatable 的實現即可
        return lhs._domain == rhs._domain
            && lhs._code   == rhs._code
    }
}

下一步就是讓 AssertThrow 知道有合理的錯誤。你可能會想,我們可以擴展已存在的 AssertThrow 實現,只是簡單檢查一下預期的錯誤是否合理。但是不幸滴是根本沒用:

“Equatable” 協議只能被當作泛型約束,因爲它需要滿足 Self 或者關聯類型的必要條件

相反,我們可以通過多一個泛型參數做首參的方式重載 AssertThrow

func AssertThrow<R, E where E: ErrorType, E: Equatable>(expectedError: E, @autoclosure _ closure: () throws -> R) -> () {
    do {
        try closure()
        XCTFail("Expected error \"\(expectedError)\", "
            + "but closure succeeded.")
    } catch let error as E {
        XCTAssertEqual(error, expectedError,
            "Catched error is from expected type, "
                + "but not the expected case.")
    } catch {
        XCTFail("Catched error \"\(error)\", "
            + "but not the expected error "
            + "\"\(expectedError)\".")
    }
}

然後跟預期一樣我們的測試最終返回了失敗。

注意後者的斷言實現就對錯誤的類型進行了強有力的假設。

不要使用“捕獲其他被拋出的錯誤”下面的方法,因爲跟目前的方法相比,它不能匹配類型。很有可能這種錯誤超出了我們的控制了。

一些有用的資源


在 Realm,我們使用 XCTest 和我們自產的 XCTestCase 子類並結合一些 預測器,這樣剛好可以滿足我們的特殊需求。值得高興的是,如果要使用這些代碼,你不需要拷貝-粘帖,也不需要重新造輪子。錯誤預測器在 GitHub 的 CatchingFire 項目中都有,如果你不是 XCTest 預測器風格的大粉絲,那麼你可能會更喜歡類似 Nimble 的測試框架,它們也可以提供測試支持。

要開心滴測試哦~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章