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
React Native アプリの代表的な E2E フレームワークとしては、他に Appium が挙げられます。
Appium は Selenium のモバイル版です。ビルド済みのアプリに対して WebDriver の Wrapper に依存したテストを行い、アプリの挙動や負荷には関心を持ちません。
Detox は React Native のアプリ用に開発されていて、ネイティブなテストフレームワークである EarlGrey(iOS) と Espresso(Android) に依存したテストを行います。
そして、端末のアイドル状態や通信処理を外側から監視するため、不確定な待機時間をある程度コントロールしながらテストを実行してくれます。
「アプリの状態を把握しながらテストする」という意味で、グレーボックステスト1 2を意識しています。
今回はその Detox のテストを CI で動かしたい、という話です。
環境
- Detox 17.14.3
- React Native 0.63
- Node 12
ライセンス
Detox は MIT, EarlGrey は Apache License 2.0 です。
サンプルリポジトリのコード、設定、テスト内容は MIT です。
テストを書いてみる
環境導入などについて触れると、長くなってしまうので公式ドキュメントを参考にしてください。
サンプルリポジトリを作ったので、そちらを使って説明します。
Android の環境導入はこちらの Commit でやっています。
今回のテスト対象は、下記のコンポーネントです。
中身は深く触れませんが、「テキストを入力してボタンを押したら、そのテキストと文字数が表示される」というものです。
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 は省略
次のテストを書きました。
文字入力時、未入力時のボタンのラベル(というか文字列)や、実際に文字を入力してボタンを押した時の操作を再現し、結果が正しく画面に表示されるかをチェックしています。
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
で設定した全てのテストが通りました。
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 イメージ があるので、macOS イメージであれば問題なくテストできます。
当初はおまけ程度に触れる予定でしたが、Bitrise より速いため、こちらも紹介することにしました。
GitHub Actions は無料枠だと 月 2,000分 までのビルド制限、20 までの並列稼働, 500MB までのストレージ制限があります。
ビルド時間は macOS イメージですと 10 倍の補正がかかるため、実質月 200 分です。他のワークフローも合わせるとだいぶカツカツになります。
参考: GitHub Actionsの支払いについて - GitHub Docs
Bitrise
Bitrise は 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
に、下記のプロファイルを追加します。
{
"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
のコマンドは下記です。
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 の 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 のものです。
#!/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 になるまで待機させています。
$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
にまとめました。
"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. ワークフローを書いて実行
ワークフローを書きます。
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 分です。
2. Bitrise でやる場合
一応 iOS で走らせる方法が公式ドキュメントに載っています。
(よくみたら情報が古いので、iOS の設定はDetox の公式ドキュメントを参考にした方がよさそうです)
2-1. Bitrise ワークフローの作成
こんな感じです。
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 用ビルドの生成までやります。
- 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
- avd-manager@1:
inputs:
- emulator_id: emulator-5554
title: Setup Android Emulator
avd-manager
には、エミュレーターの立ち上がりを待機するための処理がありません。
そこで wait-for-android-emulator
というステップを後ろに置いて待機させています。
ちなみに boot_timeout
に設定するのはミリ秒ではなく秒です。
- wait-for-android-emulator@1:
inputs:
- boot_timeout: '3000'
2-4. detox test の実行
detox test
を実行します。
- 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 圧縮したものを見せてくれます。
- deploy-to-bitrise-io@1:
inputs:
- is_compress: 'true'
2-6. 実際に走らせる
Bitrise は CLI で走らせることもできます。
brew update && brew install bitrise
bitrise run android-e2e
が、ここまで来たらせっかくなので Bitrise 上でビルドして確認してみます。
テストに通りました。約13分かかりました。npm パッケージのキャッシュ込みで 11 分でした。
Artifacts です。ログやスクリーンショットが入っています。
(フォルダは名前順になるので、各テストケースにはちゃんと番号を振りましょうというお話)
テスト前/テスト後でそれぞれスクリーンショットが確認できます。
(おまけ) AWS Device Farm でいけるか
Detox は Android の実機テストができるので、クラウド経由で実機テストができるサービスにも投げられれば、嬉しいです。
このようなサービスとしては、Firebase Test Lab や AWS Device Farm などがあります。
BitBar というオートメーションサービスは Detox をサポートする記述がありますが、ドキュメントがなく、サンプルコードも古いため見送りました。
今回は AWS Device Farm を試してみます。
AWS Device Farm は Detox をサポートしていませんが、 "Appium Node.js" という Appium 向けのワークフローがあり、そこでは Node + nvm が導入されたマシン上で、任意のスクリプトを走らせられます。
つまり Device Farm 向けにパッケージをバンドル & 圧縮したファイルを用意して、detox build
コマンドを実行すれば、自分を Appium のテストだと思い込んでいる Detox のテストができるのだと思います。
結果としては、Device Farm で起動はできたのですが、 Detox 側で Attached の端末が認識されず、タイムアウトしてしまいました。
とりあえず、設定次第ではテスト可能なことがわかったので、宿題にします。
感想
無事(?)に CI で Detox を使った Android の E2E テストを実行できました。
GitHub Actions は料金、Bitrise はリソース管理が懸念ですが、いざとなったらクラウド実機テストにも逃げられそうですし、選択肢は色々とありそうです。
CI 的には、エミュレーターの snapshot もキャッシュできればいいのかな?と思いました。Detox のドキュメントにも記載されてます。
ただ Bitrise の avd-manager
が snapshot を適用しない設定だったり、 Cold Boot しないと挙動が不安定になってしまうことがあったりして、コストに見合わない気もしています…。
-
グレーボックステストとは、内部構造を把握しながら行うブラックボックステストを指します。 ↩
-
「待機する」というのは欠点でもあり、アニメーションが無限にループすると終わったりします。iOS では同期処理を切れるのですが、Android は Espresso の仕様で完全に切れないそうです https://github.com/wix/Detox/blob/master/docs/More.AndroidSupportStatus.md#differences-between-ios-and-android ↩
-
"-show-kernel" "-no-audio" "-no-window" "-no-boot-anim" "-netdelay" "none" "-no-snapshot" "-wipe-data" "-gpu" "swiftshader_indirect" "-camera-back" "none" "-camera-front" "none"
がデフォルトです ↩