🍡

Appiumのロケーターでもう悩まない!自動テストツールShiratesのセレクター機能

2022/10/19に公開

Appiumのロケーター使いこなせてますか?

オープンソースソフトウェアの自動テストツールとしてはAppiumがメジャーですが、使いこなすのはとても大変です。理由の一つに

画面上のこの要素はどうやって取得すればいいの?

というものがあり、これは永遠のテーマかもしれません。

Appiumで画面上の要素を取得するには以下のいずれかのロケータを使用することになります。

  • id指定(Androidではresource-id属性、iOSではname属性が対応)
  • accesibility id指定(Androidではcontent-desc属性、iOSではname属性が対応)
  • class指定(Androidではclass属性、iOSではtype属性が対応)
  • xpath指定(任意の属性でフィルター可能)

Appiumでは基本的にはアプリに要素取得用のidを埋め込んで、それを指定して要素を取得することが推奨されています。あなたがそのアプリの開発者なら自ら埋め込むことができますが、テスト自動化エンジニアなら開発者に依頼して埋め込んでもらう必要があります。その時点でハードルやらコストが高くなり自動化が進みにくい要因となり得るので、テスト自動化エンジニアが自ら解決できる方法を採用するのが望ましいと言えます。

idは画面上でユニークであることが前提です。それで対応ができない場合はxpathを使うことができます。xpathならアプリ開発者に依頼しなくてもテスト自動化エンジニア自ら解決できる可能性があります。ただし、一般的にはxpathは処理速度が遅いので使わないことが推奨されています。idは利用できるシーンが限定されるし, xpathは推奨しないということになると、結局どうすりゃいいの?となり、テスト自動化エンジニアにとっては要素を取得するための決定的な方法がない状況と言えます。これはツール提供者側でなんとかしてほしいですよね。

ちなみにxpathは遅いという話は常に成立するかどうかというと、そうではありません。実際に計測すると、Androidではxpathの方が速かったりします。AppiumでXPathを使用すると遅いらしいけど、どれくらい遅いの?

そもそもAppiumのよう複数のプロセスが協調しながら動作するシステムは、プロセス間で通信が発生したり、UIのアニメーション終了待ちが発生したりと、メモリ内だけで完結する処理と比較すると非常に遅いものです。次のオペレーションまで秒単位で待ち時間が発生することが普通です。メモリ内の処理でxpathが遅い部類だとしても、せいぜい100ms程度の差の話です(場合によってはxpathの方が速い)。したがって、xpathが全体としてボトルネックを発生する要因とはいえません。なので、個人的には気にせず使います。自動テストの実行時間を短縮したければ、ロケーターでチューニングするのではなく、複数プロセスを起動して並列実行した方が効果的です。

このようにAppiumのロケーターはテストを自動化するための最初の一歩である画面要素取得の手段を提供しますが、一方でテスト自動化エンジニアにとっては扱いにくいものといえます。本記事で紹介するShiratesはこの問題を独自のセレクター機能で解決を試みています。

自動テストツールShiratesのご紹介

Shirates(シラテス) はオープンソースソフトウェアで提供されているAppium互換の自動テストツールです。
https://github.com/ldi-github/shirates-core

使用言語はKotlinに特化していますが、テストコードの記述には専用の関数を使用するので、言語としてのKotlinのプログラミング経験がなくても使えます。いわゆるDSL(ドメイン特化言語)です。

セレクター式

Shiratesではid, accessibility id, class, xpathなどのロケーターの機能をセレクター式という記法で表現します。これはjQueryやCSSのセレクターに似た簡素な記法です。セレクター式は1つ以上のフィルター式を組み合わせて使用します。

フィルター式

フィルター フォーマルな書式 省略形 Androidの属性 iOSの属性
text text=text1 text1 text label
textStartsWith textStartsWith=text1 text1* text label
textContains textContains=text1 *text1* text label
textEndsWith textEndsWith=text1 *text1 text label
textMatches textMatches=^text$ n/a text label
literal literal=literal1 'literal1' text label
id id=id1 #id1 resource-id name
access access=access1 @access1 content-desc name
accessStartsWith accessStartsWith=access1 @access1* content-desc name
accessContains accessContains=access1 @*access1* content-desc name
accessEndsWith accessEndsWith=access1 @*access1 content-desc name
accessMatches accessMatches=^access1$ n/a content-desc name
value value=value1 n/a text value
valueStartsWith valueStartsWith=value1 n/a text value
valueContains valueContains=value1 n/a text value
valueEndsWith valueEndsWith=value1 n/a text value
valueMatches valueMatches=^value1$ n/a text value
class class=class1 .class1 class type
focusable focusable=true n/a focusable n/a
scrollable scrollable=true n/a scrollable n/a
visible visible=true n/a n/a visible
xpath xpath=//*[@text='text1'] n/a (arbitrary) (arbitrary)
pos pos=2 [2] n/a n/a
ignoreTypes ignoreTypes=Class1,Class2 n/a class type
image image=image1.png image1.png n/a n/a

省略形が利用可能な場合は省略形の使用を推奨しますが、省略形で表現できない場合にはフォーマルな書式を指定できます。

セレクター機能のサンプルコード

Shiratesのセレクター機能を使用したサンプルコードを以下に示します。

select関数の引数にセレクター式を指定することで要素が取得できます。取得した要素はitプロパティに格納されます。Appiumのロケーターを使用するよりも簡素な記述でテストコードが書けます。

SelectorTest2

import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import shirates.core.driver.branchextension.emulator
import shirates.core.driver.branchextension.realDevice
import shirates.core.driver.commandextension.*
import shirates.core.testcode.UITest

class SelectTest2 : UITest() {

    @Test
    @Order(10)
    fun selectByText() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                }.action {
                    it.select("ネットワークとインターネット")
                }.expectation {
                    it.textIs("ネットワークとインターネット")
                }
            }
            case(2) {
                action {
                    it.select("ネットワークと*")
                }.expectation {
                    it.textIs("ネットワークとインターネット")
                }
            }
            case(3) {
                action {
                    it.select("*とインターネット")
                }.expectation {
                    it.textIs("ネットワークとインターネット")
                }
            }
            case(4) {
                action {
                    it.select("textMatches=^ネットワークとインターネット$")
                }.expectation {
                    it.textIs("ネットワークとインターネット")
                }
            }
            case(5) {
                action {
                    it.selectWithScrollDown("デバイス情報||エミュレートされたデバイスについて")
                }.expectation {
                    realDevice {
                        it.textIs("デバイス情報")
                    }.emulator {
                        it.textIs("エミュレートされたデバイスについて")
                    }
                }
            }
        }
    }

    @Test
    @Order(20)
    fun selectById() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                }.action {
                    it.select("#search_action_bar_title")
                }.expectation {
                    it.textIs("設定を検索")
                }
            }
        }
    }

    @Test
    @Order(30)
    fun selectByAccessibility() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                        .tap("ネットワークとインターネット")
                }.action {
                    it.select("@ネットワークとインターネット")
                }.expectation {
                    it.idIs("collapsing_toolbar")
                }
            }
        }
    }

    @Test
    @Order(40)
    fun selectByClass() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                }.action {
                    it.select(".android.widget.ImageButton")
                }.expectation {
                    it.classIs("android.widget.ImageButton")
                }
            }
        }
    }

    @Test
    @Order(50)
    fun selectByXpath() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                }.action {
                    it.select("xpath=//*[@text='設定を検索']")
                }.expectation {
                    it.textIs("設定を検索")
                }
            }
        }
    }

    @Test
    @Order(60)
    fun selectByPos() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                        .tap("バッテリー")
                }.action {
                    it.select("*バッテリー*&&[1]")
                }.expectation {
                    it.textIs("バッテリー使用量")
                }
            }
            case(2) {
                action {
                    it.select("*バッテリー*&&[2]")
                }.expectation {
                    it.textIs("バッテリーセーバー")
                }
            }
            case(3) {
                action {
                    it.select("*バッテリー*&&[3]")
                }.expectation {
                    it.textIs("バッテリーを長持ちさせ、充電を最適化します")
                }
            }
        }
    }

}

サンプルコードの入手

上記のサンプルコードの完成版はこちらから入手できます。

環境構築

サンプルコードを実行する前にこちらの記事を参考にして環境構築を実施してください。
https://wave-diary.hatenablog.com/entry/2022/10/13/084710

実行手順

サンプルコードの実行やテスト結果の確認方法の概要はこちらの動画を参考にしてください。※本記事とは異なるサンプルプログラムです。
https://youtu.be/kwCL11BU2SQ

相対セレクター(方向ベース)

Shirates相対セレクターを使用すると、取得した要素の周辺の要素を相対的に取得することができます。

基準となる要素からright, below, left, aboveを指定してウィジェットを取得することができます。

方向


相対セレクター

相対セレクター 説明
:right 右方向にあるウィジェット
:rightInput 右方向にあるinput
:rightLabel 右方向にあるlabel
:rightImage 右方向にあるimage
:rightButton 右方向にあるbutton
:rightSwitch 右方向にあるswitch
:below 下方向にあるウィジェット
:belowInput 下方向にあるinput
:belowLabel 下方向にあるlabel
:belowImage 下方向にあるimage
:belowButton 下方向にあるbutton
:belowSwitch 下方向にあるswitch
:left 左方向にあるウィジェット
:leftInput 左方向にあるinput
:leftLabel 左方向にあるlabel
:leftImage 左方向にあるimage
:leftButton 左方向にあるbutton
:leftSwitch 左方向にあるswitch
:above 上方向にあるwidget
:aboveInput 上方向にあるinput
:aboveLabel 上方向にあるlabel
:aboveImage 上方向にあるimage
:aboveButton 上方向にあるbutton
:aboveSwitch 上方向にあるswitch

方向に加えて ウィジェットタイプ(input, label, image, button, switch)を指定することができます。

ウィジェット

組み込みのウィジェットは以下のように定義されています。

ウィジェット 対応する型 (Android) 対応する型 (iOS)
label android.widget.TextView XCUIElementTypeStaticText
input android.widget.EditText XCUIElementTypeTextField
XCUIElementTypeSecureTextField
image android.widget.ImageView XCUIElementTypeImage
button android.widget.Button
android.widget.ImageButton
android.widget.CheckBox
XCUIElementTypeButton
switch android.widget.Switch XCUIElementTypeSwitch
widget (上記の全て) (上記の全て)

セレクターコマンドの例

説明
<text1>:right text属性が"text1"である最初の要素を選択し、そこから右方向にある最初のウィジェットを選択します。
<text1>:right(2) text属性が"text1"である最初の要素を選択し、そこから右方向にある2番目のウィジェットを選択します。
<text1>:rightSwitch text属性が"text1"である最初の要素を選択し、そこから右方向にある最初のswitchを選択します。
<text1>:right(text2) text属性が"text1"である最初の要素を選択し、そこから右方向にあるtext属性が"text2"である最初のウィジェットを選択します。
<text1>:right:belowButton text属性が"text1"である最初の要素を選択し、そこから右方向にある最初のウィジェットを選択し、さらにそこから下方向にある最初のbuttonを選択します。



:right セレクターの使用例 (Android)

TextView1を基準とした場合


:right セレクターの使用例 (iOS)

StaticText1を基準とした場合

検索の範囲

:right セレクターは基準となる要素のtopとbottomの間を右方向に検索します。

相対セレクター機能のサンプルコード

Shiratesの相対セレクター機能を使用したサンプルコードを以下に示します。

SelectWithDirectionTest

import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import shirates.core.driver.commandextension.*
import shirates.core.testcode.UITest

class SelectWithDirectionTest : UITest() {

    @Test
    @Order(10)
    fun selectWithDirection() {

        scenario {
            case(1) {
                condition {
                    it.restartApp()
                }.action {
                    it.select("<バッテリー>:below")
                }.expectation {
                    it.textIs("100%")
                }
            }
            case(2) {
                action {
                    it.select("<バッテリー>:leftImage")
                }.expectation {
                    it.classIs("android.widget.ImageView")
                }
            }
            case(3) {
                action {
                    it.select("<バッテリー>:above(2)")
                }.expectation {
                    it.textIs("通知")
                }
            }
            case(4) {
                condition {
                    it.tap("設定を検索")
                }.action {
                    it.select("<@戻る>:right")
                }.expectation {
                    it.classIs("android.widget.EditText")
                }
            }
        }
    }

}

テスト結果

HTML-Report

Spec-Report


追加情報

こちらをご参照ください。 Relative selector(Direction based)


まとめ

Appiumのロケーターは画面上の要素を取得するため手段ですが、テスト自動化エンジニアにとっては扱いにくいものとなっています。Shiratesはこの問題を独自のセレクター機能で解決を試みています。セレクター機能は簡素な記述ながら強力な検索手段を提供します。Shiratesのセレクター機能があなたをロケーターの悩みから解放してくれると確信しています。

Shiratesはオープンソースソフトウェアです。Appium互換なので、Appiumの機能を併用することも可能です。モバイルアプリのテストの自動化に興味がある方はGitHubから入手して利用してみてください。

Discussion