Appiumを用いたモバイルアプリテスト自動化

2年前、モバイルアプリのE2E（End to End）回帰テストを自動化してみようとして、Appiumで自動化の基盤を構築していました。ところが、担当のアプリに結構大きめの改修が行われることになり、先ずはシステムテストに集中し、テスト自動化は保留していました。

### 1. なぜ自動化だったなのか
その後、システムテストも終わり、色々と改修を行なって、今はだいぶ安定している状況になり、定期リリースにおいて回帰テストが重要なフェーズに入っています。回帰テストは探索的テストも含め１日程度かかるテストケースで実行していますが、たまには数日かかる単純確認（文言とイメージの表示確認）をやらないといけない場合もあります。似たり寄ったりしている画面を何時間もみていると、集中力が切れ、確認ミスが発生したり、探索テストを行う気力を失ったり、安心してリリースできない状況に陥ってしまう恐れもあります。ここで思い出したのが2年前のテスト自動化です。狙いは以下のとおりです。

- ヒューマンエラー防止
- デグレの徹底的防止
- 確認時間短縮
- 機械的なチェックは自動化にお任せ
- 人間は頭を使った探索的テストに集中

### 2. なぜAppiumを選んだのか
世の中にはたくさんのモバイルアプリ用のテスト自動化ツールとサービスがあります。その中でAppiumを選んだ理由は、CureAppのモバイルアプリはReact Nativeで実装されているからです。React NativeはJavaScriptを開発言語としていて、一つの言語でiOSとAndroidの両方のアプリを同時に開発することが可能で、開発環境もXcodeやAndroid Studioを必要とせず（ビルドする際には必要ですが）、別の開発環境になります。よって、E2E回帰テストの環境構築の際にも、この特徴を考慮し、iOSとAndroidの両方のテスト実行ができるAppiumで自動化環境を統一することができ、テストスクリプトもワンソース（OneSource）で両OSに対応できるようになります。参考に、iOS用アプリの開発環境がXcodeの場合は、XCUITestというテストフレームワークを使いSwiftで実装、Android用アプリの開発環境がAndroid Studioの場合は、UI Automatorというテストフレームワークを使いKotlinで実装する必要があります。

- React Nativeの特緒を考慮
- Appiumの特徴を活かす
- テストスクリプト作成とメンテナンスのコスト低減

### 3. 結局、どんな環境になったのか
E2E回帰テスト自動化の主な環境は次の通りに構築されています。

ツール・言語|用途
--|--
Appium Server|テスト自動化フレームワーク
Appium Inspector|アプリ画面上の要素を調べる＆各パーツのlocation(XPath)を知る
Jest| JavaScriptテストフレームワーク
JavaScript|テストスクリプト作成
opencv4nodejs|アプリ画面上の要素検知と画面比較（＊１）

＊１：
opencv以外に、AppiumのTest.ai Classifier Plugin（[AIによる要素セレクタ](https://github.com/testdotai/appium-classifier-plugin)）プラグインを使えば、次のように指定することで、AIが画面を認識して、要素を認知＆取得＆操作することもできますが、検討した結果（XPathといったソースが変更されたり、要素の模様や位置がちょっと変わったとしても、テストスクリプトを更新しなくてよいのがメリットではあるが、特定要素が認識できるようAIモデルを学習したり、要素検知に10秒以上かかったりするパフォーマンスの問題などがあり）、opencvで対応することにしました。

```javascript
driver.findElement('-custom', 'ai:cart');
```

### 4. 環境構築で終わりではない
ここからはテストスクリプト実装における工夫等をいくつか抜粋して、下の順で紹介します。

１） スクリプトを書きやすくする（＝共通モジュール作成）
２） iOSとAndroidによる差分を吸収する
３） XPathの依存性を無くす
___
**1） スクリプトを書くやすくする（＝共通モジュール作成）**
画面上で指定のテキストが表示されることを確認するには、画面上のXPathが取得を待ち、そのXPathの中に指定のテキストが含まれているかを確認する必要があります。その場合、次のような長い行数になってしまいます。このままでは、コピペするにも限界があり、書こうとすると手が震えます。

```javascript
  /* 文言一つの表示確認のために、ここから */
  let counter = 1;
  const MAX_TRY = 10;
  const target = "設定";

  while (true) {

    let targetElements = await driver.elements("text", target);

    if (targetElements.length > 0) {
      if ((await targetElements[0].text()) === target){
        console.log("${target} displayed");
        break;
      }
    } else {
      if (counter > MAX_TRY) break;
      counter += 1;
      sleep(1000);
    }
  }
  /* ここまでを、毎回コピペしないといけない */
```

ということで、テキストの表示を確認するためのモジュール（関数）を以下のように作成しました。
```javascript
export async function isTextDisplayed(target: string) {

  let counter = 1;
  const MAX_TRY = 10;

  while (true) {

    let targetElements = await driver.elements("text", target);

    if (targetElements.length > 0) {
      if ((await targetElements[0].text()) === target) {
        return new Promise((resolve) => {
          console.log("displayed >> " + target);
          resolve("succes!!");
        });
      }
    } else {
      if (counter > MAX_TRY) {
        break;
      }
      counter += 1;
    }
  }

  return new Promise((_resolve, reject) => {
    console.log("not found");
    reject("failed");
  });

}
```
このようにする
表示(isTextDisplayed)、
タップ(tap)、
スクロール(scroll)、
入力(input)
などの共通モジュールを用意しておくことで、テストスクリプトでは、関連関数をコールするだけで確認および操作が行えるようにしました。
```javascript
.
.
await isTextDisplayed("設定");
await scrollDownUntilDisplayed("年度")
await input("年度","1987")
await tap("保存");
.
.
```

**2) iOSとAndroidによる差分を吸収する**
Appiumではワンソース（OneSource）で両OSに対応しテスト実行することができると言いましたが、実際には、ワンソース（OneSource）の中でiOSとAndroidによって分岐できる工夫が必要です。OSによって取得したXPathの構成が異なるからです。例えば、テキストの表示を確認するために、該当要素を取得する方法は、以下のようになります。

```javascript
/* iOSの場合は、テキスト要素はnameで指定しないといけない*/
await driver.elements("name", target);
/* Androidの場合は、textで指定しないといけない */
await driver.elementsByXPath("//*[@text='" + target + "']");
```
本問題は、テスト実行デバイスのOS種別が確認できれば解決できます。そのため、テスト実行時に設定する[Desired Capabilities](https://appium.io/docs/en/writing-running-appium/caps/)を読み込み、分岐判定が可能にしました。上述のisTextDisplayed()を例に話しますと以下の通りです。
```javascript
export async function isTextDisplayed(target: string) {

  let counter = 1;
  const MAX_TRY = 10;
  
  const osType = getTestConfig().platformName;
    /*
    Desired Capabilities: iOSの例
    {
        "platformName": "iOS",
        "platformVersion": "15.0",
        "deviceName": "iPhone X",
        "automationName": "XCUITest",
        "app": "/path/to/my.app"
    }
    Desired Capabilities: Androidの例
    {
        "platformName": "Android",
        "platformVersion": "12",
        "deviceName": "Pixel 3a",
        "automationName": "uiautomator2",
        "app": "/path/to/my.app"
    }
    */
  while (true) {

    let targetElements;

    /* OSタイプにょって要素の探し方が分岐する */
    if (osType === "iOS") {
      targetElements = await driver.elements("name", target);
    } else {
      targetElements = await driver.elementsByXPath(
        "//*[@text='" + target + "']"
      );
    }

    if (targetElements.length > 0) {
      if ((await targetElements[0].text()) === target) {
        return new Promise((resolve) => {
          console.log("displayed >> " + target);
          resolve("succes!!");
        });
      }
    } else {
      if (counter > MAX_TRY) {
        break;
      }
      counter += 1;
    }
  }

  return new Promise((_resolve, reject) => {
    console.log("not found");
    reject("failed");
  });

}
```
**３） XPathの依存性を無くす**
E2Eテスト自動化を前提とした開発では、要素の特定が簡単にできるよう下の図１のように、各attribute（例：accessibility id, text, content-desc, resource-idなど）に識別可能な要素名を付与していますが、そうではない場合も多く、テスト自動化において要素の特定に苦労することが結構あります。場合によっては、他のattributeでは特定できず、XPathでしか特定するしかありません。しかし、XPathは図2の下部に書いてある通りに（`"Using XPath locators is not recommended and can lead to fragile tests. Ask your development team to provide unique accessibility locators instead!"`）、絶対的なパスではなく、いつ変わるのかわからないため、XPathに頼るとテスト自動化が不安定になります。

図１：Appium Inspector画面
![スクリーンショット 2022-12-12 15.39.03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032025/d7a81495-0e56-21e7-5871-70c97ab31813.png)

図2：複雑なXPathの例
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032025/44a1b768-c9cc-1eb6-b95d-77c191f4faa9.png width=300>

このような場合は、開発チームに特定できる要素名を付与するようお願いすることがベストではありますが、他にも解決方法がありますので紹介します。ここ（[Image Comparison Features](https://appium.io/docs/en/writing-running-appium/image-comparison/)）のAppiumサイトにも紹介していますが、opencv4nodejsのtemplate matching algorithmを活用すると、画面上の特定要素の検知と座標を取得することができます。次のソースは、上のサンプルソースを応用し、画面上で特定の要素をイメージで検知し、その座標を取得する関数です。

```javascript
async function detectObject(screenImagePath, targetImagePath) {

  // OPENCV_PATH:ローカルのopencv4nodejsライブラリのパス
  const cv = require(path.join(OPENCV_PATH));

  const originalMatPromise = await cv.imreadAsync(screenImagePath);
  const targetPromise = await cv.imreadAsync(targetImagePath);
  const [originalMat, targetMat] = await Promise.all([
    originalMatPromise,
    targetPromise,
  ]);

  const matched = await originalMat.matchTemplate(targetMat, 5);
  cv.minMaxLoc(matched);

  for (let i = 0; i < 1; i++){
    const minMax = matched.minMaxLoc();
  
    const {
      maxLoc: { x, y },
    } = minMax;
  
    const xCordination = (x + targetMat.cols / 2);
    const yCordination = (y + targetMat.rows / 2);

    console.log("スクリン上で検知したマップアイコンの一致度合い: " + minMax.maxVal*100+"%");
    console.log("一致したエリアの真ん中のX座標  　　　　　　　　　　　　　　　　  : " + xCordination);
    console.log("一致したエリアの真ん中のY座標  　　　　　　　　　　　　　　　　  : " + yCordination);
  
    originalMat.drawRectangle(
      new cv.Rect(x, y, targetMat.cols, targetMat.rows),
      new cv.Vec(0, 255, 0),
      2,
      cv.LINE_8
    );

    //デバッグ要
    //await cv.imshow("found target!", originalMat);
    //await cv.waitKey();
    //await cv.imwrite(screenImagePath, originalMat);

    return {x: xCordination, y: yCordination};
  }

  console.log("not found");
  return null;
}
```
上記のソースで実行した結果は以下の通りです。
```bash
スクリン上で検知したマップアイコンの一致度合い: 99.99471306800842%
一致したエリアの真ん中のX座標  　 　　　　　　　　　　　　　　 : 1089
一致したエリアの真ん中のY座標  　 　　　　　　　　　　　　　　 : 1676
```

targetImage|
--|
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032025/419ce6de-71cc-893e-48c4-00c41295bb6a.png width=50>|

上がtargetImageで、下がscreenImageと検知結果画面です。
screenImage画面|検知エリア表示画面
--|--
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032025/685cbade-f3f5-fd3d-0c75-c305a073dadf.png width=250>|<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3032025/081e34de-5884-424f-bf59-50bbde3e609f.png width=250>

detectObject()関数で座標が取得できたら、TouchActionを用いて指定の箇所をタップします。
```javascript
  try {
      const touchAction = await new wd.TouchAction(driver)
          .press({ x: xCordination, y: yCordination })
          .wait(100)
          .release();
        await touchAction.perform();
  } catch (err) {
       return new Promise((_resolve, reject) => {
         reject("failed to touch by Coordination!!" + err);
    });
  }
  return new Promise((resolve) => {
    resolve("succes touch by Coordination!!");
  });
```

### 今後の宿題
今日のE2E回帰テスト自動化の話は、ここまでにしておきます。テスト自動化の構築は意外と簡単にできると思いますが、この後自動化にはたくさんの試練が待っています。ここからは、まだ解決できていないいろいろな宿題について話していきたいと思います。今後の宿題、、、
- UIレイアウト（特にイメージ）の変化に気づくには？
- テスト中にAppiumがクラッシュした！どうすればよい？
- XCodeを14にアップデートしてからiOS実機でのテストが実行できない！
- 時間がない！複数の端末を並行して実行するには？
- などなど