XCUITestでUIテストの自動化にチャレンジしています。
その中で得られたTipsを記します。
環境
Xcode 10.1
Swift 4.2
そもそも、XCUITestとは
- Xcodeに統合されているUIテストフレームワークです。
- シナリオコードを書いて、ユーザーの操作をシミュレーションします。
- アプリの挙動が期待通りになっているかのアサーションも、もちろんコードで記述します。
- アサーションに失敗したタイミングで、自動的にスクリーンショットを撮ってくれます。
- ブレークポイントで止めておいて、操作の記録を開始すると、操作をテストコードに自動変換してくれます(この「レコーディング機能」の注意については後述)。
- Xcode 10より"Parallel Testing"つまり並列実行が導入され、パフォーマンスが向上しました。
導入手順と、基本的なテストコードの書き方
本稿では割愛します。
以下の記事が参考になるかと思います。
【Swift】初めてのUITest導入
UITestの導入とチートシート
UI Testing Cheat Sheet and Examples
※少々前の記事になりますが、基本は変わっていないと思います。
メリット/デメリット、そして導入のポイント
XCUITestの導入を検討されている方向けに、先にまとめっぽいことを書きます。
※個人の感想です。
メリット
一般的なコトではありますが…
- 書いてしまえば、リグレッションテストを自動化できる。
- JenkinsなどでCIに組み込める。
- UIのデグレード防止に一定の効果があり、リファクタリングする"勇気"が出る。
- テストのドキュメントを書かなくても済む。
- したがって、アジャイルとは相性が良い(はず)。
デメリット
- 慣れないうちはテストを書くことにコストがかかる。
- リーダブルなテストコードを書くようにチーム内で統一意識を持たないと、テストコードのメンテナンスにコストがかかる。
- Parallel Testingが導入されたとはいえ、実行時間はそれなりに掛かる。
- テストコードが数百件になると、Parallel Testingでも1時間ぐらい掛かると思います。
ポイント
- XCUITestは書いていて楽しいけど実行時間が掛かるので、「書き過ぎ」に注意。
- ロジック部分はXCTestで書いた方がContextも分かりやすいし実行時間も短い。
- XCUITestはUI部分のテストだけと割り切った方が良い。
- ということは、アプリのアーキテクチャ検討の際、ロジックとUIを分離させる戦略が必要ということになります。
- アーキテクチャ検討の際は、テスト戦略やCIも踏まえて検討を!
- テストコードをリーダブルに保つ重要性をチーム全員が良く理解することが大事!
- 具体的には、テスト対象、テスト条件、そして期待結果(= Context)が分かるように書くこと
XCUITestは効果的に使うと、テストを楽しめると思います!
Tips
ここからが私なりの知見です。
利用範囲をあらかじめ決めておくべし
- XCUITestはユーザー操作をシミュレーションするモノなので、結合テスト的に「も」使えます。
- 画面単独のUIテストにのみ利用するのか、画面間の結合にも利用するのか、チーム内で利用範囲の認識を合わせておいた方が良いと思います。
テストコードの作成単位をあらかじめ決めておくべし
- 上とも関連しますが、テストメソッドやファイルをどのような単位で作成するのか、あらかじめチームの決め事を作っておかないと、カオスになります。
- 画面単独であれば、"テスト対象クラス名UITests.swift"で事足りますが、画面間の結合にも利用するとなると、どのファイルにどんなシナリオが含まれるのか、さっぱり分からなくなります。
レコーディング機能は極力使うべからず
- レコーディング機能は、必ずView(テスト対象の項目)を取得できるとは限らず、エラーになる場合もあります。
- レコーディング機能に頼りすぎると、上手くViewを取得できない場合に時間を浪費します。
- レコーディング機能で自動生成されるコードからはContextが読み取りにくく、テストコードのメンテナンスにコストが掛かります。
accessibilityIdentifierを活用すべし
- どのようにしてViewを取得するのが良いかというと、accessibilityIdentifierを設定するのが良いです。
- UIButtonなど、テキストを持っているViewであれば、
app.buttons["ほげ"]
などでも簡単に取得できるのですが、UIImageViewなどはaccessibilityIdentifierを設定しないと取得しづらいです。 - また、『「ほげ」を「ふが」に文言変更する』なんていういことも多々ありますし…
- UIButtonなど、テキストを持っているViewであれば、
コードで設定する場合はこんな感じで書きます。
button.accessibilityIdentifier = "ViewController_button"
StoryBoardであれば、"Identity inspector"にて設定します。
XCUITestではこんな感じで書きます。
app.buttons["ViewController_button"]
設定する文字列は、上のように「クラス名_View名」などルールを決めると、テストコードのContextを読み取りやすいと思います。
(単にapp.buttons["button"]
とすると、Contextが分かりにくくなります。)
XCTContext.runActivity(named:)でテストのContextを表現すべし
- 前述の通り、テストのContextを読み取れることは重要です。
- コメントに書くのも悪くないですが、どうせなら、
XCTContext.runActivity(named:)
にテスト条件や期待結果を記述する方が良いです。 -
XCTContext.runActivity(named:)
に書くと、以下のようにXcodeのReport navigatorに表示されて見やすくなります。
「待つ」コードがポイント
- 「何かのタイミングで通信を開始して、インジケータを表示して操作禁止とし、レスポンスが返ってきたら画面操作を可能にして…」とか、よくあると思います。
- 「表示されるまで待つ」とか「tapできるようになるまで待つ」という場面は結構頻繁にあり、待たせ方がポイントになります。
- 固定的な時間で待たせると、環境によって待ち時間が足りなかったり、実行時間が余分に掛かってしまったりします。
- 後述するサンプルコードの
waitToAppear()
、waitToHittable()
を参照してください。
共通メソッドでAssertする場合は、引数に#fileと#lineを設定すべし
- そうすることで、共通メソッドの呼び出し元の行がAssertion Failureになります。
- 逆にそうしないと、共通メソッド内でAssertion Failureになってしまい、失敗原因箇所がわかりにくくなります。
- 後述するサンプルコードの
waitToAppear()
、waitToHittable()
を参照してください。
通信のスタブと組み合わせるべし
- 通信エラーケースのテストを行うには、スタブ化が欠かせません。
- URLRequestのレスポンスをスタブ化するには、OHHTTPStubsという定番ライブラリがあります。
- OHHTTPStubsはWebViewのスタブ化はできません。そこで、Swifterという軽量HTTP Serverを使う手があります。
テストデータを投入しやすいローカルDB
-
Realm SwiftはXCUITestに向いていると思います。
- JSONからデータを簡単に投入できます。
- メモリー上に簡単にインスタンスを生成できます。
環境変数などを上手く利用して、XCUITestからの実行の場合は通信やDBをスタブ化するようにすると捗ります。
サンプルアプリ
ここからはサンプルコードによる解説です。
まず、テスト対象のサンプルアプリです。
UIButtonがtapされたらActionSheetを表示して、その選択結果によってUILabelにテキストを表示する、という、ごくシンプルなものです。
プロジェクトはGitHubに公開しておきました。
https://github.com/y-some/UITestSample
サンプルテストコード
import XCTest
class ViewControllerUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() {
continueAfterFailure = false
app.launch()
}
override func tearDown() {
}
func testExample() {
XCTContext.runActivity(named: "初期表示でTextViewの文言を確認") { (activity) in
waitToAppear(for: app.textViews["ViewController_textView"])
XCTAssertEqual(app.textViews["ViewController_textView"].value as? String,
"Hello world!\n\nXCUITestのサンプルです。")
}
XCTContext.runActivity(named: "ボタンをtap、ActionSheetのAction1をtapし、Labelの文言を確認") { (activity) in
waitToHittable(for: app.buttons["ViewController_button"]).tap()
waitToAppear(for: app.sheets["タイトル"])
app.buttons["Action1"].tap()
XCTAssertEqual(app.staticTexts["ViewController_label"].label,
"Action1がtapされました")
}
XCTContext.runActivity(named: "ボタンをtap、ActionSheetのAction2をtapし、Labelの文言を確認") { (activity) in
waitToHittable(for: app.buttons["ViewController_button"]).tap()
waitToAppear(for: app.sheets["タイトル"])
app.buttons["Action2"].tap()
XCTAssertEqual(app.staticTexts["ViewController_label"].label,
"Action2がtapされました")
}
XCTContext.runActivity(named: "ボタンをtap、ActionSheetのCancelをtapし、Labelの文言を確認") { (activity) in
waitToHittable(for: app.buttons["ViewController_button"]).tap()
waitToAppear(for: app.sheets["タイトル"])
app.buttons["Cancel"].tap()
XCTAssertFalse(app.staticTexts["ViewController_label"].exists)
}
}
}
extension XCTestCase {
func waitToAppear(for element: XCUIElement,
timeout: TimeInterval = 5,
file: StaticString = #file,
line: UInt = #line) {
let predicate = NSPredicate(format: "exists == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
XCTAssertEqual(result, .completed, file: file, line: line)
}
func waitToHittable(for element: XCUIElement,
timeout: TimeInterval = 5,
file: StaticString = #file,
line: UInt = #line) -> XCUIElement {
let predicate = NSPredicate(format: "hittable == true")
let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
let result = XCTWaiter().wait(for: [expectation], timeout: timeout)
XCTAssertEqual(result, .completed, file: file, line: line)
return element
}
}