昨年これといった変更がなかった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
(復習)従来の非同期処理テスト方法
-
func expectation(description: String)を呼ぶ。 - 処理終了時に
func fulfill()を呼ぶ。 -
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()")
}
}
実行順は
func setUp()func testExample()- addしたteardownブロックたち
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() // 手前に来るだけ
アプリの状態の確認
XCUIApplication の var state: XCUIApplication.State でアプリの状態が取れる。
XCUIApplication.State はenumで、 case runningForeground や case notRunning などがいる。
UI Elements
変更点
- ステータスメニューを扱えるようになった
- エレメントが
existsになるまで待てるようになった -
var firstMatch: XCUIElementで速くエレメントを取れるようになった
ステータスメニュー
macの右上にいるアレ。
macOS Sierra: メニューバーの内容
いつものenum XCUIElement.Type に case 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 のみを持つ。そしてこのプロトコルを実装しているのは
XCUIScreenXCUIElement
の2つである。
XCUIScreen からスクショを撮る
XCUIScreen には class var main: XCUIScreen と class 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を開けば保存もできる。
型の変更:
XCUIScreenshot は var image: UIImage と var pngRepresentation: Data を持っていて変換ができる。
保存:
次に紹介する XCTAttachment を使う。
テスト中に得たデータの保存
Adding Attachments to Tests and Activities | Apple Developer Documentation
XCTAttachment を用いることでデータを保存できるようになった。
方法:
- 保存したいデータを使って
XCTAttachmentをイニシャライズする -
XCTActivityのメソッドfunc add(XCTAttachment)でaddする(XCTestCaseが実装済み) - 必要に応じてデータ保持期間を指定
- テストしたらログから確認したり保存したりする
let attachment = XCTAttachment(screenshot: screenshot)
attachment.lifetime = .keepAlways // 成功後もデータを保持
add(attachment)
イニシャライザ
以下のものからイニシャライズできる。つまり以下の型で表されるデータを保存できる。
Data-
URL: ファイルやフォルダ。フォルダをzipにすることもできる -
UIImage: 画質指定も可能 XCUIScreenshot-
Any: plistのこと NSSecureCoding-
String: .text
保持期間
何も指定しない場合、テスト成功時に消されてしまう。成功したい時でも保持して確認したい場合はその旨を指定する。
- 各
XCTAttachmentのvar lifetime: XCTAttachment.Lifetimeに与える - テストschemeで一括指定する
まとめ
既存APIを残したまま新機能が増えた。