Edited at

XcodeのUIテストフレームワーク「XCUITest」のTips

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は効果的に使うと、テストを楽しめると思います!:wink:


Tips

ここからが私なりの知見です。


利用範囲をあらかじめ決めておくべし


  • XCUITestはユーザー操作をシミュレーションするモノなので、結合テスト的に「も」使えます。

  • 画面単独のUIテストにのみ利用するのか、画面間の結合にも利用するのか、チーム内で利用範囲の認識を合わせておいた方が良いと思います。


テストコードの作成単位をあらかじめ決めておくべし


  • 上とも関連しますが、テストメソッドやファイルをどのような単位で作成するのか、あらかじめチームの決め事を作っておかないと、カオスになります。

  • 画面単独であれば、"テスト対象クラス名UITests.swift"で事足りますが、画面間の結合にも利用するとなると、どのファイルにどんなシナリオが含まれるのか、さっぱり分からなくなります。


レコーディング機能は極力使うべからず


  • レコーディング機能は、必ずView(テスト対象の項目)を取得できるとは限らず、エラーになる場合もあります。

  • レコーディング機能に頼りすぎると、上手くViewを取得できない場合に時間を浪費します。

  • レコーディング機能で自動生成されるコードからはContextが読み取りにくく、テストコードのメンテナンスにコストが掛かります。


accessibilityIdentifierを活用すべし


  • どのようにしてViewを取得するのが良いかというと、accessibilityIdentifierを設定するのが良いです。


    • UIButtonなど、テキストを持っているViewであれば、app.buttons["ほげ"]などでも簡単に取得できるのですが、UIImageViewなどはaccessibilityIdentifierを設定しないと取得しづらいです。

    • また、『「ほげ」を「ふが」に文言変更する』なんていういことも多々ありますし…



コードで設定する場合はこんな感じで書きます。

button.accessibilityIdentifier = "ViewController_button"

StoryBoardであれば、"Identity inspector"にて設定します。

スクリーンショット 2018-12-16 6.56.50.png

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に表示されて見やすくなります。

スクリーンショット 2018-12-15 17.38.27.png


「待つ」コードがポイント


  • 「何かのタイミングで通信を開始して、インジケータを表示して操作禁止とし、レスポンスが返ってきたら画面操作を可能にして…」とか、よくあると思います。

  • 「表示されるまで待つ」とか「tapできるようになるまで待つ」という場面は結構頻繁にあり、待たせ方がポイントになります。


    • 固定的な時間で待たせると、環境によって待ち時間が足りなかったり、実行時間が余分に掛かってしまったりします。



  • 後述するサンプルコードのwaitToAppear()waitToHittable()を参照してください。


共通メソッドでAssertする場合は、引数に#fileと#lineを設定すべし


  • そうすることで、共通メソッドの呼び出し元の行がAssertion Failureになります。

  • 逆にそうしないと、共通メソッド内でAssertion Failureになってしまい、失敗原因箇所がわかりにくくなります。

  • 後述するサンプルコードのwaitToAppear()waitToHittable()を参照してください。


通信のスタブと組み合わせるべし


テストデータを投入しやすいローカルDB



  • Realm SwiftはXCUITestに向いていると思います。


    • JSONからデータを簡単に投入できます。

    • メモリー上に簡単にインスタンスを生成できます。



環境変数などを上手く利用して、XCUITestからの実行の場合は通信やDBをスタブ化するようにすると捗ります。


サンプルアプリ

ここからはサンプルコードによる解説です。

まず、テスト対象のサンプルアプリです。

Qiita201812.gif

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
}
}