Swift の ErrorType のテスト

この記事では、Swift に新たに導入された ErrorType の性質について考え、その可能性とエラーハンドリングのテスト時の制限について考察します。わかりやすいサンプルコードと参考資料を合わせて掲載します。


ErrorType プロトコルをどのように実装するか?

Swift の標準ライブラリの ErrorType プロトコルの定義を見てみると、何も実装すべきものはありません。

protocol ErrorType {
}

しかし、ErrorType をいざ実装しようとすると、他に何か必要であることがすぐに分かります。たとえば、これを Enum として実装すると問題なく動きます。

enum MyErrorEnum : ErrorType {
}

しかし、Struct として実装すると、エラーが出ます。

struct MyErrorStruct : ErrorType {
}

はじめに思ったことは、ErrorType はコンパイラによって何か特別な方法で定義されていて、Swift の Enum でのみ実装できるのかと考えました。しかし、すぐに NSError もこのプロトコルを実装していることを思い出しました。ゆえに、特別ではないと断定できます。そこで、次の試みとして、NSObject を継承したクラスでこのプロトコルを実装してみました。

@objc class MyErrorClass: ErrorType {
}

残念ながら、これも正しく動作しませんでした。

追記: Xcode 7 beta 5から特に何もしなくてもEnumと同様にClassStructErrorTypeを実装することができるようになりました。そのため、下記のワークアラウンドはもう必要なくなりましたが、参考のために残しておきます。

Structs and classes are now allowed to conform to ErrorType. (21867608)

どうすればできるのか?

そのあと LLDB で調査することで、このプロトコルには隠されたある要件があることがわかりました。

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

そして、NSError がどのようにして、このプロトコルを実装しているのか明らかになりました。二つのプロパティが定義されていて、このインスタンス変数にはダイナミックディスパッチでなくても Swift からアクセスができます。では、どのようにして Swift の第1級オブジェクトである Enum で、自動でこのプロトコルの要件を満たすようにしているのでしょうか? それとも、他にもまだ秘密が隠されているのでしょうか?

先ほどわかったことをもとに Struct とクラスを実装してみると、以下のように問題なく動作することがわかります。

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 の素晴らしいブリッジのおかげで、この部分がより簡単に扱うことができるようになりました。特定のドメインからのエラーの場合、Enum として列挙されているため、マジックナンバーを使わずに、簡単にエラーを捕捉できます。しかし、もし列挙されていないエラーを捕捉したい場合はどうしたら良いでしょうか?

記事の更新情報を受け取る

たとえば、JSON の文字列をデシリアイズすることについて考えてみます。しかし、その JSON が正しいフォーマットかわからないとします。Foundation フレームワークの NSJSONSerialization を使用することにします。仮に、これにおかしな 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 を実装した Error 型を Struct として定義します。これのパターンマッチングのオペレーションである ~= を実装することで、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 NSCocoaError.PropertyListReadCorruptError {
    print("Invalid format")
}

エラーハンドリングに対するテストを書く

さて、次は何でしょうか? この Swift のエラーハンドリングの方法を使うことで、NSError のポインターを使ったハンドリングと、Result 型を使ったやり方を置き換えることができるようになりました。エッジケースというのは、テストにおいて非常に注意すべきことであり、この定義したエラー型が期待したエラーをきちんと返してくれることを確認する必要があります。

先ほど、どのように ErrorType が裏側で動作しているのか理解したので、エラーをテストすることを上手に行うことができます。

では、小さなサンプルを使って、このテストケースについてご説明しましょう。たとえば、銀行のアプリがあり、現実で起こりうることでモデル化を行います。ここでは、銀行のアカウントを表す、Account 型を Struct で定義します。その Struct は、予算内であればトランザクションが行える public 属性のメソッドを持っているとします。

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 : XCTestCase {
    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 {
            // Expected.
        } catch {
            XCTFail("Catched error \"\(error)\", but not the expected: \"\(Error.TransactionExceedsFunds)\"")
        }
    }
}

ここから、この型に新たにメソッドを追加するたびに、エラーケースもそれにともない増えていきます。テスト駆動開発のマナーとして、全てのエラーに対して、正しくエラーが投げられることを確認するためにテストを行いたいです。- 間違ったお金のやり取りはしたくありません! 理想的には、ここで使っている do-catch パターンをテストケースごとに書くようなことはしたくはありません。そのためにこの部分を、抽象化する関数を以下のように定義します。

/// Implement pattern matching for 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 {
        // Expected.
    } 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 のテストケースで、期待した値とエラー型が保持している値が一致しない場合はどうでしょうか? どのようにすればエラーケースだけでなく関連する値の確認も行えるでしょうか? まず、定義した Error 型に Equatable プロトコルを実装します。そして、関連する値の確認をその中で行います。

/// Extend our Error type to implement `Equatable`.
/// This must be done per individual concrete type
/// and can't be done in general for `ErrorType`.
extension Error : Equatable {}

/// Implement the `==` operator as required by protocol `Equatable`.
public func ==(lhs: Error, rhs: Error) -> Bool {
    switch (lhs, rhs) {
    case (.NonPositiveTransactionNotAllowed(let l), .NonPositiveTransactionNotAllowed(let r)):
        return l == r
    default:
        // We need a default case to return false for different case combinations.
        // By falling back to domain and code based comparison, we ensure that
        // as soon as we add additional error cases, we have to revisit only the
        // Equatable implementation, if the case has an associated value.
        return lhs._domain == rhs._domain
            && lhs._code   == rhs._code
    }
}

次のステップとして、AssertThrow の中を Equatable を使って Error 型のチェックを行うように書き換えることです。おわかりだと思いますが、AssertThrow の実装で、二つのエラーが全く同じであるかの確認をするようにします。 しかし、残念ながら、これは上手く動作しません。

Protocol ‘Equatable’ can only be used as a generic constraint because it has Self or associated type requirements

その代わりに、先ほどの 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 では、XCTestCase のサブクラスを作り、私たちの固有するニーズを満たす、いくつかのマッチャーを定義し使っています。これを使うことで、コピー&ペーストする箇所が削除でき、テストケースで重複する箇所が減らせます。エラー処理に関するマッチャーは、GitHub に CatchingFire として公開しています。 XCTest で使えるようなマッチャーがあまり好まない場合は、Nimble などのテストフレームワークが参考になることかと思います。

Happy testing!

About the content

This content has been published here with the express permission of the author.


Marius Rackwitz

Marius is an iOS Developer based in Berlin. As a Core Member of the CocoaPods team, he helped implement Swift & framework support for CocoaPods. When he’s not busy working on development tools for iOS & Mac, he works on Realm, a new database for iOS & Android.

4 design patterns for a RESTless mobile integration »

close