XCTAssertには内部実装の観点から3種類あってそれぞれ違いますという話です。

アサーションとは何か

動的テストにおいて、実結果が期待結果と合うことを確認するやつ。テスト実装パターン「Fixture」「Test Case」「Check」「Test Suite」のうちのCheckにあたる。元祖SUnitでは should: で表記していた。

SetTestCase>>testAdd
    empty add: 5.
    self should: [empty includes: 5]

JUnitでは assert expected actual と表現。第1引数と第2引数がそれぞれ区別されている(内部で違う処理がされているというわけではない)。

public static void assertEquals(Object expected, Object actual)

XCTestでは expression1 expression2 と区別なく呼んでいる。 @autoclosure がついていて、例外も投げられるのがポイント。

func XCTAssertEqual<T>(_ expression1: @autoclosure () throws -> T, _ expression2: @autoclosure () throws -> T, _ message: @autoclosure () -> String = default, file: StaticString = #file, line: UInt = #line) where T : Equatable

XCTestとは

OCUnitに取って代わったテストフレームワーク。List of unit testing frameworks - Wikipediaに記載があり、xUnitの一員とされている。

実装はたぶん3種類。

swift-corelibs-xctestには、README.mdに

This version of XCTest uses the same API as the XCTest you are familiar with from Xcode. Our goal is to enable your project's tests to run on all Swift platforms without having to rewrite them.

とあるように、OSに関係するもの(UIやActivityなど)が入っていない。
一方でapple/swiftのほうには // --- XCTest API Swiftification --- とあったりOS X用の実装が見られたりする。

  • apple/swiftのXCTest: Xcodeで作っている時に動く方。
  • apple/swift-corelibs-xctest: swift test の時に動く方。

と考えてよさそう。
Xcode - Features - Apple Developerの説明では

XCTest APIs make it easy to build unit tests that exercise app functionality and are capable of running on Mac, iPad, iPhone, or Simulator.

ということになっている。

XCTAssertとして何があるのか

カテゴリ別に

  • Booleanアサーション
  • Nilアサーション
  • Equalityアサーション
  • Comparableアサーション
  • Errorアサーション(Swift版のみ)
  • NSExceptionアサーション(ObjC版のみ)

がある。このほかにFailing Unconditionallyというカテゴリ( XCTFail )も存在する。

これらのアサーションが共通してやっていること:

  1. expression という名前で与えられているクロージャを実行して値を得る
  2. 期待通りの結果になっているか確認する( XCTAssertTrue なら1で得た値が true であることを確認する。 XCTAssertEqual なら2つの expression が同じかどうか確認する)
  3. 期待通りだったら何もしない。1で例外が発生したり、2で期待通りの結果になっていないことがわかったりした時は、然るべきところに報告する

Kent Beck氏が提唱した理念によれば、テストフレームワークはfailure(期待結果と実結果が一致しない)とerror(failureより問題のある事態。確認していなかったこと)を区別する。swift-corelibs-xctestでもapple/swiftでもこの2つを区別して報告している。

本題: それぞれの違いについて

apple/swift-corelibs-xctestとapple/swiftの違い

apple/swift-corelibs-xctestにはがっつり実装があるが、apple/swiftの方は

  • CMakeLists.txt
  • XCTest.swift
  • XCTestCaseAdditions.mm

の3ファイルのみ。そのXCTest.swiftにも

  • XCTContextXCUIElement のextension
  • アサーション各種

しかなく、 XCTestCase やアサーション内の一部の処理などの肝心なところが見当たらない。

アサーションにおける両者の違いとしては次の3つがある。

  • 例外が発生した時の処理
  • 例外スロー/失敗時の報告
  • NaN比較

それ以外だと、apple/swiftの方にはひたすらtodoだのfixmeだの書いてあるとか。

例外が発生した時の処理

expression クロージャ実行時に起こりうる例外は、swift-corelibs-xctestでは Swift.Error のみ、apple/swiftではSwift由来の Swift.Error とObjC由来の NSException の2つになっている。

なので、swift-corelibs-xctestでは単にcatchしているだけ。

    let result: _XCTAssertionResult
    do {
        result = try expression()
    } catch {
        result = .unexpectedFailure(error)
    }

apple/swiftでは、 blockErrorOptional: Error?Swift.Error を、 exceptionResultNSException を入れることで両方を処理している。_XCTRunThrowableBlockBridge の実装が見当たらないので詳細はよくわからない。

  var blockErrorOptional: Error?

  let exceptionResult = _XCTRunThrowableBlockBridge({
    do {
      try block()
    } catch {
      blockErrorOptional = error
    }
  })

  if let blockError = blockErrorOptional {
    return .failedWithError(error: blockError)
  } else if let exceptionResult = exceptionResult {

    if exceptionResult["type"] == "objc" {
      return .failedWithException(
        className: exceptionResult["className"]!,
        name: exceptionResult["name"]!,
        reason: exceptionResult["reason"]!)
    } else {
      return .failedWithUnknownException
    }
  } else {
    return .success
  }

例外スロー/失敗時の報告

上のコードにもあるように、 expression の結果は一旦enumの形で保存される。その後、failure/errorであればその旨を報告してXCTAssertの処理が完了する。

結果を表すenum

swift-corelibs-xctestのほうが「errorとfailureが区別される」というのがわかりやすい。

private enum _XCTAssertionResult {
    case success
    case expectedFailure(String?)
    case unexpectedFailure(Swift.Error)

    var isExpected: Bool {
        switch self {
        case .unexpectedFailure(_): return false
        default: return true
        }
    }

    以下略
}

var isExpected: Bool.success 時と .expectedFailure 時に true になるもの。failureはあくまでありうる事態なので、 isExpected に含まれる。

一方、apple/swiftには _XCTAssertionResult のような「成功/error/failure」のenumはない。先ほどのコードで登場したenumはexpressionクロージャ実行の「成功/ Swift.Error / NSException 」のenumである。

enum _XCTThrowableBlockResult {
  case success
  case failedWithError(error: Error)
  case failedWithException(className: String, name: String, reason: String)
  case failedWithUnknownException
}

そのため、 _XCTThrowableBlockResult.success となってもそこで終わりではなく、期待結果との比較作業をしてから報告をする。

報告処理

swift-corelibs-xctestでは、 XCTestCase にある open func recordFailure(withDescription description: String, inFile filePath: String, atLine lineNumber: Int, expected: Bool) メソッドを呼んだらアサーションの役目が終わる。
recordしたのちに行われるのは

  • errorとfailureの数のカウント
  • error/failureしたアサーションの位置を出力
  • error/failureした数の出力

である。
数を記録しているのは XCTestRun で、

    /// The number of test executions recorded during the run.
    open private(set) var executionCount: Int = 0

    /// The number of test failures recorded during the run.
    open private(set) var failureCount: Int = 0

    /// The number of uncaught exceptions recorded during the run.
    open private(set) var unexpectedExceptionCount: Int = 0

というフィールドがあり、必要に応じてインクリメントされる。
出力を担っているのが internal class PrintObserver で、アサーションの位置や数えた合計を出力してくれる。

        printAndFlush("\(file):\(lineNumber): error: \(testCase.name) : \(description)")
        printAndFlush(
            "\t Executed \(testRun.executionCount) \(tests), " +
            "with \(testRun.totalFailureCount) \(failures) (\(testRun.unexpectedExceptionCount) unexpected) " +
            "in \(formatTimeInterval(testRun.testDuration)) (\(formatTimeInterval(testRun.totalDuration))) seconds"
        )

swift/appleでは func _XCTRegisterFailure(_ expected: Bool, _ condition: String, _ message: @autoclosure () -> String, _ file: StaticString, _ line: UInt) を呼ぶ。expectedはswift-corelibs-xctestの isExpected と意味が同じで、failureなら true 、error(例外スロー)なら false を入れる。conditionには expressionSwift.ErrorNSException の詳細メッセージが入る。
具体的なregister処理や詳細メッセージの作り方はわからない。

NaN比較

浮動小数点にはふつうの数以外にNaNというものも含まれている。

http://ieeexplore.ieee.org/document/4610935/

NaNはnot a number、非数を示す。 .infinity とはまた別。Swiftでは、 .nan を含む評価は .isNaN といった専用のものを除き全てfalseになる。 .nan == .nan ですらfalse。 FloatingPoint には .nan のサンプルコードが載っている。
https://developer.apple.com/documentation/swift/floatingpoint

比較評価でもエラーなしでfalseを返すので、 .nan 専用の処理は必要ない。と思うのだが、apple/swift版では数値計算に入る前に .nan の判定が入っている。

return (!value1.isNaN && !value2.isNaN)
  && (abs(value1 - value2) <= accuracy)

swift-corelibs-xctest版では何の前置きもなしに比較を行なっている。

if abs(value1.distance(to: value2)) <= abs(accuracy.distance(to: T(0))) {

なお、 -distance で違いはない。

For two values x and y, the result of x.distance(to: y) is equal to y - x

Swift版とObjC版の違い

Equalityアサーション

ObjC版では「Cスカラ型」「 id 型」それぞれに対してアサーションが用意されている。説明書きに

スカラ型( XCTAssertEqual ):

Generates a failure when expression1 != expression2.

id 型( XCTAssertEqualObjects ):

Generates a failure when expression1 is not equal to expression2.

とあるように、この2つは比較方法が違う。これは同値比較の仕様から来たもの。同じような例としてはJava(プリミティブ型と参照型)がある。→前に書いたQiita

一方、Swift版には「 T 」「 T? 」の2種類が用意されている。

代表して行数が少ないswift-corelibs-xctest版の XCTAssertEqual を見てみる。apple/swift版もやっていることは同じ。
1つ目が T 、2つ目が T? 用。

let (value1, value2) = (try expression1(), try expression2())
if value1 == value2 {
    return .success
} else {
    return .expectedFailure("(\"\(value1)\") is not equal to (\"\(value2)\")")
}
let (value1, value2) = (try expression1(), try expression2())
if value1 == value2 {
    return .success
} else {
    return .expectedFailure("(\"\(String(describing: value1))\") is not equal to (\"\(String(describing: value2))\")")
}

比較ではなく、失敗後のメッセージの作成方法に違いがある。
Swiftにおける同値比較は Equatable が担っており、実際にどう比較するかは == が全部吸収してくれる。そのためObjCやJavaのように、比較方法別にアサーションを用意する必要はない。

メッセージの作り方で分けたのは、 Optional<T> をそのまま文字列補間するとWarningの対象になってしまうからと考えられる。ユーザさんの目にとまるところでやっちゃうと Optional(Hello, world!) と出て恥ずかしいやつ。

Screen Shot 2017-10-25 at 0.42.03.png

このwarningの修正方法としては、 a ?? "初期値" などで Optional<T>
自体を回避するか、 Stringinit(describing:) を使うことで"Optional()"と表示されても問題ないという意思表示をするかがあり、XCTAssertでは後者を採用している。

https://developer.apple.com/documentation/swift/string/2427941-init
https://github.com/apple/swift/blob/53b84192795aa007bbe4696699b80b2e243a05aa/stdlib/public/core/StringInterpolation.swift

Compareアサーション

  • ObjC版: Cスカラ型のみ
  • Swift版: T: Comparable のみ

XCTAssertEqualT? を含む同値判定ができたのは Optional<T> がきちんと == を多重定義しているからであって、 Comparable では Optional<T> を含む比較はできない。

だと思う。

Error/NSExceptionアサーション

ObjCでは XCTAssertThrows XCTAssertThrowsSpecific XCTAssertThrowsSpecificNamed などと種類が多い一方で、Swiftでは XCTAssertThrowsErrorXCTAssertNoThrow しかなく、エラーの種類までは見てくれない。

Swiftだとエラーの型の判断がむずいからかな…?
apple/swift版を見に行くとspecific系は全て // FIXME: Unsupported になっている。

https://github.com/apple/swift/blob/d03a575279cf5c523779ef68f8d7903f09ba901e/stdlib/public/SDK/XCTest/XCTest.swift

まとめ

  • XCTestは読みやすいし面白い
  • FIXMEのところを実装できたらすごいなあ
  • 「failure」「error」の違いは雰囲気で何となくやっていきましょう
    • Kent Beckさんの方が間違っているだけな気がする

References

https://developer.apple.com/documentation/xctest/
https://github.com/apple/swift/tree/master/stdlib/public/SDK/XCTest
https://github.com/apple/swift-corelibs-xctest
http://junit.org/junit5/docs/current/api
https://web.archive.org/web/20150315073817/http://www.xprogramming.com/testfram.htm
https://www.iso.org/standard/63598.html