React Native な Android アプリに WebdriverIO + Appium + mocha + chai で自動E2Eテストを導入してみた

#はじめに
この記事は、React Native による Android アプリケーションに [WebdriverIO](https://webdriver.io/) + [Appium](https://appium.io/) + [mocha](https://www.npmjs.com/package/mocha) + [chai](https://www.npmjs.com/package/chai) で自動E2Eテストを導入した際に気をつけたことをまとめたものになります。
既存の React Native プロジェクトに新しく自動テストを導入したいと考えている方の参考になれば幸いです。

#E2Eテストとは
E2E (End to End) テストとは、「システム利用者の画面操作により、システムが想定通りに動作することを確認する」テストのことです。

#今回構築する自動テストの概要
- Android実機上で Appium がサーバー、PC上で WebdriverIO がクライアントとなり、HTTPコマンドをやりとりすることで画面を操作します
- テストコードは mocha と chai を使って書きます
- テスト対象の React Native な Android アプリはAPKファイルで用意します
- 今回は自動E2Eテストの導入ということで、「アプリの初期画面に特定のコンポーネントが存在すること」をテストしてみます

#WebdriverIOのインストール
プロジェクトのルートディレクトリで以下コマンドを実行します。

```zsh
npm install @wdio/cli
```

#WebdriverIOのセットアップ
プロジェクトのルートディレクトリで```npx wdio config```を実行すると、WebdriverIO のセットアップが始まります。
選択肢は以下のように選択します。

```zsh
? Where is your automation backend located? On my local machine
? Which framework do you want to use? mocha
? Do you want to run WebdriverIO commands synchronous or asynchronous? sync
? Where are your test specs located? ./test/specs/**/*.js
? Do you want WebdriverIO to autogenerate some test files? Yes
? Do you want to use page objects (https://martinfowler.com/bliki/PageObject.html)? No
? Are you using a compiler? Babel (https://babeljs.io/)
? Which reporter do you want to use? spec
? Do you want to add a service to your test setup? appium
? What is the base url? http://localhost
```

セットアップが終わると、```package.json```の```devDependencies```内に以下の記述が追加されたことを確認できます（バージョンは一例であり、この限りではありません）。

```json:package.json
"devDependencies": {
    "@wdio/appium-service": "^7.12.5",
    "@wdio/local-runner": "^7.12.5",
    "@wdio/mocha-framework": "^7.12.5",
    "@wdio/spec-reporter": "^7.12.5",
    "@wdio/sync": "^7.12.5",
    "appium": "^1.21.0"
}
```

以上で WebdriverIO のセットアップは完了です。

#wdio.conf.jsの編集
WebdriverIO のセットアップの過程で、プロジェクトのルートディレクトリに```wdio.conf.js```というファイルが生成されているはずです。
その```wdio.conf.js```内の```capabilities```の項目を以下に置き換えます。

```javascript:wdio.conf.js
capabilities: [{
    maxInstances: 1,
    platformName: 'Android',
    deviceName: 'hoge',
    app: 'APKファイルへの絶対パス',
    automationName: 'UiAutomator2',
    testEnv: 'Android',
}]
```

``deviceName:``はなんでも良いです。
``app:``は、今回テストする React Native プロジェクトの APK ファイルへの絶対パスを指定します。

APKファイルは、```npm run android```を実行すると```プロジェクトのルートディレクトリ/android/app/build/outputs/apk/debug/app-debug.apk```に保存されます（デバッグビルドの場合）。

これで WebdriverIO と Appium を使う準備が整いました。

#chaiのインストール
mocha は WebdriverIO のセットアップ過程で一緒にインストールされますが、 chai は手動でインストールする必要があります。
プロジェクトのルートディレクトリで以下コマンドを実行します。

```zsh
npm install --save-dev chai
```

これでchaiによるアサーションをテストコードに記述できるようになりました。

#テストコードを書く
WebdriverIO のセットアップの過程で```プロジェクトのルートディレクトリ/test/specs/example.e2e.js```というファイルが生成されています。

このファイルの中身を以下に置き換えます。

```javascript:example.e2e.js
const Chai = require('../../node_modules/chai');
const expect = Chai.expect;

describe('お試しテスト:', () => {
  it('テストボタンが表示されること', () => {
    $('~TIDTestButton').waitForDisplayed({ timeout: 60000 });
    expect($('~TIDTestButton').isExisting()).to.be.true;
  });
});
```

``waitForDisplayed()``はコンポーネントが画面に表示されるのを待つ関数です。
ここでは```TIDTestButton```というテストIDを持つコンポーネントが画面に表示されるまでに、待ち時間として60000ms=60秒を指定しています。
60秒待っても表示されなかった場合はテスト失敗となります。

``isExisting()``はコンポーネントが画面上に存在しているかを見る関数です。存在する場合は true を返します。
```.to.be.true```の部分で、```TIDTestButton```というテストIDを持つコンポーネントが画面上に存在することをアサートしています。

#テストIDを仕込む
Androidでは```accessibilityLabel```というラベルでテストIDを指定します。

UIを構成するファイル内で、以下のように```accessibilityLabel```をセットします。

```javascript
<Button {...{accessibilityLabel: 'TIDTestButton'}} title={'Test'} />
```
これで、Testボタンに```TIDTestButton```というテストIDを仕込むことができました。

#npm test コマンドを追加
WebdriverIO によるテストを実行するコマンドは```npx wdio run ./wdio.conf.js --spec example.e2e.js```というコマンドです。
これを毎回コンソールに打ち込むのは大変なので、もう少し簡単なコマンドで実行できるようにします。

``package.json``の```scripts```内に以下の記述を追加してください。

```json:package.json
"scripts": {
    "test": "npx wdio run ./wdio.conf.js --spec example.e2e.js"
}
```

これで、プロジェクトのルートディレクトリで```npm test```コマンドを叩くだけで WebdriverIO による自動テストが走るようになりました。

#テスト前の準備
ついにテストを実行してみます。

まず、Android実機をPCに有線接続します。
接続した際に「USBデバッグを許可しますか？」と聞かれたら、許可を選択します。

Android実機がPCに接続できたかどうかは、```adb devices```コマンドで確認できます。

```zsh
% adb devices
List of devices attached
デバイス名     device
```

このように出力されたら接続できています。
接続できない場合は、ケーブルを抜いてもう一度挿すなどしてみてください。

なお、今回テストに用いるAPKファイルがデバッグビルドしたものである場合は、テスト実行する際に Metro Bundler を立ち上げておく必要があります。必要に応じて立ち上げてください。
Metro Bundler を立ち上げるコマンドは以下です。

```zsh
% react-native start
```

#テストを実行してみる
それではいよいよ、プロジェクトのルートディレクトリで```npm test```コマンドを叩きます。

```zsh
% npm test

> sampleApp@0.0.1 test /~~/sampleApp
> npx wdio run ./wdio.conf.js --spec example.e2e.js


Execution of 1 workers started at 2021-09-20T02:42:03.283Z

2021-09-20T02:42:03.308Z INFO @wdio/cli:launcher: Run onPrepare hook
2021-09-20T02:42:03.853Z INFO @wdio/cli:launcher: Run onWorkerStart hook
2021-09-20T02:42:03.854Z INFO @wdio/local-runner: Start worker 0-0 with arg: run,./wdio.conf.js,--spec,example.e2e.js
[0-0] 2021-09-20T02:42:03.921Z WARN @wdio/utils:shim: You are running tests with @wdio/sync which will be discontinued starting Node.js v16.Read more on https://github.com/webdriverio/webdriverio/discussions/6702
[0-0] 2021-09-20T02:42:04.244Z INFO @wdio/local-runner: Run worker command: run
[0-0] RUNNING in APKファイルへの絶対パス - /test/specs/example.e2e.js
[0-0] 2021-09-20T02:42:04.672Z INFO webdriver: Initiate new session using the WebDriver protocol
[0-0] 2021-09-20T02:42:04.708Z INFO webdriver: [POST] http://localhost:4723/session
[0-0] 2021-09-20T02:42:04.708Z INFO webdriver: DATA {
[0-0]   capabilities: {
[0-0]     alwaysMatch: {
[0-0]       platformName: 'Android',
[0-0]       deviceName: 'hoge',
[0-0]       app: 'APKファイルへの絶対パス',
[0-0]       automationName: 'UiAutomator2',
[0-0]       autoGrantPermissions: true,
[0-0]       testEnv: 'Android'
[0-0]     },
[0-0]     firstMatch: [ {} ]
[0-0]   },
[0-0]   desiredCapabilities: {
[0-0]     platformName: 'Android',
[0-0]     deviceName: 'hoge',
[0-0]     app: 'APKファイルへの絶対パス',
[0-0]     automationName: 'UiAutomator2',
[0-0]     autoGrantPermissions: true,
[0-0]     testEnv: 'Android'
[0-0]   }
[0-0] }

~中略~

[0-0] 2021-09-20T02:42:12.904Z INFO webdriver: COMMAND findElement("accessibility id", "TIDTestButton")
[0-0] 2021-09-20T02:42:12.905Z INFO webdriver: [POST] http://localhost:4723/session/259a2883-8f45-454f-8f7c-074540435211/element
[0-0] 2021-09-20T02:42:12.905Z INFO webdriver: DATA { using: 'accessibility id', value: 'TIDTestButton' }
[0-0] 2021-09-20T02:42:13.393Z INFO webdriver: RESULT {
[0-0]   'element-6066-11e4-a52e-4f735466cecf': '00000000-0000-073e-ffff-ffff00000012',
[0-0]   ELEMENT: '00000000-0000-073e-ffff-ffff00000012'
[0-0] }
[0-0] 2021-09-20T02:42:13.401Z INFO webdriver: COMMAND isElementDisplayed("00000000-0000-073e-ffff-ffff00000012")
[0-0] 2021-09-20T02:42:13.401Z INFO webdriver: [GET] http://localhost:4723/session/259a2883-8f45-454f-8f7c-074540435211/element/00000000-0000-073e-ffff-ffff00000012/displayed
[0-0] 2021-09-20T02:42:13.438Z INFO webdriver: RESULT true
[0-0] 2021-09-20T02:42:13.438Z INFO webdriver: COMMAND findElement("accessibility id", "TIDTestButton")
[0-0] 2021-09-20T02:42:13.438Z INFO webdriver: [POST] http://localhost:4723/session/259a2883-8f45-454f-8f7c-074540435211/element
[0-0] 2021-09-20T02:42:13.438Z INFO webdriver: DATA { using: 'accessibility id', value: 'TIDTestButton' }
[0-0] 2021-09-20T02:42:13.535Z INFO webdriver: RESULT {
[0-0]   'element-6066-11e4-a52e-4f735466cecf': '00000000-0000-073e-ffff-ffff00000012',
[0-0]   ELEMENT: '00000000-0000-073e-ffff-ffff00000012'
[0-0] }
[0-0] 2021-09-20T02:42:13.543Z INFO webdriver: COMMAND findElements("accessibility id", "TIDTestButton")
[0-0] 2021-09-20T02:42:13.544Z INFO webdriver: [POST] http://localhost:4723/session/259a2883-8f45-454f-8f7c-074540435211/elements
[0-0] 2021-09-20T02:42:13.544Z INFO webdriver: DATA { using: 'accessibility id', value: 'TIDTestButton' }
[0-0] 2021-09-20T02:42:13.644Z INFO webdriver: RESULT [
[0-0]   {
[0-0]     'element-6066-11e4-a52e-4f735466cecf': '00000000-0000-073e-ffff-ffff00000012',
[0-0]     ELEMENT: '00000000-0000-073e-ffff-ffff00000012'
[0-0]   }
[0-0] ]
[0-0] 2021-09-20T02:42:13.652Z INFO webdriver: COMMAND deleteSession()
[0-0] 2021-09-20T02:42:13.652Z INFO webdriver: [DELETE] http://localhost:4723/session/259a2883-8f45-454f-8f7c-074540435211
[0-0] PASSED in APKファイルへの絶対パス - /test/specs/example.e2e.js
2021-09-20T02:42:14.031Z INFO @wdio/cli:launcher: Run onComplete hook

 "spec" Reporter:
------------------------------------------------------------------
[デバイス名 Android 10 #0-0] Running: デバイス名 on Android 10 executing APKファイルへの絶対パス
[デバイス名 Android 10 #0-0] Session ID: 259a2883-8f45-454f-8f7c-074540435211
[デバイス名 Android 10 #0-0]
[デバイス名 Android 10 #0-0] » /test/specs/example.e2e.js
[デバイス名 Android 10 #0-0] お試しテスト:
[デバイス名 Android 10 #0-0]    ✓ テストボタンが表示されること
[デバイス名 Android 10 #0-0]
[デバイス名 Android 10 #0-0] 1 passing (2s)


Spec Files:      1 passed, 1 total (100% completed) in 00:00:10 

2021-09-20T02:42:14.033Z INFO @wdio/local-runner: Shutting down spawned worker
2021-09-20T02:42:14.287Z INFO @wdio/local-runner: Waiting for 0 to shut down gracefully
2021-09-20T02:42:14.288Z INFO @wdio/local-runner: shutting down
```

こんな感じで出力されたらテスト成功です。

#上手くいかないとき
何らかの理由で上手くいかない場合は、大体はエラー文を見ればわかりますが、主に以下を疑ってみてください。

- ```package.json```に```"@wdio/sync"```が追加されているか
- ```wdio.conf.js```に設定したAPKファイルへの絶対パスは正しいか
- ```npx wdio run ./wdio.conf.js --spec example.e2e.js```コマンドで指定しているファイルが存在するか、ファイル名は正しいか
- デバッグビルドしたAPKファイルを使用する場合は Metro Bundler が立ち上がっているか
- テストしたいコンポーネントが画面上にあるか(スクロールしないと画面上に現れない場合は検出できないので、スクロール操作を行うテストコードを書く必要があります)