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