「Remote TestKit」Appium連携とThriftAPIチュートリアルを試してみた

この記事は[Qiita Engineer Festa 2022「Remote TestKitを使ってレビューを書こう！」](https://qiita.com/official-campaigns/engineer-festa/2022)のエントリー記事です。

(2022.08.10 追記)
当該テーマで最優秀賞をいただきました！🏆
初投稿で評価をいただけて、アウトプットのモチベがガン上がりです！💪
ありがとうございます😭

https://blog.qiita.com/qiitaengineerfesta-2022-presents/#remote-testkit-%E6%9C%80%E5%84%AA%E7%A7%80%E8%B3%9E

# はじめに

私が勤めている会社で開発しているWebサービスはマルチブラウザをサポートしているため、PCやスマホの複数環境で検証をすることがあります。
その際に、今回ご紹介する**Remote TestKit**を使用しています。

普段使用している身近なサービスで、これだ！となったため、Qiitaに初投稿してみました！🎉

今回はそんな**RemoteTestKit**を使用して、
`Android端末で動作するWebアプリのE2Eテスト自動化`を実際のスクリプトを交えながらご紹介できればと思います。

# Remote TestKitとは

Remote TestKitとは、ブラウザやPCソフトウェアからクラウド上の実機端末をレンタルすることができるサービスです。

レンタルした端末は実機端末のように操作したり、アプリをインストールさせたりすることができます。
シミュレータでは扱えない古いiOSやAndroid、さらにはガラホが使えたりと、端末の種類の豊富さも魅力です。

https://appkitbox.com/testkit/

Web版の操作画面
![スクリーンショット 2022-07-18 15.01.06-min.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/31349e76-d337-2823-b31b-51131a932b7e.png)

ソフトウェア版の操作画面
![スクリーンショット 2022-07-18 15.01.45-min.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/4749cffc-a923-5528-7f46-760464097429.png)

# 環境準備
ここからは`Android端末で動作するWebアプリのE2Eテスト自動化`を実現する環境を準備していきます。
以下構成でE2Eテスト環境を構築します。

任意のディレクトリを作成し、事前にNode.jsとnpmを使えるようにしておいてください。:muscle_tone1:

- OS: macOS Big Sur(11.6.4)
- Node.js: v16.10.0
- JRE: v8.0
- テストフレームワーク: WebDriverIO、Appium
- 開発言語: JavaScript、TypeScript(テストコード)

![目指すE2Eテスト環境構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/73dc1017-0f91-86e7-3b7b-e628a45c1cb4.png "目指すE2Eテスト環境構成")

```bash:最終的なフォルダ構成
.
├── .env
├── conf
│   └── wdio
│       ├── shared
│       │   ├── wdio.shared.appium.conf.ts
│       │   └── wdio.shared.conf.ts
│       ├── wdio.appium.android.10.arrows-nx9.conf.ts
│       └── wdio.appium.android.11.galaxy-a22.conf.ts
├── package-lock.json
├── package.json
├── rtk
│   ├── client.js
│   ├── devices.json
│   └── gen-nodejs
│       ├── AllVersionDeviceService.js
│       ├── AllVersionMainService.js
│       ├── DeviceService.js
│       ├── MainService.js
│       └── RemoteTestKitAPI_types.js
├── run-e2e.js
├── test
│   ├── pageobjects
│   │   ├── login.page.ts
│   │   ├── page.ts
│   │   └── secure.page.ts
│   └── specs
│       └── browser.sp.spec.ts
└── tsconfig.json
```

## Appium
https://appium.io/

Appiumはオープンソースのクロスプラットフォームテスト自動化ツールです。
AppiumとRemote TestKitを連携することで、Remote TestKitの端末に対してE2Eテストを実施できるようにします。
（詳しい手順は以下リンク）

https://appkitbox.com/testkit/doc/appium_tutorial/

### 「Remote TestKit」Appium連携

連携方法は2つありますが、
- Remote TestKit の仮想adb機能/Xcode連携を用いた Appium 環境
- Remote TestKit Appium Cloudを用いたAppium環境

>※Remote TestKit Appium CloudはFlat 3以上のプランをご契約しているお客様のみご利用になります。

Remote TestKit Appium Cloudは課金グレードが上がるため、今回は前者を採用します。

### インストール

詳細な環境構築は割愛しますが、
以下のように環境診断ツール[appium-doctor](https://github.com/appium/appium-doctor)の結果をオールグリーンにできればOKです！

```bash
$ appium-doctor 
info AppiumDoctor Appium Doctor v.1.16.0
info AppiumDoctor ### Diagnostic for necessary dependencies starting ###
info AppiumDoctor  ✔ The Node.js binary was found at: /Users/ogatm/.nodebrew/current/bin/node
info AppiumDoctor  ✔ Node version is 16.10.0
info AppiumDoctor  ✔ Xcode is installed at: /Applications/Xcode.app/Contents/Developer
info AppiumDoctor  ✔ Xcode Command Line Tools are installed in: /Applications/Xcode.app/Contents/Developer
info AppiumDoctor  ✔ DevToolsSecurity is enabled.
info AppiumDoctor  ✔ The Authorization DB is set up properly.
info AppiumDoctor  ✔ Carthage was found at: /usr/local/bin/carthage. Installed version is: 0.38.0
info AppiumDoctor  ✔ HOME is set to: /Users/ogatm
info AppiumDoctor  ✔ ANDROID_HOME is set to: /Users/ogatm/Library/Android/sdk
info AppiumDoctor  ✔ JAVA_HOME is set to: /Users/ogatm/.jenv/versions/1.8
info AppiumDoctor    Checking adb, android, emulator
info AppiumDoctor      'adb' is in /Users/ogatm/Library/Android/sdk/platform-tools/adb
info AppiumDoctor      'android' is in /Users/ogatm/Library/Android/sdk/tools/android
info AppiumDoctor      'emulator' is in /Users/ogatm/Library/Android/sdk/emulator/emulator
info AppiumDoctor  ✔ adb, android, emulator exist: /Users/ogatm/Library/Android/sdk
info AppiumDoctor  ✔ 'bin' subfolder exists under '/Users/ogatm/.jenv/versions/1.8'
info AppiumDoctor ### Diagnostic for necessary dependencies completed, no fix needed. ###
...
```

後で使うためnpmパッケージをインストールしておきます。

```bash
$ npm i -D appium
```

## WebDriverIO

https://webdriver.io/

WebDriverIOはオープンソースのE2Eテストフレームワークです。
今回ご紹介するAndroid端末のE2Eテストだけではなく、Selenium Standaloneを使用してPCブラウザのE2Eテストも実現したかったため、
AppiumとSelenium Standaloneをラップするテストフレームワーク**WebDriverIO**経由でAppiumを使うことにしました。

### インストール

```bash
$ npm init wdio .
...
=========================
WDIO Configuration Helper
=========================

? Where is your automation backend located? On my local machine
? Which framework do you want to use? mocha
? Do you want to use a compiler? TypeScript (https://www.typescriptlang.org/)
? Where are your test specs located? ./test/specs/**/*.ts
? Do you want WebdriverIO to autogenerate some test files? Yes
? Do you want to use page objects (https://martinfowler.com/bliki/PageObject.html)? Yes
? Where are your page objects located? ./test/pageobjects/**/*.ts
? Which reporter do you want to use? spec
? Do you want to add a plugin to your test setup? 
? Do you want to add a service to your test setup? chromedriver
? What is the base url? http://localhost
? Do you want me to run `npm install` Yes
```

生成された以下を削除しておきます。

```bash
$ rm test/tsconfig.json
$ rm test/wdio.conf.ts
```

型ファイル、WebDriverIOでAppiumを使用するためのnpmパッケージをインストールしておきます。

```bash
$ npm i -D @wdio/types @wdio/appium-service
```

参考までにtsの設定はこんな感じです。

```json:tsconfig.json
{
  "compilerOptions": {
    "sourceMap": false,
    "target": "es2021",
    "module": "commonjs",
    "esModuleInterop": true,
    "noImplicitAny": true,
    "strictPropertyInitialization": true,
    "strictNullChecks": true,
    "types": ["node", "webdriverio", "webdriverio/async", "@wdio/mocha-framework", "expect-webdriverio"],
    "strictFunctionTypes": false
  },
  "exclude": ["node_modules"],
  "compileOnSave": false,
  "include": ["./tests/**/*.ts", "./conf/wdio/**/*.ts"]
}
```

### 設定ファイル

```
├── conf
│   └── wdio
│       ├── shared
│       │   ├── wdio.shared.appium.conf.ts
│       │   └── wdio.shared.conf.ts
│       ├── wdio.appium.android.10.arrows-nx9.conf.ts
│       └── wdio.appium.android.11.galaxy-a22.conf.ts
```

マルチブラウザ・マルチプラットフォームでテストする観点で、上のように設定ファイルを分けています。

概ねデフォルトのままで、
`wdio.appium.android.10.arrows-nx9.conf.ts` `wdio.appium.android.11.galaxy-a22.conf.ts`が
主に今回使用するAndroid端末向けの設定になります。

```ts:wdio.shared.conf.ts
export const config: WebdriverIO.Config = {
  specs: [],
  exclude: [],
  maxInstances: 1,
  capabilities: [],
  logLevel: 'error',
  bail: 0,
  waitforTimeout: 10000,
  connectionRetryTimeout: 120000,
  connectionRetryCount: 3,
  reporters: ['spec'],
  mochaOpts: {
    ui: 'bdd',
    timeout: 60000,
  },
  autoCompileOpts: {
    autoCompile: true,
    tsNodeOpts: {
      transpileOnly: true,
      project: 'tsconfig.json',
    },
  },
};
config.port = 4723;
```

```ts:wdio.shared.appium.conf.ts
import { config } from './wdio.shared.conf';

config.services = (config.services ? config.services : []).concat([
  [
    'appium',
    {
      command: 'appium',
      args: {
        relaxedSecurity: true,
      },
    },
  ],
]);

config.hostname = '127.0.0.1';
config.port = 4723;
config.path = '/wd/hub/';

export default config;
```

```ts:wdio.appium.android.10.arrows-nx9.conf.ts
import config from './shared/wdio.shared.appium.conf';

config.specs = ['./test/**/browser.sp*.spec.ts'];

config.capabilities = [
  {
    platformName: 'Android',
    browserName: 'chrome',
    maxInstances: 1,

    chromeOptions: {
      args: ['--ignore-certificate-errors'],
    },

    deviceName: 'arrows NX9 F-52A',
    platformVersion: '10',

    automationName: 'UIAutomator2',
    orientation: 'PORTRAIT',
  },
];
exports.config = config;
```

```ts:wdio.appium.android.11.galaxy-a22.conf.ts
import config from './shared/wdio.shared.appium.conf';

config.specs = ['./test/**/browser.sp*.spec.ts'];

config.capabilities = [
  {
    platformName: 'Android',
    browserName: 'chrome',
    maxInstances: 1,

    chromeOptions: {
      args: ['--ignore-certificate-errors'],
    },

    deviceName: 'Galaxy A22 5G SC-56B',
    platformVersion: '11',

    automationName: 'UIAutomator2',
    orientation: 'PORTRAIT',
  },
];
exports.config = config;
```

### 実行

ここまでに作成した環境で、手動でレンタルしたRemote TestKitの端末に対してテストを実行してみるとこんな感じです。
(※添付gifは4倍速)

テストコードはwdio init時のサンプル(ファイル名は`browser.sp.spec.ts`に変更)です。

```
$ npx wdio run ./conf/wdio/wdio.appium.android.10.arrows-nx9.conf.ts
```

![1.-テスト実行_4x_min.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/131854df-1890-59e4-9449-1f5507292278.gif)

Remote TestKitの端末が自動操作され、テスト結果が出力されることがわかります。

## Apache Thrift

https://appkitbox.com/testkit/doc/thriftapi/

Remote TestKitの端末に対してテストを実行するだけであれば、先ほどの環境で問題ないのですが、
端末をレンタルする部分が手動のため、端末のレンタルを自動化するために**Remote TestKit Thrift API**を使用します。

### インストール

リンク先よりthriftファイルをダウンロードします。
![スクリーンショット 2022-07-17 18.21.29-min.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/b3527ba5-e5cb-7101-708c-9ebf50e2577e.png)

テストフレームワークと同じNode.js環境でthriftを扱えるように、以下を参考にthriftファイルをjsに変換します。

https://thrift.apache.org/tutorial/nodejs.html

```bash
$ brew install thrift
$ thrift --gen js:node ./RemoteTestKitAPI.thrift
```

生成された`gen-nodejs`を以下のように配置、変換前のthriftファイルは削除しておきます。

```
├── rtk
│   └── gen-nodejs
│       ├── AllVersionDeviceService.js
│       ├── AllVersionMainService.js
│       ├── DeviceService.js
│       ├── MainService.js
│       └── RemoteTestKitAPI_types.js
```

後で使うためthriftのnpmパッケージをインストールしておきます。
```bash
$ npm i -D thrift
```

# ThriftAPIで自動レンタル

ここまでで環境構築が概ね完了したため、先ほどのThrift APIを使用し、スクリプトで端末のレンタルを試していこうと思います。

作成するスクリプトは、jsonファイルに定義した端末を自動でレンタルし、実行するテストの設定ファイル名を返すスクリプトです。

### 準備

まずはRemote TestKitのログイン情報を管理するためにdotenvをインストールします。

```bash
$ npm i -D dotenv
```

```dot:.env
RTK_USER_NAME="{Remote TestKit ユーザ名}"
RTK_PASSWORD="{Remote TestKit パスワード}"
```

別のターミナルを開いて、Remote TestKit Thrift APIサーバを起動しておきます。

```bash
$ cd "/Applications/Remote TestKit.app/Contents/MacOS" ; java -cp "../Java/*" -Dconf.directory.company.name=nttr nttr.rtk.MainForThriftServer
```

以下のスクリプトを作成します。

```js:rtk/client.js
const fs = require('fs');
const path = require('path');

require('dotenv').config();

const thrift = require('thrift');

const DeviceServiceClient = require('./gen-nodejs/DeviceService').Client;
const MainServiceClient = require('./gen-nodejs/MainService').Client;
const ServicePorts = require('./gen-nodejs/RemoteTestKitAPI_types').ServicePorts;

/**
 * Remote TestKit から devicesConfName に定義した端末をレンタルする
 *
 * @param {string} devicesConfName
 * @returns {Promise<?string>}
 */
async function rtkClient(devicesConfName = 'devices.json') {
  const devicesConfPath = path.resolve(__dirname, devicesConfName);
  const devicesConf = readDevicesJson(devicesConfPath);
  const devices = devicesConf.devices;
  const deviceIds = devices.map((device) => device.deviceId);

  const transport = thrift.TBufferedTransport;
  const protocol = thrift.TBinaryProtocol;

  console.log('Connect to main service');
  const mainServiceConnection = thrift.createConnection('localhost', ServicePorts.MAIN_SERVICE_PORT, {
    transport: transport,
    protocol: protocol,
  });

  console.log('Login');
  const mainServiceClient = thrift.createClient(MainServiceClient, mainServiceConnection);
  await mainServiceClient.login(process.env.RTK_USER_NAME, process.env.RTK_PASSWORD).catch((err) => {
    console.error('[login error]', err);
    return;
  });

  console.log('Connect to device service');
  const deviceServiceConnection = thrift.createConnection('localhost', ServicePorts.DEVICE_SERVICE_PORT, {
    transport: transport,
    protocol: protocol,
  });
  const deviceServiceClient = thrift.createClient(DeviceServiceClient, deviceServiceConnection);

  console.log('Get device list');
  const fetchDevices = async () => {
    const remoteDevices = await mainServiceClient.getDevices();
    if (!remoteDevices.length) {
      await sleep(1000);
      return fetchDevices();
    }
    return remoteDevices;
  };
  const remoteDevices = await fetchDevices();

  const rentDevice = async (requiredDevices) => {
    for (const requiredDevice of requiredDevices) {
      const deviceId = requiredDevice.deviceId;
      let failed = false;
      await deviceServiceClient.open(deviceId).catch((e) => {
        console.error(`[device "${deviceId}" open error]`, e);
        failed = true;
      });
      if (!failed) {
        console.log('Rent a device', requiredDevice);
        return deviceId;
      }
    }
  };
  const requiredDevices = remoteDevices.filter((device) => deviceIds.includes(device.deviceId));
  const rentedDeviceId = await rentDevice(requiredDevices);

  // devicesConfで定義した端末がすべてレンタル不可
  if (!rentedDeviceId) {
    return;
  }

  // 仮想adb有効化
  const adbCommandFilePath = '/Users/ogatm/Library/Android/sdk/platform-tools/adb';
  await deviceServiceClient.enableAdb(adbCommandFilePath).catch((e) => {
    console.error('[enable adb error]', e);
    return;
  });

  return devicesConf.devices.find((device) => device.deviceId === rentedDeviceId)?.configFileName;
}
module.exports = rtkClient;

/**
 * @param {string} jsonPath
 * @returns {Object}
 */
function readDevicesJson(jsonPath) {
  const devices = fs.readFileSync(jsonPath, { encoding: 'utf-8' });
  return JSON.parse(devices);
}

/**
 * @param {number} time (ms)
 * @returns {Promise}
 */
function sleep(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, time);
  });
}
```

```json:devices.json
{
  "devices": [
      {
          "deviceId": "RFCRB18KFJK",
          "configFileName": "wdio.appium.android.11.galaxy-a22.conf.ts"
      },
      {
          "deviceId": "998f5cef",
          "configFileName": "wdio.appium.android.10.arrows-nx9.conf.ts"
      }
  ]
}
```

### 自動レンタル実行

```
$ node -e "require('./rtk/client')()"
```

![2.-自動レンタル_min_4x.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/fd1b2bed-06e9-fd6e-5c59-ed5e6ae54b94.gif)
接続・レンタル中の端末一覧に端末が追加されたことがわかります。(※添付gifは4倍速)

# 自動レンタル→自動テスト実行

自動レンタルを実装できたため、レンタルした端末に対してE2Eテストを実行させてみようと思います。

先ほどの自動レンタルスクリプトと、WebDriverIOのテスト実行コマンドをNode.jsから呼び出すスクリプトを組み合わせます。

```js:run-e2e.js
const childProcess = require("child_process");
const path = require("path");
const rtkClient = require("./rtk/client");

async function run() {
  const wdioConfName = await rtkClient('devices.json');
  if (!wdioConfName) {
    throw new Error("Run E2E testing failed...");
  }
  const appium = childProcess.exec(`npx wdio run ${path.resolve("conf", "wdio", wdioConfName)}`);
  appium.stdout.on("data", (data) => console.log(data));
  appium.stderr.on("data", (data) => console.log(data));
}

run();
```

### 実行結果

![3.-自動レンタル_テスト実行_min_4x.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/814996/6f0f34c3-3da8-2ecb-87d2-ca6224502e2b.gif)

```
$ node run-e2e.js 
Connect to main service
Login
Connect to device service
Get device list
Connect to device service
Get device list
Rent a device {
  deviceId: '998f5cef',
  productName: 'arrows NX9 F-52A',
  osType: 1,
  osVersion: '10',
  releaseDay: { buffer: <Buffer 00 00 01 76 71 37 05 80>, offset: 0 },
  displayWidth: 1080,
  displayHeight: 2280,
  carrier: 'NTTドコモ',
  manufacturer: '富士通',
  processor: 'Snapdragon 765G (2.4GHz ＆ 2.2GHz ＆ 1.8GHz - Octa)',
  gpu: 'Adreno 620',
  ram: '8GB',
  rom: '128GB',
  pointsPerUnit: 3,
  isSoundInputAvailable: true,
  isSoundOutputAvailable: true
}

Execution of 1 workers started at 2022-07-18T06:13:31.530Z



[0-0] RUNNING in chrome - /test/specs/browser.sp.spec.ts

[0-0] PASSED in chrome - /test/specs/browser.sp.spec.ts


 "spec" Reporter:
------------------------------------------------------------------
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0] Running: arrows_nx9_f-52a.adb.appkitbox.com:47002 on Android 10 executing chrome
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0] Session ID: cdd420a9-71a6-40ee-83ca-e67f5a3d6a2f
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0]
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0] » /test/specs/browser.sp.spec.ts
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0] My Login application
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0]    ✓ should login with valid credentials
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0]
[arrows_nx9_f-52a.adb.appkitbox.com:47002 Android 10 #0-0] 1 passing (9.2s)


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

Thrift API経由でレンタルするとレンタルした端末がウィンドウ表示されないためわかりにくいですが、
スクリプト1つで、Remote TestKitの端末を自動レンタル→レンタルした端末に対してE2Eテストを実行できることがわかると思います。
(※添付gifは4倍速)

# まとめ
**Remote TestKit**は、たくさんの実機端末のレンタルだけではなく、
E2Eテストを実施するためのAPIを整えてくれているため、とても活用しやすいサービスです。

また、今回はWebブラウザに対してE2Eテストを実施しましたが、Remote TestKitの端末にapkをインストールして、
ネイティブアプリに対してもE2Eテストを実施できるため、可能性の塊に感じました！

これからも業務で使っていきたいと思います。