モバイルアプリの自動テストツールShiratesを使ってみよう（その２）

本記事はOSSの自動テストツール **Shirates（シラテス）** の紹介記事です。
[前回](https://qiita.com/wave1008/items/13e0c854002f6a58f354)の記事ではOSSの自動テストツールである**Shirates**を使うための環境構築とテストコードの実行方法について説明しました。

今回はAppiumと**Shirates**でどのような違いがあるのかをサンプルコードを使用して確認します。
**Shirates**はAppiumをドライバーとして使用していますが、Appiumの使いにくさを解消するための工夫が多数盛り込まれていることがわかります。

## サンプルコードの入手
まずはGitHubからサンプルコードを入手してください。

https://github.com/wave1008/shirates-samples-introduce1

ダウンロードしたプロジェクトのディレクトリ下に複数のサブプロジェクトがあります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/866744fa-1d06-b538-1dcc-0c07c6bde3a2.png)
以下のプロジェクトを使用して説明します。
- SampleByAppium_ja
- SampleByShirates_ja

## サンプルプログラムの概要

Androidの設定アプリの「ネットワークとインターネット画面」において
「機内モードスイッチがONの場合にタップするとOFFになること」
を検証します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/2006db4e-a37c-71ae-5596-46f9e40069c9.png)
1. 設定画面で「ネットワークとインターネット」をタップする
1. ネットワークとインターネット画面で機内モードスイッチがOFFならばタップしてONにする
1. 機内モードスイッチをタップする
1. 機内モードスイッチがOFFであることを検証する


## サンプルコードの実行

実行するには環境構築が必要ですので[前回](https://qiita.com/wave1008/items/13e0c854002f6a58f354)の記事を参考にして環境を構築してください。

### SampleByAppium_ja

1. Androidのエミュレーターを起動します。※筆者は`Android 12`で動作確認しています。
1. ターミナル（またはコマンドプロンプト）を開いて`appium`コマンドを実行し、Appiumサーバーを起動します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/9131ddbb-2ff7-c20b-e58c-ab1fac03a1e8.png)
1. IntelliJ IDEAで`kotlin/AirplaneModeTestByAppium.kt`を開きます。
1. `airplaneModeSwitch`関数を右クリックして`Debug`を選択します。

テストが実行されます。正常に実行されるとグリーンのチェックマークになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/bd810293-9a2c-9c92-a7ff-0d4f99675f4d.png)

----

### SampleByShirates_ja

1. Androidのエミュレーターを起動します。※筆者は`Android 12`で動作確認しています。
1. IntelliJ IDEAで`kotlin/AirplaneModeTestByShirates.kt`を開きます。
1. `airplaneModeSwitch`関数を右クリックして`Debug`を選択します。
1. テストが実行されます。正常に実行されるとグリーンのチェックマークになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/a1c3b573-ccd2-a0e2-5502-56bf4785726c.png)

#### テスト結果の出力
サンプルコードではAppiumはログ出力やスクリーンショットに関する処理を実装していないので、テスト結果はJUnitランナーのテスト結果以外は何も出力されません。

**Shirates**はテストコードを書くと全自動で以下のものを出力します。
- コンソールへのログ出力
- ログファイル
- スクリーンショットファイル
- HTMLレポートファイル（テスト手順、テスト結果、スクリーンショット）
- スプレッドシートファイル（テスト手順、テスト結果）

以下の手順で内容を確認してみましょう。
1. コンソール出力の「ログは次の場所に出力します。」のハイパーリンクをクリックします。ログが出力されたディレクトリが開きます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/d5cc311e-7503-f60e-5b9a-fbcfa1b29a4f.png)
1. `_Report(simple).html`を開きます。
テストの実行ログとスクリーンショットを確認できます。
ログの行や画像をダブルクリックすると拡大された画像を確認できます。カーソルキーで行を移動できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/8ce879c1-03f6-22ee-31c2-d45c5770e60c.png)
1. `AirplaneModeTestByShirates@a.xlsx`をExcel等のアプリケーションで開きます。
テスト手順とテスト結果をスプレッドシートで確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/52e7c6da-ebc7-12e1-bf22-ab08911c99a5.png)

このように、**Shirates**でテストコードを実行するとさまざまな出力を得ることができます。


## テストコードの比較
Appiumと**Shirates**のテストコードを比較します。
まずは両者のテストコードの全量を掲載するので、ざっと確認してみてください。

### Appiumで実装した場合のテストコード

**SampleByAppium_ja**のプロジェクトにある`build.gradle.kts`を右クリックしてIntelliJ IDEAで開きます。
下記のファイルを開きます。

##### kotlin/AirplaneModeTestByAppium.kt

```kotlin
import io.appium.java_client.android.AndroidDriver
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import org.openqa.selenium.By
import org.openqa.selenium.WebElement
import org.openqa.selenium.remote.DesiredCapabilities
import org.openqa.selenium.remote.RemoteWebDriver
import java.net.URL

class AirplaneModeTestByAppium {

    private fun getDriver(): RemoteWebDriver {

        val caps = DesiredCapabilities()
        with(caps) {
            setCapability("appium:automationName", "UiAutomator2")
            setCapability("platformName", "Android")
            setCapability("appium:platformVersion", "12")
            setCapability("appium:language", "ja")
            setCapability("appium:locale", "JP")
            setCapability("appium:appPackage", "com.android.settings")
            setCapability("appium:appActivity", "com.android.settings.Settings")
        }
        val driver = AndroidDriver(URL("http://127.0.0.1:4723/"), caps)
        driver.setSetting("enforceXPath1", true)

        return driver
    }

    @Test
    @Order(10)
    @DisplayName("機内モードスイッチがOFFの場合にタップするとONになること")
    fun airplaneModeSwitch() {

        val driver = getDriver()
        var e: WebElement

        // 「ネットワークとインターネット」をタップする
        e = driver.findElement(By.xpath("//*[@text='ネットワークとインターネット']"))
        e.click()
        Thread.sleep(1000)

        // 機内モードスイッチの状態を取得する。OFFの場合はタップしてONにする
        e = driver.findElement(By.xpath("//*[@text='機内モード']/following::*[@resource-id='android:id/switch_widget']"))
        if (e.getDomAttribute("checked") == "false") {
            e.click()
            Thread.sleep(1000)
            e =
                driver.findElement(By.xpath("//*[@text='機内モード']/following::*[@resource-id='android:id/switch_widget']"))
            assertThat(e.getDomAttribute("checked")).isEqualTo("true")
        }

        // 機内モードスイッチをタップする
        e.click()
        Thread.sleep(1000)

        // 機内モードスイッチがOFFであることを検証する
        e = driver.findElement(By.xpath("//*[@text='機内モード']/following::*[@resource-id='android:id/switch_widget']"))
        val checkState = e.getDomAttribute("checked")
        assertThat(checkState).isEqualTo("false")
    }

}
```

### Shiratesで実装した場合のテストコード

**SampleByShirates_ja**のプロジェクトにある`build.gradle.kts`を右クリックしてIntelliJ IDEAで開きます。
下記のファイルを開きます。

##### kotlin/AirplaneModeTestByShirates.kt

```kotlin
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.Test
import shirates.core.driver.branchextension.ifCheckOFF
import shirates.core.driver.commandextension.checkIsOFF
import shirates.core.driver.commandextension.checkIsON
import shirates.core.driver.commandextension.select
import shirates.core.driver.commandextension.tap
import shirates.core.testcode.UITest

class AirplaneModeTestByShirates : UITest() {

    @Test
    @Order(10)
    @DisplayName("機内モードスイッチがONの場合にタップするとOFFになること")
    fun airplaneModeSwitch() {

        scenario {
            case(1) {
                condition {
                    // 「ネットワークとインターネット」をタップする
                    it.tap("ネットワークとインターネット")
                    // 機内モードスイッチの状態を取得する。OFFの場合はタップしてONにする
                    it.select("<機内モード>:rightSwitch")
                        .ifCheckOFF {
                            it.tap()
                        }
                    it.checkIsON()
                }.action {
                    // 機内モードスイッチをタップする
                    it.tap("<機内モード>:rightSwitch")
                }.expectation {
                    // 機内モードスイッチがOFFであることを検証する
                    it.checkIsOFF()
                }
            }
        }
    }

}
```

<br>

**Shirates**の場合はCapabilitiesの情報をJSONファイルに分離しています。

##### testConfig/settingsConfig.json
```json
{
  "testConfigName": "settingsConfig",

  "appIconName": "設定",

  "packageOrBundleId": "com.android.settings",
  "startupPackageOrBundleId": "com.android.settings",
  "startupActivity": "com.android.settings.Settings",

  "capabilities": {
    "automationName": "UiAutomator2",
    "platformName": "Android",
    "language": "ja",
    "locale": "JP"
  },

  "profiles": [
    {
      "profileName": "Android 12",
      "capabilities": {
        "platformVersion": "12"
      }
    }
  ]
}
```

使用するJSONファイルとプロファイルをtestrun.propertiesファイルで指定します。

##### testConfig/testrun.properties
```properties
## Config --------------------
## [Android]
android.configFile=testConfig/settingsConfig.json
android.profile=Android 12

## Log --------------------
logLanguage=ja

## Appium --------------------
appiumServerUrl=http://127.0.0.1:4723/
appiumPath=appium
appiumArgs=--session-override --relaxed-security
```

#### 構成管理に関する比較

Appiumの場合、JSONファイルやpropertiesファイルで構成管理する仕組みはない（あっても限定的）ので、必要ならば自力で実装する必要があります。

**Shirates**の場合は独自の構成管理のフレームワークを提供しており、テスト実行環境に依存するほとんどの設定はJSONファイルやpropertiesファイルに記述することができるので、テスト実行環境が変わる際でも多くの場合はテストコードの修正が不要です。

詳細はドキュメントの[Parameter configuration files](https://github.com/ldi-github/shirates-core/blob/main/doc/markdown/basic/parameter/parameter_configuration_files.md)を参照ください。


#### Driverインスタンス生成に関する比較

AppiumのサンプルコードではgetDriver()関数の中でAndroidDriverのインスタンスを組み立てる処理を記述しています。


##### kotlin/AirplaneModeTestByAppium.kt
```kotlin
    private fun getDriver(): RemoteWebDriver {

        val caps = DesiredCapabilities()
        with(caps) {
            setCapability("appium:automationName", "UiAutomator2")
            setCapability("platformName", "Android")
            setCapability("appium:platformVersion", "12")
            setCapability("appium:language", "ja")
            setCapability("appium:locale", "JP")
            setCapability("appium:appPackage", "com.android.settings")
            setCapability("appium:appActivity", "com.android.settings.Settings")
        }
        val driver = AndroidDriver(URL("http://127.0.0.1:4723/"), caps)
        driver.setSetting("enforceXPath1", true)

        return driver
    }
```

**Shirates**のサンプルコードではDriverのインスタンスを組み立てる処理はありません。JSONの設定ファイルに正しくパラメーターを設定すれば、テスト関数が呼び出された時点で利用できます。


##### testConfig/settingsConfig.json
```json
{
  "testConfigName": "settingsConfig",

  "appIconName": "設定",

  "packageOrBundleId": "com.android.settings",
  "startupPackageOrBundleId": "com.android.settings",
  "startupActivity": "com.android.settings.Settings",

  "capabilities": {
    "automationName": "UiAutomator2",
    "platformName": "Android",
    "language": "ja",
    "locale": "JP"
  },

  "profiles": [
    {
      "profileName": "Android 12",
      "capabilities": {
        "platformVersion": "12"
      }
    }
  ]
}
```

ドライバーを起動するときのパラメーターのセットはプロファイルを定義することで使い分けすることができます。

以下のようにプロファイルは複数定義することができます。
- `"Android 13"`というプロファイルは`platformVersion`を`13`に設定します。
- `"Android 13(en-US)"`というプロファイルはさらに`language`を`en`、`locale`を`US`に設定します。

```json
  "profiles": [
    {
      "profileName": "Android 12",
      "capabilities": {
        "platformVersion": "12"
      }
    },
    {
      "profileName": "Android 13",
      "capabilities": {
        "platformVersion": "13"
      }
    },
    {
      "profileName": "Android 13(en-US)",
      "capabilities": {
        "platformVersion": "13",
        "language": "en",
        "locale": "US"
      }
    }
  ]
```


#### 画面要素の取得に関する比較（設定画面）

設定画面の「ネットワークとインターネット」をタップする例で説明します。
この要素で利用可能な属性を確認するために**Appium Inspector**を起動し、設定画面をキャプチャします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/a3f84f69-d61d-602b-ade9-0403712e46d5.png)
「ネットワークとインターネット」の要素には**text属性**に"ネットワークとインターネット"が設定されていることがわかります。

Appiumのサンプルコードでは、XPathでtext属性を指定して要素を取得し、click関数を呼び出してタップを行います。
```kotlin
        // 「ネットワークとインターネット」をタップする
        e = driver.findElement(By.xpath("//*[@text='ネットワークとインターネット']"))
        e.click()
        Thread.sleep(1000)
```

**Shirates**のサンプルコードでは、**tap関数**を使用して要素を取得し、タップを実行しています。
```kotlin
        // 「ネットワークとインターネット」をタップする
        it.tap("ネットワークとインターネット")
```

AppiumではXPathに関する知識が必要ですが、**Shirates**では必要ありません。
また、text属性を使用する場合、**Shirates**では**tap関数**を利用することで極めてシンプルかつ直感的に記述することができます。

----

#### ボタンタップ後の同期に関する比較

Appiumのサンプルコードではタップ（click関数呼び出し）後に1000ミリ秒間の待ち合わせを行なった後、機内モードスイッチの要素を取得しています。
```kotlin
        // 「ネットワークとインターネット」をタップする
        e = driver.findElement(By.xpath("//*[@text='ネットワークとインターネット']"))
        e.click()
        Thread.sleep(1000)

        // 機内モードスイッチの状態を取得する。OFFの場合はタップしてONにする
        e = driver.findElement(By.xpath("//*[@text='機内モード']/following::*[@resource-id='android:id/switch_widget']"))
```

1000ミリ秒以内に画面遷移が完了すれば問題ありませんが、1000ミリ秒を超えてしまった場合は要素を取得できずにテストが失敗します。テスト対象アプリを実行する端末スペックが高い場合はもっと短い時間を設定したほうがテスト実行時間が短くなりますが、端末スペックが低い場合は1000ミリ秒で完了する保証はありません。従って、単純に固定値で一定時間待ち合わせする方法では、テストはその時々の状況で成功したり、失敗したり、無駄な待ち時間を消費したりするようになります。

**Shirates**のサンプルコードではtap関数を呼び出した後にThread.sleepによる待ち合わせを記述していません。
```kotlin
        // 「ネットワークとインターネット」をタップする
        it.tap("ネットワークとインターネット")
        // 機内モードスイッチの状態を取得する。OFFの場合はタップしてONにする
        it.select("<機内モード>:rightSwitch")
```
_**tap関数は画面遷移が完了するまで（画面上の要素の座標やサイズに変更が発生しなくなるまで）自動で待機します。**_  また、その後の _**select関数では、要素が取得されるまでリトライします。**_ この仕組みによって**Shirates**ではほとんどの場合において明示的にsleepを記述する必要はありません。（必要な場合はselect関数のwaitSeconds引数でタイムアウト時間を指定することができます。）


#### 画面要素の取得に関する比較（設定画面）
ネットワークとインターネット画面の「機内モードスイッチ」をタップする例を使用して比較します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/fde61a71-9373-0e62-b890-33b89ffd69cd.png)


AppiumのサンプルコードではXPathを使用して、「機内モード」のテキストの後に存在する要素の中でresource-id属性が **"android:id/switch_widget"** である最初の要素を取得します。

```kotlin
        // 機内モードスイッチがOFFであることを検証する
        e = driver.findElement(By.xpath("//*[@text='機内モード']/following::*[@resource-id='android:id/switch_widget']"))
```

この画面では **"android:id/switch_widget"** というresource-idがユニークなので**By.id**で直接取得したほうが簡潔ですが、同じresource-idが画面上に複数存在する場合は、上記のテクニックを使用してユニークな他の要素からの相対関係で要素を取得します。

**Shirates**のサンプルコードでは**セレクター式**という記述方法によって、より簡潔に記述しています。
```kotlin
        // 機内モードスイッチをタップする
        it.tap("<機内モード>:rightSwitch")
```

`<機内モード>`はtext属性が"機内モード"である最初の要素を表します。`:rightSwitch`はその要素の右側に存在する最初のスイッチ要素を表します。tap関数はセレクター式 `<機内モード>:rightSwitch` を解釈して画面上の要素を取得し、タップします。


セレクター式についてより詳細な情報はこちらの記事を参照ください。

https://zenn.dev/wave1008/articles/501da678760cf0




### 検証に関する比較

Appiumのサンプルコードでは**AssertJ** の **assertThat関数** を使用しています。

```kotlin
        // 機内モードスイッチがOFFであることを検証する
        e = driver.findElement(By.xpath("//*[@text='機内モード']/following::*[@resource-id='android:id/switch_widget']"))
        val checkState = e.getDomAttribute("checked")
        assertThat(checkState).isEqualTo("false")
```

検証結果がNGの場合は例外が表示されますが、OKの場合は特に何も出力されません。


**Shirates**のサンプルコードでは **checkIsOFF()** という専用関数を使用しています。

```kotlin
                }.action {
                    // 機内モードスイッチをタップする
                    it.tap("<機内モード>:rightSwitch")
                }.expectation {
                    // 機内モードスイッチがOFFであることを検証する
                    it.checkIsOFF()
                }
```

検証結果はOKの場合、NGの場合ともにログに出力されます。

**コンソール**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/eaa1b4b7-3df6-a0af-59a8-8a44532e54c8.png)

**HTMLレポート**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/3a4870f4-1353-8d70-3ef7-06d1151981a6.png)



#### テストの構造に関する比較

Appiumのサンプルコードではフラットにコードを記述しています。可読性を確保するためにはコメントを工夫する必要があります。
テストケースの基本形は
- 事前条件
- アクション
- 事後条件
ですが、これに沿った記述をサポートするような機能はありません。

**Shirates**のサンプルコードでは、テスト関数には必ず1つの**scenario関数**を記述します。また、その下には1つ以上の**case関数**を記述します。

caseの記述には独自の **CAEパターン（Condition-Action-Exprectation Pattern）** を使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/258506/2a33c38f-5d58-27c5-f28a-91005c201cd2.png)

**Shirates**では上記のようにテストを記述するための専用関数の使用を強制することで、構造化され、インデントされ、可読性の高いテストコードを記述することができます。

## まとめ
- **Shirates**はAppium互換でありながら、Appiumの使いにくさを徹底的に解消し、自動テストコード作成の生産性を高めたテストフレームワークです。
- OSSなので誰でも入手して無償で利用できます。


https://github.com/ldi-github/shirates-core