Xcode 9.0 Beta 1におけるXCTest.frameworkの変更点

  • 25
    Like
  • 0
    Comment

昨年これといった変更がなかったXCTest.frameworkですが、今年は色々と追加や変更があり、WWDC2017でも「What's New in Testing」というセッションが設けられました。

本記事ではXCTest.framework in Xcode 9.0 Beta 1の追加や変更を取り上げます。

XCTest.frameworkの歴史

2013年: XCTest.framework誕生
2014年: 非同期処理のテストやパフォーマンスのテストができるようになった
2015年: UIテストができるようになった
2016年: 沈黙
2017年: APIが増えたり型が変わったりスクショが取れるようになったりした

XCTest.frameworkの主な変更点

deprecatedはありません。

細かい変更点:

  • あちこちで Int 型引数が UInt 型に変わった。
  • 一部で引数から @escaping が取れた。
  • その他、引数の型が色々変わった。
    • func measureMetrics の引数の1つが String から XCTPerformanceMetric ( init(String) を持つ)に変わるなど

今回の目玉:

  • 非同期テスト用のAPIの設計が見直された
  • teardownブロックが導入された
  • 名前ありアクティビティが導入された
  • マルチAppテストができるようになった
  • UI Elementの仲間が増えたりパフォーマンスを見直したりした
  • スクショが撮れるようになった
  • テスト中にできたデータ(スクショ含)を保存できるようになった

非同期API

Testing Asynchronous Operations with Expectations | Apple Developer Documentation

(復習)従来の非同期処理テスト方法

  1. func expectation(description: String) を呼ぶ。
  2. 処理終了時に func fulfill() を呼ぶ。
  3. func waitForExpectations(timeout: TimeInterval, handler: XCWaitCompletionHandler? = nil) で待つ。

問題点

  • class XCTestCaseに直接実装されていた。
  • タイムアウト時は問答無用で常に失敗扱いにされた。慈悲はない。
  • waitForExpectationsがタイムアウトとハンドラしか取らないせいで何を待機しているのか非明示的だった。

今回の改善点の概要

Xcode8.3:

  • 実装がXCTestCaseから独立した
  • 何を待機するのか具体的に書くwaitメソッドができた
  • XCTestExpectation の種類が豊富になった
  • エラー時の処理を書けるようになった
  • タイムアウト時にテスト成功にするか失敗にするか、開発者が決められるようになった

Xcode9.0 Beta 1:

  • 非同期処理時のエラーを表す XCTestError ができて、waitForExpectationsのハンドラ( (Error?) -> Void )で処理を具体的に行えるようになった

実装の独立

中身の話なのでAPI自体に変更はない。が、waitForExpectationsを見に行くと様子が変わっているのがわかる。

XCTestExpectation

これまでは func expectation(description: String) で作っていたが、イニシャライザで作ることもできるようになった。ただし、イニシャライザで作った場合は登録まではしてくれないのでwaitForExpectationsが認識しない。

NSNotification を待つ XCTNSNotificationExpectation など種類が増えた。

今使えるwaitメソッド

結果的に待機に使うメソッドは3種類存在する。

  • (XCTestCase) waitForExpectations
  • (XCTestCase) wait
  • (XCTWaiter) wait

(XCTestCase) waitForExpectations

func waitForExpectations(timeout: TimeInterval, handler: XCWaitCompletionHandler? = nil) 自体に変更はない。
XCWaitCompletionHandler(Error?) -> Void のtypealiasであることにも変更はない。

が、この (Error?)XCTestError を取ってくるようになった。まるでエラー全般をとってくるような名前をしているが、非同期待機中のエラーのみを扱う。 XCTestError 自身はstructであり、エラーの種類はenum XCTestError.Code が列挙している。

waitForExpectations(timeout: 10) { error in
    guard let error = error as? XCTestError else {
        return
    }
    switch error.code {
    case .failureWhileWaiting:
        print("failure")
    case .timeoutWhileWaiting:
        print("timeout")
    }
}

ハンドラを書いたところで最終的にテストが失敗扱いになるのは従来から変わっていない。

(XCTestCase) wait

func wait(for: [XCTestExpectation], timeout: TimeInterval) など。引数で XCTestExpectation を与えないといけないので、こいつが何を待つのかわかりやすくなった。

待機中にタイムアウト等のエラーが起きた場合は、このメソッドを持つXCTestCase(が実装している XCTWaiterDelegate )のデリゲートのメソッドが呼ばれる。
func waiter(XCTWaiter, didTimeoutWithUnfulfilledExpectations: [XCTestExpectation]) とか。

これを使うとタイムアウトでもエラーにならなくなるので、エラー扱いにしたい場合は該当メソッドをoverrideして自力でfailする。

(XCTWaiter) wait

XCTWaiter:
XCTestCase版waitよりもできることが多い子。XCTestCase自身以外をデリゲートに使いたい場合は init(delegate: XCTWaiterDelegate?) でデリゲートを指定する。省略した場合はXCTestCaseのメソッドが呼ばれる。

XCTWaiterのwaitは func wait(for expectations: [XCTestExpectation],
timeout seconds: TimeInterval) -> XCTWaiter.Result
など。enum XCTWaiter.Resultで結果が取れるので、デリゲートメソッドで処理するだけでなく戻り値で分岐することもできる。

let result = XCTWaiter().wait(for: [expectation], timeout: 10)
switch result {
case .completed:
    break
case .timedOut:
    break
default:
    break
}

teardownブロック

Understanding Setup and Teardown for Test Methods | Apple Developer Documentation

これまでにもXCTestの func tearDown() やXCTestCaseの class func tearDown() がいたが、今回 func addTeardownBlock(_ block: @escaping () -> Void) が増えた。つまり動的に/個別にteardownをつけられるようになった。

func testExample() {
    print("testExample")
    addTeardownBlock {
        print("teardown in testExample()")
    }
}

実行順は

  1. func setUp()
  2. func testExample()
  3. addしたteardownブロックたち
  4. func tearDown()

である。このteardownブロックはLIFOで管理されているため、setUpとtextExampleで積んだteardownブロックは逆順に処理される。teardownブロックやtearDownのステップに突入してから積もうとすると実行時にエラーになるので、teardown中にaddTeardownBlockしてはいけない。

実行順の図がUnderstanding Setup and Teardown for Test Methods | Apple Developer Documentationに乗っている。

積むのはどのスレッドからでも可能だが実行はメインスレッドがやる。
teardownに限らずテストメソッドはrethrow禁止。自分でcatchまでやる。

名前ありアクティビティ

Grouping Tests into Substeps with Activities | Apple Developer Documentation

これまでの問題点

UIテストをやるとログにUI操作が1つ1つ記録され、それが長くて読みにくかった

今回の変更点

class func runActivity(named: String, block: (XCTActivity) -> ()) を持つclass XCTContext ができた。与えた名前はログで使われ、さらにblock内のログ出力が畳まれて読みやすくなる。
runActivityは入れ子にすることができるので、どんどんログを畳むことができる。

共通操作を (XCTActivity) -> () に切り分けてrunActivityで呼び出すなど。

func testExample() {
    XCTContext.runActivity(named: "stepA") { a in
        XCTContext.runActivity(named: "stepA-1") { a_1 in
        }
        XCTContext.runActivity(named: "stepA-2", block: hogeActivity)
    }
}

func hogeActivity(activity: XCTActivity) {
}

マルチAppテスト

UIテスト中に複数のアプリを起動し、両方を操作してテストすることができるようになった。

アプリのイニシャライザ

アプリを扱うのはclass XCUIApplication であり、イニシャライザが3つ用意されている。

init() :
target applicationで指定されているアプリが呼ばれる。UIテストありでプロジェクトを作成した時にできるサンプルのfunc setUp()内にいるのはこれ。

init(bundleIdentifier: String):
"com.example.HogeApp"と書く。

init(url: URL):
macOS専用。
/Applications/Slack.appって書いたら動いた。

アプリ起動

元からいる func launch() に加えて func activate() が増えた。
同期処理であること、起動中にエラーがあった場合はテスト失敗の扱いになることは共通。
違う点はすでにアプリが起動している時に呼んだ場合の挙動で、launch()はアプリを再起動させるがactivate()は停止させない点にある。なので、別のアプリを操作した後にresumeさせるのに使える。

let vim = XCUIApplication(url: URL(fileURLWithPath: "/Applications/MacVim.app"))
let emacs = XCUIApplication(url: URL(fileURLWithPath: "/Applications/Emacs.app"))
vim.launch()     // 起動
emacs.launch()   // 起動
vim.launch()     // 再起動
emacs.activate() // 手前に来るだけ

アプリの状態の確認

XCUIApplicationvar state: XCUIApplication.State でアプリの状態が取れる。
XCUIApplication.State はenumで、 case runningForegroundcase notRunning などがいる。

UI Elements

変更点

  • ステータスメニューを扱えるようになった
  • エレメントが exists になるまで待てるようになった
  • var firstMatch: XCUIElement で速くエレメントを取れるようになった

ステータスメニュー

macの右上にいるアレ。
macOS Sierra: メニューバーの内容

いつものenum XCUIElement.Typecase statusItem が増えている。

exists になるまで待機

UIElementが現れる前にタップ操作などをするとエラーになってしまったが、 func waitForExistence(timeout: TimeInterval) で安全に待つことができるようになった。
var exists: Bool がtrueになるまで待ち、タイムアウトした場合はテスト失敗とする。

let application = XCUIApplication()
application.launch()
let hogeButton = application.buttons["hoge"]
hogeButton.waitForExistence(timeout: 10)
hogeButton.tap()

firstMatch

欲しい XCUIElement を取るのは時間のかかることだったが、 var firstMatch: XCUIElement により最初の1つを発見した段階で早めに切り上げられるようになった。
buttons.firstMatch よりは navigationBars.buttons["Done"].firstMatch のように具体的な方が望ましいとのこと。

スクリーンショットを撮る

protocol XCUIScreenshotProviding というスクショメソッドを与えるプロトコルができた。このプロトコルは func screenshot() -> XCUIScreenshot のみを持つ。そしてこのプロトコルを実装しているのは

  • XCUIScreen
  • XCUIElement

の2つである。

XCUIScreen からスクショを撮る

XCUIScreen には class var main: XCUIScreenclass var screens: [XCUIScreen] があり、ここからscreenshot()を呼ぶことができる。

XCUIScreen.main.screenshot()
XCUIScreen.screens.map { $0.screenshot() }

mainは名前の通りメインスクリーンを得ることができる。
screensはデバイスにディスプレイが複数繋がっている時用。0番目がメインスクリーンになる。

(iPhone7シミュレータでscreensをやったらなぜか2枚取れたし、謎の1番目をスクショしようとしたらCannot capture a non-main screenって言われた。。)

XCUIElement からスクショを撮る

XCUIElementでwindowsを探す。 windows.firstMatch.screenshot() など。
(windows以外でやろうとしたら音もなくエラーになった。)

XCUIScreenshot が撮れたら

クイックルックで確認:
ブレイクポイントで止め、XCUIScreenshotが入っているモノの👁アイコンから確認する。ここからPreview.appを開けば保存もできる。

型の変更:
XCUIScreenshotvar image: UIImagevar pngRepresentation: Data を持っていて変換ができる。

保存:
次に紹介する XCTAttachment を使う。

テスト中に得たデータの保存

Adding Attachments to Tests and Activities | Apple Developer Documentation

XCTAttachment を用いることでデータを保存できるようになった。

方法:

  1. 保存したいデータを使って XCTAttachment をイニシャライズする
  2. XCTActivity のメソッド func add(XCTAttachment) でaddする(XCTestCaseが実装済み)
  3. 必要に応じてデータ保持期間を指定
  4. テストしたらログから確認したり保存したりする
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways  // 成功後もデータを保持
add(attachment)

イニシャライザ

以下のものからイニシャライズできる。つまり以下の型で表されるデータを保存できる。

  • Data
  • URL : ファイルやフォルダ。フォルダをzipにすることもできる
  • UIImage : 画質指定も可能
  • XCUIScreenshot
  • Any : plistのこと
  • NSSecureCoding
  • String : .text

保持期間

何も指定しない場合、テスト成功時に消されてしまう。成功したい時でも保持して確認したい場合はその旨を指定する。

  • XCTAttachmentvar lifetime: XCTAttachment.Lifetime に与える
  • テストschemeで一括指定する

まとめ

既存APIを残したまま新機能が増えた。