アプリテスト自動化への旅【AppiumとJestで簡単に試してみよう！】

皆さんはアプリのテスト自動化をしたことがありますか？
おそらく手元で**手動テスト**をし、Excelか何かにまとめられたテスト仕様書にチェックを入れている人もいるのではないでしょうか。

しかし、自動化しようとしても、**どこまで自動化ができるのだろうか。。。**だとか、**こんな表現をテストで実装は出来ないよね？**だとか、**時間かかりそうだし、今はそういうフェーズじゃない。**だとか...そう思っている人は結構いるのではないかと私は思っています。

そんな人たちに、意外とアプリのテスト自動化はイケるぞ！ってのを伝えるために、本記事にて、**Appium**と**Jest**を組み合わせた自動テストを紹介します。

皆さんの手元で簡単にセットアップできるので、是非お試しください！

なお、今回紹介する内容のソースコードは以下のリポジトリから参照できますので、是非ご利用ください！
https://github.com/minakawa-daiki/AppiumJestSample

# 目標
今回の記事では、以下が出来ることをゴールにしていきたいと思います。

- ページがしっかりと表示されている
- テキストを自動入力できる
- ボタンが押せる
- スワイプができる
- ブラウザアプリを開いて、テスト対象のアプリに戻ってくる
- スクリーンショットを保存してみる
- 一連のテストフローを動画として保存する

# Appiumについて
公式サイト: https://appium.io/

公式サイトの言葉を借りて、Google翻訳すると

> Appiumは、ネイティブ、 ハイブリッド、およびモバイルWebアプリで利用可能なオープンソーステスト自動化フレームワークです。
> WebDriverプロトコルを使用してiOS、Android、およびWindowsアプリで動きます。

要するにアプリのテストを自動化できます。使う前は、どこまで自動化できるのだろうか？と疑問でしたが、**かなり自動化できる**印象です。**様々な言語に対応**しているので、とても使いやすいです。

また、**GUIのデバッグツール**なども用意されているので、とても開発者に優しい、素晴らしいプロダクトだと思います。

![Appium](https://www.3pillarglobal.com/sites/default/files/560xNxappium1.png.pagespeed.ic.yO1Z5Q47Bd.webp)

> 画像は https://www.3pillarglobal.com/insights/appium-a-cross-browser-mobile-automation-tool より

Appiumは様々な言語に対応していますが、今回はJest + TypeScriptで記述していきます。
また、ライブラリは`wd`を使って書かれたサンプルが多いですが、今回は`webdriverio`でやっていきます。

# 下準備
AndroidとiOSの両方のテスト自動化を行なっていきたいため、 React Nativeでサンプルアプリを作成します。
今回使用するバージョンは以下の通りになります。

- Expo SDK: v36
- Appium: 1.16.0-beta.3

Appiumは執筆当時、iOS10系周りでバグが出ていたため、betaを使用しています。
https://github.com/appium/appium/issues/13627

## iOS Simulatorの準備
テストに使用するiOS Simulatorの準備をまずしましょう。

Xcodeを開いて、使用したいOSバージョンのシミュレーターをまずはインストールします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/86f80fe5-0a1e-41af-34cd-1e3487710566.png)

インストールした後、Simulatorの一覧に存在する端末であれば、その端末を利用してテストが実行されます。
存在しない端末だと毎回新しいSimulatorが作られてしまうので、テストが落ちる原因になるので少し注意が必要です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/471620d6-ede6-7495-a6df-694cf27a2090.png)

今回の例だと、iPhone11 Pro MaxのiOS 13.2という感じです。


## Android Emulatorの準備
AndroidのEmulatorも以下の手順に従って準備していきます。

1. Android Studioで適当なサンプルアプリを開いてAVDマネージャを開きます
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/7355308c-99e7-c1fd-2b5a-78f1f57edb7c.png)
2. Create Virtual Deviceからエミュレータを追加します
3. Nameの部分を利用するのでメモっておくと良いでしょう

ここで注意点なのですが、**Android 6系以下のEmulatorはChromeではないため（実機はChrome）、WebViewを使用しているアプリでは、そもそもテスト自動化の検証端末に使用しない方がいい**です。実機を利用しましょう。

参考: https://qiita.com/masakura/items/210261c954256a7879e6


## Appium doctorの実行と修正
`project`フォルダに移動し、 `yarn appium-doctor` を実行しましょう。そして、エラーが出た部分を直していきます。

Macユーザーの方は以下の項目でエラーが出る確率が高いと思います。

- ✖ Xcode is NOT installed!
- ✖ Carthage was NOT found!
- ✖ ANDROID_HOME is NOT set!
- ✖ JAVA_HOME is NOT set!
- ✖ adb could not be found because ANDROID_HOME is NOT set!
- ✖ android could not be found because ANDROID_HOME is NOT set!
- ✖ emulator could not be found because ANDROID_HOME is NOT set!
- ✖ Bin directory for $JAVA_HOME is not set

まず、 `Xcode is NOT installed!` は https://github.com/nodejs/node-gyp/issues/569#issuecomment-94917337 を参考に、Xcodeをインストールした後、 `udo xcode-select -s /Applications/Xcode.app/Contents/Developer` を実行しましょう。

`Carthage was NOT found!` は `brew install carthage` してください。

`ANDROID_HOME is NOT set!` や `JAVA_HOME is NOT set!` は以下のような感じで環境変数を設定しましょう。

```
export ANDROID_HOME=$HOME/Library/Android/sdk
export JAVA_HOME=`/usr/libexec/java_home`
export PATH=$JAVA_HOME/bin:$PATH
```

そして、もう一度 `yarn appium-doctor` をすると解消しているはずです。

そして今回は、テストの実行状況を録画するため、ffmpegをインストールしておきます

```
brew install ffmpeg
```

# 今回のサンプルアプリの概要
今回使用するサンプルアプリは以下のようなページを持っています。コードの内部実装は詳しく説明しませんが、ソースコードは公開しているので、気になる方はご覧ください。

- １ページ目はテキストを入力する画面があります
- 2ページ目は１ページ目で入力されたテキストが表示されます。ボタンを押すことで内容が変わります
- ３ページ目は画像が配置されたWebViewで、画像をクリックするとSafariに遷移します
- 4ページ目は空のページです
- 5ページ目は動画が配置されたWebViewで、動画をクリックするとSafariに遷移します

![hoge.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/99142fdd-57ce-567a-c237-0fdeda3f3fd6.gif)

# アプリを取得する
GitHubにてapkとappを公開してますが、自分でビルドしたい場合はexpo経由で以下のコマンドを実行してください。

## 自分でビルドする場合
- iOSの場合は `yarn build:ios`
- Androidの場合は `yarn build:android`

## 直接ダウンロードする場合
https://github.com/minakawa-daiki/AppiumJestSample/releases/tag/v1.0

また、生成されたアプリは`app`フォルダ直下に`AppiumJestSample.app`と`AppiumJestSample.apk`でそれぞれ配置してください。（`AppiumJestSample.app.zip`を解凍してご利用ください）

# テスト自動化対象端末のconfigを設定する
今回はテストコードを書く上のドライバーとして`webdriverio`を採用していますので、`jest-environment-webdriverio`というライブラリを使用しています。
https://www.npmjs.com/package/webdriverio
https://www.npmjs.com/package/jest-environment-webdriverio

また、よくAppiumのテストで使用されているドライバーでは `wd` があります。
https://www.npmjs.com/package/wd

`webdriverio`を採用した理由は、非同期処理をより綺麗に書ける点もありますが、執筆時点の`wd`では動画撮影周りにバグがあったため採用を見送りました。

`jest-environment-webdriverio`のおかげで、`webdriverio`の設定を簡単に記述できます。`jest.config.js` を実際に見てみましょう。

必要な設定は`testEnvironment`に`jest-environment-webdriverio`を指定し、`testEnvironmentOptions`に端末情報を記述するだけです。

iOSでは以下のような設定になります。

```jest.config.js
{
  port: 4723,
  capabilities: {
    platformName: "iOS",
    platformVersion: "11.3",
    deviceName: "iPhone X",
    automationName: "XCUITest",
    wdaLocalPort: 8100,
    nativeWebTap: true,
    app: "./app/AppiumJestSample.app"
  }
}
```

- `port`はAppiumが起動しているポートを指定します
- `platformVersion`はiOSのバージョンです
- `deviceName`はiOS Simulatorに存在する端末を指定します
- `automationName`は実際に使用するUIテストフレームワークを指定します
- `wdaLocalPort`は並列化するときに違うポート番号を指定します
- `app`はiOSのアプリケーションのpathを指定します

Androidはこんな感じです。

```jest.config.js
{
  port: 4723,
  capabilities: {
    platformName: "Android",
    deviceName: "Android Emulator",
    automationName: "Appium",
    avd: "Pixel_3_API_29",
    systemPort: 8200,
    nativeWebTap: true,
    app: "./app/AppiumJestSample.apk"
  }
}
```

- `deviceName`はエミュレータを使用する場合は`Android Emulator`を指定します
- `avd`はエミュレータのnameのスペースを`_`で埋めたものを記述します
- `systemPort`は並列化するときに違うポート番号を指定します

また、実機を接続してる場合は、以下のような設定で動かせます。

```jest.config.js
{
  port: 4723,
  capabilities: {
    platformName: "Android",
    systemPort: 8201,
    app: "./app/AppiumJestSample.apk"
  }
}
```


# 自動テストを動かす
初回は自動化するためのアプリをインストールするため、時間がかかります。テストが失敗することが多々あるので、その場合は再度実行しましょう。

テストを実行する前にバックグラウンドで `yarn appium` を実行し、Appiumサーバーを立ち上げておきましょう。

その上で、テストの実行は`yarn test`で動作します。デフォルトはiOSが起動します。
明示的にOSを指定したい場合はそれぞれ`yarn test:android`, `yarn test:ios`を実行してください。

テストが開始されると、シミュレータやエミュレータが起動し、テストが行われます。
全てのテストが終わったら、`tests/screenshots`にテスト途中のスクショや全体の動画が以下のような感じで保存されていると思います。


<img width="375" alt="image.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/2ca2c938-32ae-e3f0-31bc-3880a41445fd.png">


![hoge.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213538/9c848273-2120-ee39-9cef-58d42d81f9de.gif)


Appiumのスタイルは**jestなどのテストフレームワークで表示チェックを担保するのと、スクリーンショットや動画なのでアプリの全体的な動きのテストを担保する**ことで自動化していくのが良いと思っています。

無理に全てテストコードで担保しようとせず、スクリーンショットや動画も積極的に使っていきましょう。

# テストコードの説明
まず、ReactNativeとNativeで書かれたコードのテストコードは結構違う感じになることを念頭に置いておいてください。
また、今回はほんの一部しか機能を紹介しないので興味を持った方はドキュメントを見てみると良いでしょう。

ドキュメントの例: http://appium.io/docs/en/commands/element/find-element/

それに加え、テストコードを書くときにはアプリ内のそれぞれのElementに対して`accessibilityLabel`を振っておくと**要素を特定しやすくなるのでオススメ**です。

## 動画を撮影する
```index.test.ts
beforeAll(async () => {
  try {
    await browser.startRecordingScreen({ videoType: 'mpeg4' });
  } catch (e) {}
});

afterAll(async () => {
  try {
    const movie = await browser.stopRecordingScreen();
    const decode = Buffer.from(movie, 'base64');
    fs.writeFileSync(
      `${baseResultPath}/result.mp4`,
      decode
    );
  } catch (e) {}
});
```

動画は簡単に撮れます。`browser.startRecordingScreen({ videoType: 'mpeg4' })`で撮影を開始した後、`browser.stopRecordingScreen()`で結果を保持し、後はデコードして書き出すだけです。

`ffmpeg`をインストールしておくのを忘れずに。

## 「1ページ目が存在している」かどうかのテスト
```index.test.ts
test('1ページ目が存在している', async () => {
  expect((await browser.$('~slide1')).elementId).not.toBeUndefined();
  await browser.saveScreenshot(`${baseResultPath}/page1.png`);
});
```

`browser.$('~slide1')).elementId`で`undefined`とならない場合、そのページは確かに存在している。という風にしてテストを担保しています。これは各ページで行っています。

また、`browser.saveScreenshot('${baseResultPath}/page1.png')`でスクリーンショットを保存しています。簡単ですね！

## 「1ページ目で入力された内容が2ページ目で表示されている」かどうかのテスト
```index.test.ts
test('1ページ目で入力された内容が2ページ目で表示されている', async () => {
  const textInputElement = await browser.$('~TextInput');
  await textInputElement.addValue('おりばー');
  await browser.pause(2000);
  await browser.hideKeyboard();
  await swipe('left');
  // 2ページ目の存在確認
  expect((await browser.$('~slide2')).elementId).not.toBeUndefined();

  // https://github.com/appium/appium/issues/13288 をみる限り
  // iOSのバージョンによってはgetText()がaccessibilityLabelと同じ文字列を返すバグがある
  // なのでaccessibilityLabelの値をinputTextと一緒にしている
  // 最新のiOSとAndroidは問題ない
  const textInputResultElement = await browser.$(`~${inputText}`);
  expect(await textInputResultElement.getText()).toBe(inputText);
});

async function swipe(direction: 'left' | 'right') {
  if(platformName === 'iOS') {
    // iOSはswipeを呼び出せるのでそれを使う
    await browser.execute('mobile: swipe', { direction });
  } else if(platformName === 'Android') {
    // Androidは代わりにflickを利用する
    const rootElement = await browser.$('//*');
    const x = direction === 'left' ? -1000 : 1000;
    await browser.touchFlick(x, 0, rootElement.elementId, 100);

    // 現状ReactNativeはこれだとSwipeできない？（AndroidのNativeだと動くことを確認）
    // await browser.touchPerform([
    //   { action: 'press', options: { x: 1000, y: windowSize.height / 2 } },
    //   { action: 'moveTo', options: { x: 100, y: windowSize.height / 2 } },
    //   { action: 'release' },
    // ]);
  }
}
```

ここでは、1ページにて、文字を入力し、それが２ページ目で入力された内容が表示されているかどうかのテストを行っています。

`textInputElement.addValue('おりばー')`とすることで、`textInputElement`に対して「おりばー」という文字が入力されます。

また、`browser.hideKeyboard()`でキーボードを閉じることができるのも意外と盲点だったりします。

`swipe('left')`はスワイプを行なっています。iOSとAndroidでスワイプの仕方が様々あるので、気になる方はコードを読んでみてください。

`browser.$(`~${inputText}`)`ここの部分はコメントにも書いているのですが、iOSの特定のバージョンにおいて、`getText()`が`accessibilityLabel`で指定した内容になってしまうというバグが存在しています。

追記： Xcodeのバージョンも大きく関係しているらしいです。詳しくはコメント欄をご覧ください。

ですので、その応急処置として、`accessibilityLabel`にそのまま値を入れるという実装になってしまっています。

## 「2ページ目のボタンをタップすると内容が変化する」かどうかのテスト
```index.test.ts
test('2ページ目のボタンをタップすると内容が変化する', async () => {
  await browser.saveScreenshot(`${baseResultPath}/page2-1.png`);
  const textChangeButtonElement = await browser.$('~textChangeButton');
  await textChangeButtonElement.click();
  const textInputResultElement = await browser.$('~タップされたよ！');
  await browser.saveScreenshot(`${baseResultPath}/page2-2.png`);
  expect(await textInputResultElement.getText()).not.toBe(inputText);
});
```

ここでは`textChangeButtonElement.click()`でボタンをタップして、文字の内容が変わっていることをテストしています。

## 「3ページ目が存在している」かどうかのテスト
```index.test.ts
test('3ページ目が存在している', async () => {
  await swipe('left');
  expect((await browser.$('~slide3')).elementId).not.toBeUndefined();
  await browser.saveScreenshot(`${baseResultPath}/page3.png`);

  // 画像をクリックして戻ってこれることを確認
  await browser.pause(2000);
  const imageWrap = await browser.$("~imageWrap");
  await imageWrap.click();
  await browser.pause(5000);
  // アプリに戻ってくるための処理
  if (platformName === 'Android') {
    await browser.pressKeyCode(4);
  } else {
    await browser.execute('mobile: activateApp', {
      bundleId: app.expo.ios.bundleIdentifier,
    });
  }
});
```

3ページ目では存在確認に加え、「画像をクリックして、別アプリに行った後、戻ってこれることを確認」しています。
別アプリに遷移してしまった後はAndroidとiOSとで指定の仕方は変わるのですが、それぞれ以下のコマンドで可能になっています。

Androidの場合
`browser.pressKeyCode(4)`

iOSの場合 
`browser.execute('mobile: activateApp', {bundleId:app.expo.ios.bundleIdentifier,})`

簡単ですね！

## 「5ページ目が存在している」かどうかのテスト

```index.test.ts
test('5ページ目が存在している', async () => {
  await swipe('left');
  expect((await browser.$('~slide5')).elementId).not.toBeUndefined();

  // 動画が再生されていることをスクショで判別する
  await browser.saveScreenshot(`${baseResultPath}/page5-1.png`);
  await browser.pause(3000);
  await browser.saveScreenshot(`${baseResultPath}/page5-2.png`);
});
```

5ページ目では動画が自動再生されますので、スクリーンショットを撮って、しっかりと再生されているかをチェックしています。

こんな感じでスクリーンショットを撮りつつ、テストを実行していく流れになります。

## 余談
今回のテストの中で、Androidの場合は`ChromeDriver`を利用するのですが、`ChromeDriver`はChromeのバージョンと強く紐づいています。
ですので、本来であればAndroidのChromeバージョンによって使い分けなければいけないのですが、Appiumの起動時に`chromedriver_autodownload`と指定することで、**Appium側でそこをよしなにやってくれています。**すごいですね！

# 並列化について
並列化は`webdriverio`が提供しているものもありますが、それを利用して並列化すると**特定の端末のテストが落ちたときに全てのテストが失敗してしまう**ことになるのでオススメはあまりしないです。

なので、自分で別プロセスでそれぞれ実行して並列化させることをオススメします。

並列化するときの注意点はiOSの場合は`wdaLocalPort`を、Androidの場合は`systemPort`をずらして実行することです。

# 終わりに
アプリのテスト自動化はAppiumのおかげで想像よりかは書きやすいです。
しかし、バグも多く潜んでいるので、根気よく戦っていく形にはやっぱりなってしまいます。
無理して全てを自動化するのではなく、今手動でやっているテストを少しでも簡単にしようという目線で、少しずつ自動化していくことが良いのではないかと思います。是非皆さんもAppiumに触れてみてください。