原文鏈接:Testing Swift's ErrorType: An Exploration
譯者:mmoaay
在本篇中,我們對 Swift 新錯誤類型的本質(zhì)進行探究,觀察并測試錯誤處理實現(xiàn)的可能性和限制。最后我們以一個說明樣例、以及一些有用的資源結尾
ErrorType
協(xié)議
如果跳轉到 Swift 標準庫中 ErrorType
定義的位置,我們就會發(fā)現(xiàn)它并沒有包含明顯的要求。
protocol ErrorType {
}
然而,當我們試著去實現(xiàn) ErrorType
時,很快就會發(fā)現(xiàn)為了滿足這個協(xié)議至少有一些東西是必須的。比如,如果以枚舉的方式實現(xiàn)它,一切OK。
enum MyErrorEnum : ErrorType {
}
但是如果以結構體的方式實現(xiàn)它,問題來了。
struct MyErrorStruct : ErrorType {
}
我們最初的想法可能是,也許 ErrorType
是一種特殊類型,編譯器以特殊的方式來對它進行支持,而且只能用 Swift 原生的枚舉來實現(xiàn)。但隨后你又會想起 NSError
也滿足這個協(xié)議,所以它不可能有那么特殊。所以我們下一步的嘗試就是:通過一個 NSObject
的派生類實現(xiàn)這個協(xié)議
@objc class MyErrorClass: ErrorType {
}
不幸滴是,仍然不行。
更新:從 Xcode 7 beta 5 版本開始,我們可能不需要花費其他精力就可以為結構體和類實現(xiàn) ErrorType
協(xié)議。所以下面的解決方法也不再需要了,但是仍然留作參考。
允許結構體和類實現(xiàn)
ErrorType
協(xié)議。(21867608)
通過 LLDB
進一步調(diào)查發(fā)現(xiàn)這個協(xié)議有一些隱藏的要求。
(lldb) type lookup ErrorType
protocol ErrorType {
var _domain: Swift.String { get }
var _code: Swift.Int { get }
}
這樣一來 NSError
滿足這個定義的原因就很明白了:它有這些屬性,在 ivars
的支持下,不用動態(tài)查找就可以被 Swift 訪問。還有一點不明白的是為什么 Swift 的一等公民(first class)枚舉可以自動滿足這個協(xié)議。也許其內(nèi)部仍然存在一些魔法?
如果我們用我們新獲得的知識再去實現(xiàn)結構體和類,一切就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 完美銜接的情況下,這些已經(jīng)變得更加簡單。確定域的錯誤會以枚舉的方式暴露出來,這樣就可以簡單滴在不使用“魔法數(shù)字“的情況下捕獲它們。但是如果你需要捕獲一個沒有暴露出來的錯誤,該怎么辦呢?
假設我們需要反序列化一個 JSON 串,但是不確定它是不是有效的。我們將使用 Foundation
的 NSJSONSerialization
來做這件事情。當我們傳給它一個異常的 JSON 串時,它會拋出一個錯誤碼為 3840 的錯誤。
當然,你可以用通用的錯誤來捕獲它,然后手動檢查 _domain
和 _code
域,但是我們有更優(yōu)雅的替代方案。
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
}
}
另外一個替代方案就是我們引入一個通用的錯誤結構體,這個結構體通過我們之前發(fā)現(xiàn)的方法滿足 ErrorType
協(xié)議。當我們?yōu)樗鼘崿F(xiàn)模式匹配操作符 ~=
時,我們就可以在 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
,這個輔助類包含大量定義了各種錯誤的靜態(tài)方法。
這里所產(chǎn)生的叫做 NSCocoaError.PropertyListReadCorruptError
錯誤,雖然不是那么明顯,但是它確實是有我們需要的錯誤碼的。不管你是通過標準庫還是第三方框架捕獲錯誤,如果有像這樣的東西,你就需要依賴給定的常數(shù)而不是自己再去定義一次。
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
類型, 我們都需要確保我們所預期的錯誤會被正確拋出。邊界值永遠是測試時最有趣的場景,我們想要確認所有的保護措施都是到位的,而且在適當?shù)臅r候會拋出相應的錯誤。
現(xiàn)在我們對這個錯誤類型在底層的工作方式有了一些基本的認識,同時對如何在測試時讓它遵循我們的意愿也有了一些想法。所以我們來展示一個小的測試用例:我們有一個銀行 App,然后我們想在業(yè)務邏輯里面為現(xiàn)實活動建模型。我們創(chuàng)建了代表銀行帳號的結構體 Account,它包含一個接口,這個接口暴露了一個方法用來在預算范圍內(nèi)進行交易。
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)\"")
}
}
}
現(xiàn)在假想我們有更多的方法和更多的錯誤場景。在以測試為導向的開發(fā)方式下,我們想對它們都進行測試,從而保證所有的錯誤都被正確滴拋出來——我們當然不想把錢轉到錯誤的地方去!理想情況下,我們不想在所有的測試代碼中都重復這個 do-catch
。實現(xiàn)一個抽象,我們可以把它放到一個高階函數(shù)中。
/// 為 ErrorType 實現(xiàn)模式匹配
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))
}
}
但你可能會發(fā)現(xiàn), 預期出現(xiàn)的參數(shù)化錯誤 NonPositiveTransactionNotAllowed
比這里所用到的參數(shù)要多個 amount
。我們該如何對錯誤場景和它們相關的值做出強有力的假設呢? 首先,我們可以為錯誤類型實現(xiàn) Equatable
協(xié)議, 然后在相等操作符的實現(xiàn)中添加對相關場景的參數(shù)個數(shù)的檢查。
/// 對我們的錯誤類型進行擴展然后實現(xiàn) `Equatable`。
/// 這必須是對每一個具體的類型來做的,
/// 而不是為 `ErrorType` 統(tǒng)一實現(xiàn)。
extension Error : Equatable {}
/// 為協(xié)議 `Equatable` 以 required 的方式實現(xiàn) `==` 操作符。
public func ==(lhs: Error, rhs: Error) -> Bool {
switch (lhs, rhs) {
case (.NonPositiveTransactionNotAllowed(let l), .NonPositiveTransactionNotAllowed(let r)):
return l == r
default:
// 我們需要在默認場景,為各種組合場景返回 false。
// 通過根據(jù) domain 和 code 進行比較的方式,我們可以保證
// 一旦我們添加了其他的錯誤場景,如果這個場景有相應的值
// 我只需要回到并修改 Equatable 的實現(xiàn)即可
return lhs._domain == rhs._domain
&& lhs._code == rhs._code
}
}
下一步就是讓 AssertThrow
知道有合理的錯誤。你可能會想,我們可以擴展已存在的 AssertThrow
實現(xiàn),只是簡單檢查一下預期的錯誤是否合理。但是不幸滴是根本沒用:
“Equatable” 協(xié)議只能被當作泛型約束,因為它需要滿足 Self 或者關聯(lián)類型的必要條件
相反,我們可以通過多一個泛型參數(shù)做首參的方式重載 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)\".")
}
}
然后跟預期一樣我們的測試最終返回了失敗。
注意后者的斷言實現(xiàn)就對錯誤的類型進行了強有力的假設。
不要使用“捕獲其他被拋出的錯誤”下面的方法,因為跟目前的方法相比,它不能匹配類型。很有可能這種錯誤超出了我們的控制了。
在 Realm,我們使用 XCTest 和我們自產(chǎn)的 XCTestCase
子類并結合一些 預測器,這樣剛好可以滿足我們的特殊需求。值得高興的是,如果要使用這些代碼,你不需要拷貝-粘帖,也不需要重新造輪子。錯誤預測器在 GitHub 的 CatchingFire 項目中都有,如果你不是 XCTest
預測器風格的大粉絲,那么你可能會更喜歡類似 Nimble 的測試框架,它們也可以提供測試支持。
要開心滴測試哦~
更多建議: