はじまり
Xcode7から導入されたUITestingですが、そろそろiOS9専用の機能も増えてきているし本格的に社内のプロジェクトで使ってみてもいいかなあなんて思っている方も多いのではないでしょうか。今回は導入から良く使うコードのチートシートをまとめてみました。つらいテスト作業を少しでも少なくするお手伝いになれば幸いです。
UITestの導入
ターゲットの追加
既存のアプリがObjective-Cで書かれているものでもUnitTestのようにSwiftでテストが書けます。迷わずSwiftで導入してください。こちらの記事が丁寧でわかりやすいかと思います。
導入時の注意点
上記の方法ですぐにテストが実行できれば問題ないのですが、私の環境では以下のようなエラーが出ました。
Undefined symbols for architecture x86_64:
"_main", referenced from:
implicit entry/start for main executable
ld: symbol(s) not found for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)
ひとつ目のエラーはアプリケーションテストを行う為のセットアップが済んでいないと出ます。 UnitTestのドキュメントXcode Unit Testing Guideに書いてありました。
- XcodeでUITestターゲットの「Build Setting」を開く
- スコープバーをAllにしてBundle Loaderに下記の値を入れる
BUNDLE_LOADER = $(BUILT_PRODUCTS_DIR)/<app_name>.app/<app_name>
ふたつ目のエラーはバイナリタイプのせいみたいです。よくわからない。デバッグとテストをバンドルに変更してあげると消えました。
- XcodeでUITestターゲットの「Build Setting」を開く
- Mach-O TypeのDebugに下記の値を入れる
MACH_O_TYPE = mh_bundle
UITestの概要
XCUIApplication
こいつはアプリケーションそのものを示すクラスです。まずデフォルトで追加される$(PRODUCT_NAME)
ファイルのsetUp
メソッドを見てみましょう。
override func setUp() {
super.setUp()
// In UI tests it is usually best to stop immediately when a failure occurs.
continueAfterFailure = false
// UI tests must launch the application that they test. Doing this in setup will make sure it happens for each test method.
XCUIApplication().launch()
}
XCTestCaseのプロパティであるcontinueAfterFailure
はfalseを入れることでFailureだった場合そこで止まります。UnitTestではメソッド単位で独立したテストを行うのでデフォルトのtrueで使っていたと思いますが、UITestの場合一貫した画面操作なのでどこかでFailureになった時点で終了すべきなのだと思います。
次に出てきたXCUIApplication()
はUITestで使用する最も重要なクラスで、テスト対象のアプリケーションそのものを指し、.launch()
で起動しています。アプリの画面を操作する時はこのクラスが継承しているUIAElement
を使って要素を探し出し、操作するといった記述をしていきます。
UIAElement
自動テストをする上でアプリケーションがもつボタンやテーブルビューなどの全てのユーザインタフェースの親クラス。こいつに存在確認をしたり、タップやスクロールなどの操作を加えることができます。
要素の特定
リファレンスを見れば分かる通り、アプリケーションから取り出せるの要素(UIKitのクラス)は通常UIAElementArray
というUIAElement
の集合であり、その中から操作をしたいものを特定する必要が有ります。"UIA"は"UIAutomation"ですね。
一番単純な方法としては静的なテキストから要素を特定する方法です。ここにアプリケーションの画面上に"OK"と表示されているボタンがあります。
// ボタンテキストから特定する(staticTextsは省略可能)
XCUIApplication().buttons.staticTexts["OK"]
テキストのない画像だけボタンの場合はAssetsに登録されている画像名でも特定が可能です。特定の方法はテキストと同じです。
// 画像名から特定する
XCUIApplication().buttons["thmbs ok"]
しかしこれではテキストが可変だった場合や、画像名を変更した場合正常に動作しません。そこでIdentityInspectorにあるAccessibilityのIdentifierを設定することでテキストや画像名に代替して使用できます。
コードの場合はボタンのインスタンスに対して下記のように指定します。あまり書きたくないですね。
thumbsButton.accessibilityIdentifier = "Thumbs Button"
特定の方法はテキストと同じです。
// Identifierから特定する
XCUIApplication().buttons["Thumbs Button"]
一番ハマったのはテーブルビューなどでセル内のContentViewにある要素を特定したい場合です。その要素自体にIdentifierが設定すれば良いのですが、動的に生成されるセルの中身に一々上記のコードを書くのは嫌です。
ここで🍕のラベルを持つセルのテキストフィールドに値を入力するためタップする場合はこのようにしました。
// 子のビューのエレメントタイプから特定する
XCUIApplication().tables.cells.containingType(.StaticText, identifier:"🍕").childrenMatchingType(.TextField).element.tap()
また、個人的に面白いと感じたのですがXCUIApplication
は要素間の階層を自動的に補完してくれます。下記のコードは全て同じ動作をします。
// セルの中のボタンをタップする
XCUIApplication().tables.cells.buttons["Identifier"].tap()
// "Identifier"が画面内で要素を特定できる唯一のものであれば
XCUIApplication().buttons["Identifier"].tap()
// そのUIAElementが唯一のもの(ボタンが画面内にひとつ)であれば
XCUIApplication().buttons.element.tap()
チートシート
ここではテストで良く使うXCUIElement
やXCUIElementQuery
などのメソッドを紹介していきます。
要素やデバイスの操作
タップ
ボタンをタップ、セルをタップ、テキストフィールドにフォーカスするなどあらゆる場面で使います。ロングタップの場合は秒数を指定できます。
let button = XCUIApplication().buttons["Button"]
button.tap()
button.tapWithNumberOfTaps(5)
button.pressForDuration(5)
スワイプ
いい感じにスワイプしてくれます。テーブルビューの場合は1画面分スワイプしてくれる感じでした。
XCUIApplication().tables.cells["Cell"].swipeUp()
ドラッグ
pressForDuration:thenDragToElement:
を使います。セルの入れ替えの場合は指定したセル要素の上までドラッグすれば入れ替わります。
let tables = XCUIApplication().tables
let topCell = tables.cells["Top Cell"]
let bottomCell = tables.cells["Bottom Cell"]
bottomCell.pressForDuration(0.5, thenDragToElement: topCell)
テキストを入力
上記のタップ操作をしてから入力することができます。
XCUIApplication().textFields["Name"].typeText("John Smith")
インデックスで指定
XCUIApplication().tables.elementBoundByIndex(0)
XCUIApplication().tabBars.elementBoundByIndex(1)
XCUIApplication().toolbars.elementBoundByIndex(2)
スライダーの操作
XCUIApplication().sliders["Percentage"].adjustToNormalizedSliderPosition(0.5)
ピッカーの操作
XCUIApplication().pickerWheels["Age"].adjustToPickerWheelValue("25")
複数のピッカーが並んでいる場合は少し複雑になります。ここで出てくるNSPredicate
はデータの抽出などで使用する条件(クエリ)を作成できるクラスです。構文についてはこちらを参照してください。あまり理解できていないです。
// "First Picker"で前方一致という条件
let firstPredicate = NSPredicate(format: "label BEGINSWITH 'First Picker'")
let firstPicker = app.pickerWheels.elementMatchingPredicate(firstPredicate)
firstPicker.adjustToPickerWheelValue("first")
// "Second Picker"で前方一致という条件
let secondPredicate = NSPredicate(format: "label BEGINSWITH 'Second Picker'")
let secondPicker = app.pickerWheels.elementMatchingPredicate(secondPredicate)
secondPicker.adjustToPickerWheelValue("second")
ウェブビューのリンクタップ
XCUIApplication().links["Top"].tap()
リフレッシュ操作
リロードなどをする時に行う引っ張ってリフレッシュする操作はSpriteKitで使用するCGVectorMake()
とpressForDuration:thenDragToCoordinate:
を使用します。最小値は6[m/sec]だそうです。
let cell = XCUIApplication().tables.cells["Cell"]
// 縦方向に重力ゼロと6[m/s]のXCUICoordinateを生成
let start = cell.coordinateWithNormalizedOffset(CGVectorMake(0, 0))
let finish = cell.coordinateWithNormalizedOffset(CGVectorMake(0, 6))
// 上記の重力がかかるまで引っ張る
start.pressForDuration(0, thenDragToCoordinate: finish)
アラートが出た時の処理
アラートが出た時の為に事前に処理を登録しておくことができます。
// 位置情報の許可アラートが出た時の処理
addUIInterruptionMonitorWithDescription("Location Services") { (alert) -> Bool in
alert.buttons["Allow"].tap()
return true
}
// 地図画面に移動して上記の処理を実行
XCUIApplication().buttons["Map"].tap()
XCUIApplication().tap()
キーボードの操作
テキストフィールドなどでキーボードが出ている状態で使えます。buttons
とkeys
が別になっているのが気持ち悪いですね。ちなみにapp.keyboards
が通常の指定の仕方です。
let app = XCUIApplication()
app.buttons["Return"].tap()
app.buttons["Next keyboard"].tap()
app.keys["more, letters"].tap()
app.keys["more, numbers"].tap()
app.keys["delete"].tap()
app.keys["shift"].tap()
app.otherElements["good"].tap()
app.buttons["Hide keyboard"].tap()
デバイスの操作
// ホームボタンを押下
XCUIDevice.sharedDevice().pressButton(XCUIDeviceButton.Home)
// デバイスを左回転
XCUIDevice.sharedDevice().orientation = .LandscapeLeft
要素のテスト
存在確認
UITestの中で一番基本になるテストだと思います。要素が存在すればtrueが返ってきます。
// "Button"のIdentifierを持ったボタンが存在するか
XCTAssert(XCUIApplication().buttons["Button"].exists)
// プッシュ先でどの画面かナビゲーションバーのタイトルで確認する
XCTAssert(app.navigationBars["Title"].exists)
表示確認
上記のexists
だと画面外で表示されていてもtrueが返ってきてしまします。ここで画面にちゃんと表示されているかを確認するにはCGRectContainsRect()
を使用します。
let app = XCUIApplication()
// ウィンドウ(UIKitだとUIWindow)を取得
let window = app.windows.elementBoundByIndex(0)
// ボタンがウィンドウのフレーム内に含まれているか
let element = app.buttons["Button"]
XCTAssert(CGRectContainsRect(window.frame, element.frame))
非同期処理
読み込み画面が出るなどの非同期処理が走る場合にある条件を満たすまで待つことができます。ピッカーで出てきたNSPredicate
を使っています。
// くるくる画像がいなくなるまでという条件
let waitingImage = self.app.images["Waiting"]
let notExists = NSPredicate(format: "exists == false")
// 検索してくるくるがいなくなるまで5秒間処理を中断する
expectationForPredicate(notExists, evaluatedWithObject: waitingImage, handler: nil)
app.buttons["Search"].tap()
waitForExpectationsWithTimeout(5, handler: nil)
ちなみにwaitForExpectationsWithTimeout
で指定した時間内に条件を満たさなかった場合Failureとなりテストが終了します。
その他
デバッグ
シミュレータ内でGeneral > Accessibilityの Accessibility Inspectorを使うと要素のIdentifierやフレームなどを確認できます。
上記を立ち上げると操作が面倒なので、ちょっと見たい時などはコンソールに出力することもできます。
print(XCUIApplication().buttons["Button"].debugDescription)
おしまい
ここまでズラズラと書いてきましたが、Identifierがしっかりと設定されていればチートシートにある大部分はレコーディング機能を使用すれば自動で記述してくれます。日本語ダメだったり、結構わけわからないことするのでリファクタリングは必須ですが…
あと機能の改修を行った際にはほぼ間違いなくUITestのコードも改修が必要になると思われます。この修正に当たるリソースと人力で行うテスト作業のリソースを比べてうまく運用していかないと本末転倒なことになりそうです。今後のアップデートに期待したいと思います。
参考文献
UIAElement Class Reference
UIAElementArray Class Reference
XCTest Reference
UI Testing Cheat Sheet
UI Testing Cheat Sheet and Examples