0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

XCTest XCUITest SwiftTestingトラブルシューティング

Last updated at Posted at 2025-02-26

SwiftTesting

XCTest, XCUITest

導入まで

UITestを導入すべきかどうかの判断

「プロジェクトの規模・寿命やテスト頻度」「テストの項目の多さ」「テスト対象がどのくらい重要な機能・バグか」の考慮をすることになります。その上で、手動でやった場合と比較衡量し、UITestでやったほうが効率的と言える必要があります。

  • 回帰試験など

頻繁に大きく変更されるような不安定な箇所はあまりUITestには向かず、比較的安定的な部分で、何か繰り返し決まった項目をチェックしているのでそれを自動化したい(たとえば、回帰試験)場合など向いているかと思います。

回帰試験の項目が多いような規模の大きいプロジェクトで、かつ会社をあげてのプロジェクトであるなど今後何年も続いていくようなプロジェクトで、かつリリースを頻繁に行うので回帰試験も頻繁に行っているなどの状況に向いています(自社の基幹製品のプロジェクトなど)。

逆に小さなプロジェクトでテスト量が多くない場合や、もう近いうちに終わりが見えている、あるいは終わりの可能性があるプロジェクトでは、あまり意味がないかと思います。お客様都合でいつ終了するかわからないプロジェクトなどがこれに該当します。また、リリース量が多くなく、回帰試験を行う頻度が少ない場合も、恩恵を受けにくくなります。

また、UI Testは導入だけでなくメンテナンス・改善にも継続的にコストがかかります。そのため、破壊的な変更が多い機能・部分のテストなどは、メンテナンスにコストがかかりすぎるのであまり向いていないと言えます。この点回帰試験は、コードに変更を加えた部分以外も含めて、デグレが発生していないか全機能を念のため点検するいう趣旨のものです。そうすると、回帰試験は基本的にUITestに向いていると思われます。

また、あくまでテストで書いたことが確認できるだけなので、UITestをしているからといって人間の目視確認がまったく必要なくなるわけではない点にも注意してください。

  • バグ究明

稀にしか発生しないバグで、再現手順が複雑で、しかもどうしても原因を突き止めないといけないようなバグがあるとします。その調査のため、何度も同じ動作をする必要がある場合にもUI testが向いています。

ただテストを実装して終わりではなく、テストのフィードバックを受けて、また別パターンのテストで最試行・・・というサイクルを回す必要があります。結構長期戦となるので、そのコストを負ってでもやる価値があるという状況でのみやったほうがいいでしょう。

比較的簡単に再現できる場合は、手でやったほうが早いです。また、原因究明はせず代替手段で回避すれば着地してしまうようなバグの場合も、やる意味はあまりないかと思います。バグ調査のためだからといってすぐUI testをするのではなく、まずは、簡単に・確実に手で再現させる方法があるのではないか、原因究明しなくても回避手段・代替手段で着地させられないかを十分検討した後にしましょう。

また、バグ調査などをしていて、実は想定していた前提条件が全く違っており空振りになってしまった経験はないでしょうか。また、自分の担当システム(ここではアプリ側)で無理に調査してもわかる内容ではなく、他のシステム(サーバ・Webサイトなど)側の問題だったり、他のシステム側で調査したほうが早く原因究明できるものだった・・・などという経験はないでしょうか。

導入コストが高いだけに、UITestをせっかく書いたのにこのような空振りをしてしまうと非常に無駄となります。そのため、導入前に、他のシステム側や全体を把握している人と十分相談する・問題の切り分けをするなどして、「アプリ側でUITestをして調査するのが最も効率的である」と確認できてから導入しましょう。

@testable importを書いたのにundeclared class...などのエラーが頻発する

-> Targets >HogeProjectTests> Build Phases > Compile Sourcesの中に、HogeProject(メインのターゲット)のファイルが一部含まれていないかどうか確認してください。@testable importを書いてメインのターゲットをimportしていれば、本来メインターゲットのファイルをテストターゲットのCompile Sourcesとして加える必要はないので、メインターゲットのファイルは全部消してしまっていいはずなのですが、中途半端にCompile Sourcesに含まれていたりいなかったりと統一されていない状態だとエラーが起こるようです。

言い換えれば、テストターゲットのCompile Sourcesの中には、テストターゲットのファイルのみ含まれるようにして下さい。

参考
Use of undeclared type 'ViewController' when unit testing my own ViewController in Swift?

UITestがThe bundle couldn’t be loaded because it is damaged or missing necessary resourcesというメッセージが出てこける

Cocoapodsを使っているとUITestにライブラリが取り込まれない問題があるようです。
以下が参考になります。テスト対象ターゲットで、inhert_searchpathだけではなく、テスト対象ターゲットに追加したいライブラリを改めて二重に記載するとうまく取り込まれるようです。
Library not loadedでUITestsが落ちるときの対処法
The bundle UITests couldn’t be loadedbecause it is damaged or missing necessary resources. Try reinstalling the bundle

その他トラブルシューティング

XCTestを始めるときにつまづいたこととその解決法

テスト記述

UITestの書き方がわからない

典型的には

  • AccessibilityIdentifierを用いてUI要素を特定
  • waitForExistence()を用いて、そのUI要素を触れるようになるまで待つ
  • tap()などを用いて、したい動作をする
    という流れになります。

ここで、PageObjectableパターンというものを用いると、この流れを非常に簡潔に見やすく書くことができます。この書き方の場合、特定のUI要素を見つける、特定の動作をするということが「PageObject」という形のファイルにまとめて定義されます。UITestを実施に行うTestCaseにおいて、PageObjectを介して要素の特定・何らかの動作を行なっていくことになります。そのため、複数の箇所でそれらを使い回すのが非常に容易ですし、UIテストの流れをちょっと変えたいという時も容易に変更できます。

一例です。

PageObjectable.swift
import Foundation
import XCTest

protocol PageObjectable: AnyObject {
    
    /// Accessibility Identifier
    associatedtype A11y
    var app: XCUIApplication { get }
    init(app: XCUIApplication)
    
    /// ページが存在するか否か
    var exists: Bool { get }
    
    /// ページが存在するか否かの判定に使うXCUIElement
    var pageRepresentative: XCUIElement { get }
    func elementsExist(_ elements: [XCUIElement], timeout: Double) -> Bool
}

//デフォルト実装
extension PageObjectable {
    var app: XCUIApplication {
        return XCUIApplication()
    }
    
    var exists: Bool {
        return elementsExist([pageRepresentative], timeout: 5)
    }
    
    func elementsExist(_ elements: [XCUIElement], timeout: Double) -> Bool {
        for element in elements {
            if !element.waitForExistence(timeout: timeout) {
                return false
            }
        }
        
        return true
    }
}

BasePage.swift
import Foundation
import XCTest

final class BasePage: PageObjectable {
    let app: XCUIApplication
    init(app: XCUIApplication = XCUIApplication()) {
        self.app = app
    }
    
    /// Accessibility Identifier
    enum A11y {
        static let pageTitle = "Base"
        static let loginButton = "base_login_button"
        static let menuButton = "base_menu_button"
    }

    
    var pageRepresentative: XCUIElement {
        return app.otherElements[A11y.pageTitle].firstMatch
    }
    
    var registerLoginButton: XCUIElement {
        return app.navigationBars.buttons[A11y.loginButton].firstMatch
    }
    
    var menuBarButton: XCUIElement {
        return app.navigationBars.buttons[A11y.menuButton].firstMatch
    }
    
    @discardableResult
    func goToLoginPage() -> LoginPage? {
        guard loginButton.waitForExistence(timeout: 5) else { return nil }
        loginButton.tap()
        return LoginPage(app: app)
    }
    
    @discardableResult
    func goToSideMenuPage() -> SideMenuPage {
        _ = menuButton.waitForExistence(timeout: 5.0)
        menuButton.tap()
        return SideMenuPage()
    }
}
LoginPage.swift
import Foundation
import XCTest

final class LoginPage: PageObjectable {
    let app: XCUIApplication
    init(app: XCUIApplication = XCUIApplication()) {
        self.app = app
    }
    
    /// Accessibility Identifier
    enum A11y {
        static let pageTitle = "Login"
        static let closeButton = "login_close_button"
        static let autoLoginButton = "login_autologin_button"
    }
    
    var pageRepresentative: XCUIElement {
        return app.otherElements[A11y.pageTitle].firstMatch
    }
    
    var closeButton: XCUIElement {
        return app.buttons[A11y.closeButton].firstMatch
    }
    
    var autoLoginButton: XCUIElement {
        return app.buttons[A11y.autoLoginButton].firstMatch
    }
    
    /// ログイン画面を閉じる
    @discardableResult
    func close() -> BasePage {
        _ = closeButton.waitForExistence(timeout: 5)
        closeButton.tap()
        return BasePage()
    }
    
    /// ログインする
    @discardableResult
    func login() -> BasePage {
        _ = autoLoginButton.waitForExistence(timeout: 5)
        autoLoginButton.tap()
        return BasePage()
    }
}

LoginUITests.swift
import XCTest

class LoginUITests: XCTestCase {
    
    let app = XCUIApplication()

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // 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.
        app.launch()

        // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testLogin() throws {
        XCTContext.runActivity(named: "Login UITest") { _ in
            // トップページ > ログインボタン > ログインページ
            if let loginPage = BasePage()
                .goToLoginPage() {
                
                //  ログインボタンでログイン > ホーム画面に戻る
                let basePage = loginPage
                    .login()
                XCTAssert(basePage.exists, "Base Page is not displayed.")
            } else {
                // すでにログイン済みのケース
                XCTAssertTrue(true)
            }
        }
    }

参考 XCUITestのつらさを乗り越えて、iOSアプリにUITestを導入する
[iOS] XCUITestをPageObjectパターンで実装してみた

批判

PageObjectパターンは過度にクラス分けなどをしすぎで、逆にわかりにくいと言う批判もある。
Page Object Patternを使うな、というCypress公式記事を読んで思ったこと

最終的には自分で両方使ってみての判断となるか。

UITestで、位置情報許諾ダイアログ・push通知許諾ダイアログなどが途中で入ってくるため失敗してしまう。

UITest中にダイアログや通知などの突然のUIの割り込みが発生してしまい、本来やりたい動作ができずに終わる場合があります。

このような時のために、割り込みへ対処するメソッドaddUIInterruptionMonitor(withDescription:handler:)が用意されています。いつ何時、該当のアラートなどが表示されても、自動でボタンを押して閉じてくれます。

ただ、UIテストの進行によって必然的に引き起こされる類のアラートや、発生するタイミングが確実にわかる類のアラートでであれば、このaddUIInterruptionMonitorではなく、waitForExistenceなどを使い、その場その場でアラート等を待ち受けることが推奨されています。うまく使い分けると良いかと思います。

スクリーンショット 2021-08-23 11.17.27.png

addUIInterruptionMonitor(withDescription:handler:)の使い方に関して、まずアラートのラベルの文字によって、どのアラートか判別できるようにします。NSPredicateを使って「含む」検索をします。

.swift
extension XCUIElement {
  func labelContains(text: String) -> Bool {
    let predicate = NSPredicate(format: "label CONTAINS %@", text)
    return staticTexts.matching(predicate).firstMatch.exists
  }
}

アラートを判別できたら、どのボタンを押すかを指定します。

.swift

override func setUpWithError() throws {
   //push通知許諾ダイアログが来たら、Allowを押してすかす
   addUIInterruptionMonitor(withDescription: "Local Notifications") {
     (alert) -> Bool in
     let notifPermission = "Would Like to Send You Notifications"
     if alert.labelContains(text: notifPermission) {
       alert.buttons["Allow"].tap()
       return true
     }
     return false
   }

   //マイクアクセス許諾ダイアログが来たら、OKを押してすかす
   addUIInterruptionMonitor(withDescription: "Microphone Access") {
     (alert) -> Bool in
     let micPermission = "Would Like to Access the Microphone"
     if alert.labelContains(text: micPermission) {
       alert.buttons["OK"].tap()
       return true
     }
     return false
   }
}

参考 WWDC15 - UI Testing in Xcode
UI Testing Quick Guide
NSPredicate 全構文解説
Predicate Programming Guide - Predicate Format String Syntax
WWDC20 - Handle interruptions and alerts in UI tests
UIテストにおける割り込みやアラート処理 – WWDC2020
Handling System Alerts In UI Tests

UITestでアプリを一度削除したい

下記のようにしてアプリを消すことができます(iOS14以上)

Springboard.swift
import Foundation
import XCTest

struct Springboard {
    
    /// アプリ表示名
    private static var myAppName: String = "MyApp"
    private static let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")

    /// アプリを削除する。(iOS14以上のみ有効)
    /// https://qiita.com/bitpoetics/items/8e39dfa839b0fb404a40
    static func deleteApp(name: String = myAppName) {
        XCUIApplication().terminate()

        springboard.activate()

        XCUIDevice.shared.orientation = UIDeviceOrientation.portrait

        let appIcon = springboard.icons[name].firstMatch
        guard appIcon.waitForExistence(timeout: 5.0) else {
            return
        }
        appIcon.press(forDuration: 1.5)

        let preferredLanguageCode = NSLocale.preferredLanguages[0].prefix(2)

        // コンテキストメニュー
        let firstDeleteButtonText = preferredLanguageCode == "ja" ? "Appを削除" : "Remove App"
        let firstDeleteButton = springboard.buttons[firstDeleteButtonText].firstMatch
        _ = firstDeleteButton.waitForExistence(timeout: 5.0)
        firstDeleteButton.tap()

        // 削除確認アラート1
        let secondDeleteButtonText = preferredLanguageCode == "ja" ? "Appを削除" : "Delete App"
        let secondDeleteButton = springboard.buttons[secondDeleteButtonText].firstMatch
        _ = secondDeleteButton.waitForExistence(timeout: 5.0)
        secondDeleteButton.tap()

        // 削除確認アラート2
        let thirdDeleteButtonText = preferredLanguageCode == "ja" ? "削除" : "Delete"
        let thirdDeleteButton = springboard.buttons[thirdDeleteButtonText].firstMatch
        _ = thirdDeleteButton.waitForExistence(timeout: 5.0)
        thirdDeleteButton.tap()

        XCUIDevice.shared.press(.home)
    }
}

iOS13, iOS12以下ではアプリを削除するときに表示されるアラート・ボタンが微妙に異なるので、上記コードではうまくいきません。もしiOS13, iOS12以下でもUITestをしたい場合は以下記事を参照してください。

UITestの導入とチートシート
[iOS 14] XCUITestでアプリを削除する

UITestで複数OSヘの対応がしんどい

テスト対象をiOSXX以上などに限定してしまってもいいかもしれません。

UITestでWebViewの操作ができない部分がある

XCUITestだけでもWebViewの操作は基本的には可能です。

一例:

.swift
let textFields = app.webViews.textFields.element(boundBy: 1)
_ = textFields.waitForExistence(timeout: 5)
textFields.typeText("HogeHoge")

let buttons = app.webViews.buttons.element(boundBy: 1)
_ = buttons.waitForExistence(timeout: 5)
buttons.tap()

let staticTexts = app.webViews.staticTexts["StaticText"].waitForExistence(timeout: 5)
XCTAssertTrue(staticTexts)

iOSバージョンなどによっては動作が不安定化する場合があります。(iOS13.3、iOS12.1とXcode12.0の組み合わせ?)

その場合Appiumなどサードパーティツールを使うことが考えられます。

また、テストの時だけデバッグ専用のボタンを追加し、そのボタンを押下するとJavaScriptを実行させ、Web画面でしたい処理をするなども考えられます。

参考 iOSのWebViewでUIテストが安定しない - Bitrise / Firebase Test Labで検証してみた
UITestでWebページを操作する時、JavaScriptを使おう

UITestで@testable importを書いたのに、該当クラスへアクセスできない

UITestは本体のターゲットとは全く違うところにあるため、@testable importをしても本体のクラスなどへアクセスすることはできなそうです。本体のクラスに書いてあるものを使いたいのであれば、UITestのスコープ内で二重に定義したりするしかありません。例えばAccessibilityIdentifierに設定する文字列など、本体のターゲットとUITestのターゲットで両方必要になりますので、両方に同じものを定義する必要があります。

○○まで待つ、といった動作を記述したい

例えば、何らかの動作が済むまでUI部品のisEnabledをfalseにしているので、falseの間は待っておき、trueになってから初めてボタンを押したいなどのケースになります。

XCTWaiterを用いるといいでしょう。
こちらが参考になります

上記を参考に、どのような状況でも使えるwaitメソッドを作ると以下のようになります。

XCUIElement+Wait.swift
import Foundation
import XCTest

extension XCUIElement {
    @discardableResult
    func wait(
        until expression: @escaping (XCUIElement) -> Bool,
        timeout: TimeInterval = 10.0,
        message: @autoclosure () -> String = "",
        file: StaticString = #file,
        line: UInt = #line
    ) -> Self {
        if expression(self) {
            return self
        }

        let predicate = NSPredicate { _, _ in
            expression(self)
        }

        let expectation = XCTNSPredicateExpectation(predicate: predicate, object: nil)

        let result = XCTWaiter().wait(for: [expectation], timeout: timeout)

        if result != .completed {
            XCTFail(
                message().isEmpty ? "expectation not matched after waiting" : message(),
                file: file,
                line: line
            )
        }

        return self
    }
    
    /// ```
    /// app.buttons["identifier"].wait(until: \.exists, matches: false)
    ///
    ///app.staticTexts["identifier"].wait(until: \.label, matches: input)
    ///app.tables["identifier"].wait(until: \.cells.count, matches: 0)
    ///
    /// ```
    /// - Parameters:
    ///   - keyPath:
    ///   - match:
    ///   - timeout:
    ///   - message:
    ///   - file:
    ///   - line:
    /// - Returns:
    @discardableResult
    func wait<Value: Equatable>(
        until keyPath: KeyPath<XCUIElement, Value>,
        matches match: Value,
        timeout: TimeInterval = 10.0,
        message: @autoclosure () -> String = "",
        file: StaticString = #file,
        line: UInt = #line
    ) -> Self {
        wait(
            until: { $0[keyPath: keyPath] == match },
            timeout: timeout,
            message: message,
            file: file,
            line: line
        )
    }
    
    /// Can be utilised to explicitly wait for a dynamic range of expectations. The following examples are a handful of the more commonly used methods from our UI-test solution.
    ///
    /// ```swift
    /// app.alerts.buttons.element(boundBy: 1)
    ///.wait(until: \.exists)
    ///.wait(until: \.isHittable)
    ///.tap()
    ///
    ///app.button["identifier"].wait(until: \.isEnabled)
    ///
    ///app.button["identifier"].tap()
    ///app.button["identifier"].wait(until: \.isSelected)
    ///
    ///
    /// ```
    /// - Parameters:
    ///   - keyPath:
    ///   - timeout:
    ///   - message:
    ///   - file:
    ///   - line:
    /// - Returns:
    func wait(
        until keyPath: KeyPath<XCUIElement, Bool>,
        timeout: TimeInterval = 10.0,
        message: @autoclosure () -> String = "",
        file: StaticString = #file,
        line: UInt = #line
    ) -> Self {
        wait(
            until: keyPath,
            matches: true,
            timeout: timeout,
            message: message(),
            file: file,
            line: line
        )
    }
}

Testの実行結果を出力したい

xcodebuild testコマンドにおいて、-resultBundlePath XXXXX.xcresultオプションを指定するとテスト結果のxcresultファイルが出力されます。ただXcodeでないと開けない欠点があります。

これをHTML形式で見やすく出力してくれるツールXCTestHTMLReportもあります。

Homebrewなどでこのツールをインストールすることになります。環境を汚したくなければ、XCTestHTMLReportをダウンロードし、使ったらすぐ消すという手もあります。以下のようなシェルを使うと、結果のindex.htmlが出力され、XCTestHTMLReport自体は使用完了後に消えます。

    - xcodebuild test -workspace MyProject.xcworkspace -scheme MyProject -destination 'platform=iOS Simulator,OS=14.2,name=iPhone 11' -resultBundlePath resultBundle.xcresult
    - CURL=$(curl -L -s -w "%{http_code}" -o xchtmlreport.zip https://github.com/TitouanVanBelle/XCTestHTMLReport/releases/download/2.0.0/xchtmlreport-2.0.0.zip)
    - unzip xchtmlreport.zip
    - chmod 755 xchtmlreport
    - ./xchtmlreport -r resultBundle.xcresult
    - rm xchtmlreport.zip
    - rm xchtmlreport
    - rm -R resultBundle.xcresult

UITestでログをファイルに保存したい

https://medium.com/vmware-end-user-computing/log-grabber-how-to-get-application-logs-while-running-xcuitest-da068f5525dd
https://www.ohitori.fun/entry/how-to-save-text-file-in-swift5

UITestで動画を撮りたい

UI要素が見つからない!

UI要素を見つけるためのメソッドはここ https://developer.apple.com/documentation/xctest/xcuielementtypequeryprovider に定義されています。

UI要素のaccesibilityIdentifierが一致しているか再度確認しましょう。

それでも見つからない場合、Break PointでUIテストを止めて、
po app.debugDescription, po app.buttonsなどlldbで入力し、想定しているはずのUI部品があるかどうかをチェックしてみましょう。

参考: How to find elements on XCUITest

textfieldをtapしてもキーボードが出てこない。

以下を全て試せば解決するのではないかと思います。

テスト実行

CIなどで一部のテストクラス、一部のテストケースのみ実行したい

XcodeのEdit Scheme > Testの下部分にて、どのテストクラスのどのテストケースを有効化するかを選ぶことができます。

別の方法としてはxcodebuild testコマンドにて-only-testing MyProjectTests/MyTestClass/MyTestCaseオプションを指定すると、特定スキーマのみ、または特定テストクラスのみ、または特定テストケースのみを実行することができます。-skip-testingというオプションもあります。xcodebuild test -hで説明が見れます。

Release設定で、Testが実行できない。

そもそもDebug とReleaseでコードは基本的に同じものであるべきであって、Releaseでテストするというのはあまり想定されていることではありません。

Xcodeの設定のEnable TestablityをReleaseでもONにすることで、一応可能な様です。
https://stackoverflow.com/questions/44896961/ios-how-can-i-run-unit-ui-tests-on-release-scheme

全テストを一気に実行したい

Command + U (Xcodeのショートカット)

@testable import をしたのに、テストクラスからテスト対象クラスのprivateメソッドにアクセスできない

privateメソッドにテストクラスから直接アクセスするのは、アクセスコントロールのあり方としてあまり良いやり方とは言えません。その対象privateメソッドを呼んでいるpublic or internal のメソッドをテストすることによって、間接的にテストを行うのが一番良いと考えられます。

どうしてもprivateメソッドにアクセスしたい場合は、少し汚くなってしまうがprivate修飾詞を外す、あるいはテスト用のメソッドを用意するなどが考えられます。

SwiftUIではUIテストはできるのか?

UIKitと全く変わらずにできます。

参考: How to implement UI Tests with SwiftUI — A few examples

メンテナンス

テスト対象クラスが大きく変更してしまい、元のテストの大半が失敗してしまいメンテナンスに非常に時間がかかる。

元のクラスと根本的に異なるくらい変更したのであれば、実質新規追加のような物なので、必ずしも元のテスト全てをパスするまで元のテストをメンテする必要はなく、全部メンテする事は諦めて、テストを一回消し、新しく作りなおしてしまってもいいのではないかと考えています。

そもそも頻繁に大きく変更するような不安定な箇所はあまりUITestには向いていないかと思います。

参考
Running individual XCTest (UI, Unit) test cases for iOS apps from the command line

ライブラリ

XCUITest以外にもテストができるツールがあります。

Maestro

Android, Flutter, React Nativeにも対応。
iOS の UI テストに Maestro を試してみる

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?