Github ActionsとBitriseで、DetoxのAndroid E2Eテストを走らせる

Detox での Android テストを、GitHub Actions と Bitrise で（あとおまけに AWS Device Farm で）走らせてみた記事です。

## はじめに - Detox について
Detox は Wix が開発している React Native の E2E テスト用フレームワークです。
[wix/Detox: Gray box end-to-end testing and automation framework for mobile apps](https://github.com/wix/Detox)

React Native アプリの代表的な E2E フレームワークとしては、他に [Appium](https://github.com/appium/appium) が挙げられます。
Appium は Selenium のモバイル版です。ビルド済みのアプリに対して WebDriver の Wrapper に依存したテストを行い、アプリの挙動や負荷には関心を持ちません。

Detox は React Native のアプリ用に開発されていて、ネイティブなテストフレームワークである [EarlGrey](https://github.com/google/EarlGrey)（iOS） と [Espresso](https://developer.android.com/training/testing/espresso/basics?hl=ja)（Android） に依存したテストを行います。
そして、端末のアイドル状態や通信処理を外側から監視するため、不確定な待機時間をある程度コントロールしながらテストを実行してくれます。
「アプリの状態を把握しながらテストする」という意味で、グレーボックステスト[^1] [^2]を意識しています。

今回はその Detox のテストを CI で動かしたい、という話です。

## 環境
- Detox 17.14.3
- React Native 0.63
- Node 12

### ライセンス
Detox は MIT, EarlGrey は Apache License 2.0 です。
[サンプルリポジトリ](https://github.com/grgr-dkrk/rne2eBitriseSample)のコード、設定、テスト内容は MIT です。

## テストを書いてみる
環境導入などについて触れると、長くなってしまうので[公式ドキュメント](https://github.com/wix/Detox/blob/master/docs/Introduction.GettingStarted.md)を参考にしてください。
[サンプルリポジトリ](https://github.com/grgr-dkrk/rne2eBitriseSample)を作ったので、そちらを使って説明します。
Android の環境導入は[こちらの Commit](https://github.com/grgr-dkrk/rne2eBitriseSample/commit/61c02daa4b08cd8d8711b0248f8494e3df23f61a) でやっています。

今回のテスト対象は、下記のコンポーネントです。
中身は深く触れませんが、「テキストを入力してボタンを押したら、そのテキストと文字数が表示される」というものです。

```tsx:src/App.tsx
import React from 'react'
import {
  KeyboardAvoidingView,
  StyleSheet,
  SafeAreaView,
  Text,
  View,
  TextInput,
  TouchableOpacity,
} from 'react-native'

export const App = () => {
  const [formText, setFormText] = React.useState('')
  const [resultText, setResultText] = React.useState('')
  const handleChangeText = React.useCallback(
    (value: string) => {
      setFormText(value)
    },
    [formText],
  )
  const handlePress = React.useCallback(() => {
    setResultText(formText)
  }, [formText])
  return (
    <SafeAreaView style={styles.wrapper}>
      <KeyboardAvoidingView style={styles.inner} behavior="padding">
        {resultText ? (
          <View>
            <Text accessibilityLiveRegion="polite">
              文字数は{[...resultText].length}です。
            </Text>
          </View>
        ) : null}
        <View style={styles.formWrapper}>
          <Text style={styles.label}>何らかのテキストを入力</Text>
          <TextInput
            accessible
            accessibilityLabel={'カウントしたい文字を入力'}
            defaultValue={formText}
            onChangeText={handleChangeText}
            style={styles.input}
            testID="Input"
          />
        </View>
        <View style={styles.buttonWraper}>
          <TouchableOpacity
            accessible
            accessibilityLabel="入力された文字数をカウントする"
            accessibilityRole="button"
            onPress={handlePress}
            disabled={!formText?.length}
            testID="Button"
          >
            <Text>
              {formText?.length ? '文字数をカウントする' : '文字を入力してね'}
            </Text>
          </TouchableOpacity>
        </View>
      </KeyboardAvoidingView>
    </SafeAreaView>
  )
}

// StyleSheet は省略
```



次のテストを書きました。
文字入力時、未入力時のボタンのラベル（というか文字列）や、実際に文字を入力してボタンを押した時の操作を再現し、結果が正しく画面に表示されるかをチェックしています。

```typescript:e2e/App.e2e.ts
import { by, device, element, expect } from 'detox'

describe('E2E テストのテスト', () => {
  beforeEach(async () => {
    await device.reloadReactNative()
  })
  it('ラベルが「文字を入力してね」である', async () => {
    await expect(element(by.text('文字を入力してね'))).toBeVisible()
  })
  it('テキストを入力すると、「文字数をカウントする」に変わる', async () => {
    await element(by.id('Input')).replaceText('Hello Detox')
    await expect(element(by.text('文字数をカウントする'))).toBeVisible()
  })
  it('テキスト "Hello Detox" を入力してボタンを押すと、「文字数は11です。」が表示される', async () => {
    await element(by.id('Input')).replaceText('Hello Detox')
    await element(by.id('Button')).tap()
    await expect(element(by.text('文字数は11です。'))).toBeVisible()
  })
  it('テキスト "🍺" を入力すると、「文字数は1です。」が表示される', async () => {
    await element(by.id('Input')).replaceText('🍺')
    await element(by.id('Button')).tap()
    await expect(element(by.text('文字数は1です。'))).toBeVisible()
  })
})
```

まずはローカルでテストを走らせてみます。
`e2e/App.e2e.ts` で設定した全てのテストが通りました。
![E2EテストをエミュレーターのAndroidで動かしている動画です。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/f0e32544-c216-50b1-c0c4-591dd59a7887.gif)
![テストをローカル環境で実行した時の画面です。33.567秒で、文字列のラベルを調べる全4つのテストが通っています。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/eeb3b978-9b8e-c11e-2bc9-436afd06e868.png)

## CI で Detox の Android テストを走らせる
Detox を CI プラットフォームで走らせること自体は簡単で、`detox build` してから `detox test` するだけです。

Android で Detox のテストする場合は、CI の選定が必要になると思います。
エミュレーターを高パフォーマンスで動かすには、VM アクセラレーションが必須なためです。
Linux の KVM か macOS の HAXM いずれかをサポートすることが、今回の条件になります。

この条件に当てはまったのが GitHub Actions と Bitrise でした。

### GitHub Actions
GitHub Actions には HAXM をインストールする [macOS イメージ](https://github.com/actions/virtual-environments/blob/main/images/macos/macos-11.0-Readme.md) があるので、macOS イメージであれば問題なくテストできます。
当初はおまけ程度に触れる予定でしたが、Bitrise より速いため、こちらも紹介することにしました。

GitHub Actions は無料枠だと 月 2,000分 までのビルド制限、20 までの並列稼働, 500MB までのストレージ制限があります。
ビルド時間は macOS イメージですと 10 倍の補正がかかるため、実質月 200 分です。他のワークフローも合わせるとだいぶカツカツになります。
[参考: GitHub Actionsの支払いについて - GitHub Docs](https://docs.github.com/ja/free-pro-team@latest/github/setting-up-and-managing-billing-and-payments-on-github/about-billing-for-github-actions)

### Bitrise
[Bitrise](https://www.bitrise.io/) は iOS, Android アプリ向けの CI/CD プラットフォームです。
iOS のデプロイフローに特化していたり、デフォルトで macOS 環境が導入されていたり、モバイルアプリに優しいです。
一応 KVM をサポートしているのも特徴です。

Bitrise の無料枠である Hobby Plan は、月 200 回までのビルド制限、並列稼働不可、30 分でタイムアウトなど、それはそうとは言えども厳しい制限があります。
Bitrise のビルドは遅く、 E2E 用途の場合ですと並列ありきな構成が必要となるため、有料プランでないと使い物になりません。
プロジェクトが 1 つで、テストを 30 分以内に終わる 5〜6 ワークフローに区切って、ちまちまテストするようスケジューリングするならタダですが… Android テストのためだけにそれをやるのは無理があります。

## 実際の手順
### 0. 共通の準備
#### 0-1. Detox の設定
まず、プロジェクト側で Detox の設定を追加します。
具体的には package.json または .detoxrc.json の `configurations` に、下記のプロファイルを追加します。

```json:.detoxrc.json
{
  "configurations": {
    "android.emu.ci": {
      "binaryPath": "android/app/build/outputs/apk/release/app-release.apk",
      "build": "cd android && ./gradlew -q assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..",
      "type": "android.attached",
      "device": {
        "avdName": "emulator-5554"
      }
    },
  },
  "test-runner": "jest"
}
```

`"type": "android.attached"` により、デバイス名を直接指定することなく、 接続された Android 端末をテストに使うことができます。
また、端末名（`"avdName"`）は、エミュレーターに使用する `"emulator-5554"` に設定しておきました。

#### 0-2. detox test のコマンド
今回使用する `detox test` のコマンドは下記です。

```shell:shell
detox test -c android.emu.ci -u -H -R 1 -d 5000 -a artifacts/detox/ --take-screenshots all --record-logs all
```

`-c android.emu.ci` は `.detoxrc.json` の設定でテストする必須オプションです。
`-u` は、テスト終了時に Detox を念のためクリーンアップするオプションです。
`-H` は Android 限定で動作するヘッドレスモードです（そもそも `no-window` で動くからいいんですが…）。
`-R 1` を設定しているので、仮にテストに失敗してしまった場合、 [Jest Circus](https://github.com/facebook/jest/tree/master/packages/jest-circus) の `retryTimes` を用いて、もう 1 回テストを行います。テストが落ちた時の応急措置ですが、全部やり直してしまうのが難点です。
`-d 5000` は、Detox の項目で説明した同期（待機）処理に 5000ms かかったらステータスを出力する処理です。
`-a artifacts/detox/` は Artifact の保存パスです。CI ごとに異なります。
`--take-screenshots all` で、各テストの実行前/実行後のスクリーンショットを Artifact として保存します。
`--record-logs all` で、全てのログを Artifact として書き出します。

これで準備完了です。

### 1. GitHub Actions でやる場合

#### 1-1. エミュレーター立ち上げスクリプトの作成
まず、Android エミュレーターをインストールして立ち上げるシェルスクリプトを用意します。
このスクリプトは [Azure Pipelines | Microsoft Docs](https://docs.microsoft.com/en-us/azure/devops/pipelines/ecosystems/android?view=azure-devops#test-on-the-android-emulator) のものです。

```sh:scripts/runAndroid.sh
#!/usr/bin/env bash

# スクリプトは Azure ドキュメントのサンプルを採用しています。
# https://docs.microsoft.com/en-us/azure/devops/pipelines/ecosystems/android?view=azure-devops#test-on-the-android-emulator

echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --install 'system-images;android-27;google_apis;x86'

$ANDROID_HOME/platform-tools/adb devices

echo "no" | $ANDROID_HOME/tools/bin/avdmanager create avd -n test_android_emulator -k 'system-images;android-27;google_apis;x86' --force

nohup $ANDROID_HOME/emulator/emulator -avd test_android_emulator -no-boot-anim -no-window -no-audio -no-snapshot > /dev/null 2>&1 &
$ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do sleep 1; done; input keyevent 82' 

echo "Emulator started"
```

補足として、エミュレーターの立ち上がりまでにはラグがあるので、`sys.boot_completed` が 1 になるまで待機させています。

```shell:
$ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do sleep 1; done; input keyevent 82' 
```

#### 1-2. テスト用スクリプトの準備
`package.json` にまとめました。

```json:package.json
  "scripts": {
    "android:setup": "sh ./scripts/runAndroid.sh",
    "build:android:ci": "detox build -c android.emu.ci",
    "test:android:ci": "detox test -c android.emu.ci -u -H -R 1 -d 5000 -a artifacts/detox/ --take-screenshots all --record-logs all"
  },
```

#### 1-3. ワークフローを書いて実行
ワークフローを書きます。

```yml:.github/workflows/e2eAndroid.yml
name: e2eAndroid

on:
  workflow_dispatch:

jobs:
  test:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v2

      - uses: actions/setup-node@v1
        with:
          node-version: '12'

      - name: Get yarn cache directory path
        id: yarn-cache-dir-path
        run: echo "::set-output name=dir::$(yarn cache dir)"

      - uses: actions/cache@v2
        id: yarn-cache
        with:
          path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
          key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-yarn-

      - name: Install Modules
        run: yarn

      - name: Build for Detox
        run: yarn build:android:ci

      - name: Setup Android Emulator
        run: yarn android:setup

      - name: Test on Detox
        run: yarn test:android:ci
```

実行した結果です。
npm パッケージのキャッシュなしでも 8 分 17 秒で終わりました。キャッシュ込みで 7 分 42 秒です。
とは言っても macOS イメージで 10 倍補正があるので実質 70〜80 分です。
![スクリーンショット 2020-12-13 20.00.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/a5258b9a-54dc-aceb-a180-f5c245f21f88.png)

### 2. Bitrise でやる場合
一応 iOS で走らせる方法が[公式ドキュメント](https://devcenter.bitrise.io/jp/testing/running-detox-tests-on-bitrise/)に載っています。
（よくみたら情報が古いので、iOS の設定は[Detox の公式ドキュメント](https://github.com/wix/Detox/blob/master/docs/Introduction.Ios.md)を参考にした方がよさそうです）

#### 2-1. Bitrise ワークフローの作成

こんな感じです。

```yml:bitrise.yml
workflows:
  android-e2e:
    steps:
    - activate-ssh-key@4:
        run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}'
    - git-clone@4: {}
    - cache-pull@2:
        title: Pull Cache
    - yarn@0:
        title: yarn install
        inputs:
        - command: install
    - yarn@0:
        title: Detox Build
        inputs:
        - command: detox build -c android.emu.ci
    - avd-manager@1:
        title: Setup Android Emulator
        inputs:
        - emulator_id: emulator-5554
    - wait-for-android-emulator@1:
        inputs:
        - boot_timeout: '3000'
    - yarn@0:
        title: Detox Test
        is_always_run: true
        inputs:
        - verbose_log: 'yes'
        - command: detox test -c android.emu.ci -u -H -R 1 -d 5000 -a $BITRISE_DEPLOY_DIR/detox/
            --take-screenshots all --record-logs all
    - deploy-to-bitrise-io@1:
        inputs:
        - is_compress: 'true'
    - cache-push@2:
        title: Push Cache
        inputs:
        - cache_paths: |-
            $BITRISE_CACHE_DIR
            node_modules -> yarn.lock
        is_always_run: true
```

上から順番に説明します。

#### 2-2. インストールからビルドまで
Git から Clone して、 Detox 用ビルドの生成までやります。

```yml:bitrise.yml
  - activate-ssh-key@4:
    run_if: '{{getenv "SSH_RSA_PRIVATE_KEY" | ne ""}}'
  - git-clone@4: {}
  - cache-pull@2:
      title: Pull Cache
  - yarn@0:
    inputs:
    - command: install
    title: yarn install
  - yarn@0:
    inputs:
    - command: detox build -c android.emu.ci
    title: Detox Build
```

#### 2-3. インストールからビルドまで
次に、エミュレーターを設定します。
スクリプトを走らせればいいのですが、それでは Bitrise であることの恩恵を受けづらいので `avd-manager` というステップに頼ります。
これでエミュレーターのインストールから立ち上げまでやってくれます。
`no-window` などの E2E 向けオプションも設定してくれます。[^3]

```yml:bitrise.yml
  - avd-manager@1:
    inputs:
    - emulator_id: emulator-5554
    title: Setup Android Emulator
```

`avd-manager` には、エミュレーターの立ち上がりを待機するための処理がありません。
そこで `wait-for-android-emulator` というステップを後ろに置いて待機させています。
ちなみに `boot_timeout` に設定するのはミリ秒ではなく秒です。

```yml:bitrise.yml
  - wait-for-android-emulator@1:
    inputs:
    - boot_timeout: '3000'
```

#### 2-4. detox test の実行
`detox test` を実行します。

```yml:bitrise.yml
  - yarn@0:
    inputs:
      - command:
        detox test -c android.emu.ci -u -H -R 1 -d 5000 -a $BITRISE_DEPLOY_DIR/detox/
        --take-screenshots all --record-logs all
    title: Detox Test
```

#### 2-5. Artifact のデプロイ
Detox は Artifacts をテストケースごとに分割するのですが、Bitrise は子階層のファイル一式を見せてくれません。
今回の yml のように `is_compress: 'true'` を設定すれば、子階層を含めて zip 圧縮したものを見せてくれます。

```yml:bitrise.yml
  - deploy-to-bitrise-io@1:
    inputs:
      - is_compress: 'true'
```

#### 2-6. 実際に走らせる
Bitrise は CLI で走らせることもできます。

```shell:shell
brew update && brew install bitrise
bitrise run android-e2e
```

が、ここまで来たらせっかくなので Bitrise 上でビルドして確認してみます。
テストに通りました。約13分かかりました。npm パッケージのキャッシュ込みで 11 分でした。
![BitriseのCIで、エミュレーターを立ち上げてから操作のテストを行うまでの手順を全て終えた画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/6672af0e-7b04-ca88-4f9a-9261df289bc9.png)

Artifacts です。ログやスクリーンショットが入っています。
（フォルダは名前順になるので、各テストケースにはちゃんと番号を振りましょうというお話）
![Artifactsをまとめたzipファイルの中身です。テストケースごとにログと、テスト前後の画像ファイルが入っています](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/3f207d9f-9430-016e-bc19-3401c6799301.png)

テスト前/テスト後でそれぞれスクリーンショットが確認できます。
![テスト後の状態を撮影した画像です。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/8b2f4a5f-f09c-3696-1018-730d6d30fed9.png)


### (おまけ) AWS Device Farm でいけるか
Detox は Android の実機テストができるので、クラウド経由で実機テストができるサービスにも投げられれば、嬉しいです。
このようなサービスとしては、Firebase Test Lab や AWS Device Farm などがあります。
[BitBar](https://bitbar.com/) というオートメーションサービスは Detox をサポートする記述がありますが、ドキュメントがなく、サンプルコードも古いため見送りました。
今回は AWS Device Farm を試してみます。

AWS Device Farm は Detox をサポートしていませんが、 "Appium Node.js" という Appium 向けのワークフローがあり、そこでは Node + nvm が導入されたマシン上で、任意のスクリプトを走らせられます。
つまり Device Farm 向けにパッケージをバンドル & 圧縮したファイルを用意して、`detox build` コマンドを実行すれば、自分を Appium のテストだと思い込んでいる Detox のテストができるのだと思います。

結果としては、Device Farm で起動はできたのですが、 Detox 側で Attached の端末が認識されず、タイムアウトしてしまいました。
とりあえず、設定次第ではテスト可能なことがわかったので、宿題にします。
![AWS Device Farm で Detox を動かそうとした画面です。アプリは立ち上がったのですが、テスト側が認識できずタイムアウトしました。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/324105/bd447523-307e-5e04-39ae-f43873ac290d.png)

## 感想
無事（？）に CI で Detox を使った Android の E2E テストを実行できました。
GitHub Actions は料金、Bitrise はリソース管理が懸念ですが、いざとなったらクラウド実機テストにも逃げられそうですし、選択肢は色々とありそうです。

CI 的には、エミュレーターの snapshot もキャッシュできればいいのかな？と思いました。[Detox のドキュメントにも](https://github.com/wix/Detox/blob/master/docs/Introduction.AndroidDevEnv.md#emulator-quick-boot)記載されてます。
ただ Bitrise の `avd-manager` が snapshot を適用しない設定だったり、 Cold Boot しないと挙動が不安定になってしまうことがあったりして、コストに見合わない気もしています…。

[^1]: グレーボックステストとは、内部構造を把握しながら行うブラックボックステストを指します。
[^2]: 「待機する」というのは欠点でもあり、アニメーションが無限にループすると終わったりします。iOS では同期処理を切れるのですが、Android は Espresso の仕様で完全に切れないそうです https://github.com/wix/Detox/blob/master/docs/More.AndroidSupportStatus.md#differences-between-ios-and-android
[^3]: `"-show-kernel" "-no-audio" "-no-window" "-no-boot-anim" "-netdelay" "none" "-no-snapshot" "-wipe-data" "-gpu" "swiftshader_indirect" "-camera-back" "none" "-camera-front" "none"` がデフォルトです