皆さんはアプリのテスト自動化をしたことがありますか?
おそらく手元で手動テストをし、Excelか何かにまとめられたテスト仕様書にチェックを入れている人もいるのではないでしょうか。
しかし、自動化しようとしても、**どこまで自動化ができるのだろうか。。。**だとか、**こんな表現をテストで実装は出来ないよね?**だとか、**時間かかりそうだし、今はそういうフェーズじゃない。**だとか...そう思っている人は結構いるのではないかと私は思っています。
そんな人たちに、意外とアプリのテスト自動化はイケるぞ!ってのを伝えるために、本記事にて、AppiumとJestを組み合わせた自動テストを紹介します。
皆さんの手元で簡単にセットアップできるので、是非お試しください!
なお、今回紹介する内容のソースコードは以下のリポジトリから参照できますので、是非ご利用ください!
https://github.com/minakawa-daiki/AppiumJestSample
目標
今回の記事では、以下が出来ることをゴールにしていきたいと思います。
- ページがしっかりと表示されている
- テキストを自動入力できる
- ボタンが押せる
- スワイプができる
- ブラウザアプリを開いて、テスト対象のアプリに戻ってくる
- スクリーンショットを保存してみる
- 一連のテストフローを動画として保存する
Appiumについて
公式サイト: https://appium.io/
公式サイトの言葉を借りて、Google翻訳すると
Appiumは、ネイティブ、 ハイブリッド、およびモバイルWebアプリで利用可能なオープンソーステスト自動化フレームワークです。
WebDriverプロトコルを使用してiOS、Android、およびWindowsアプリで動きます。
要するにアプリのテストを自動化できます。使う前は、どこまで自動化できるのだろうか?と疑問でしたが、かなり自動化できる印象です。様々な言語に対応しているので、とても使いやすいです。
また、GUIのデバッグツールなども用意されているので、とても開発者に優しい、素晴らしいプロダクトだと思います。
画像は 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バージョンのシミュレーターをまずはインストールします。
インストールした後、Simulatorの一覧に存在する端末であれば、その端末を利用してテストが実行されます。
存在しない端末だと毎回新しいSimulatorが作られてしまうので、テストが落ちる原因になるので少し注意が必要です。
今回の例だと、iPhone11 Pro MaxのiOS 13.2という感じです。
Android Emulatorの準備
AndroidのEmulatorも以下の手順に従って準備していきます。
- Android Studioで適当なサンプルアプリを開いてAVDマネージャを開きます
- Create Virtual Deviceからエミュレータを追加します
- 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
今回のサンプルアプリの概要
今回使用するサンプルアプリは以下のようなページを持っています。コードの内部実装は詳しく説明しませんが、ソースコードは公開しているので、気になる方はご覧ください。
- 1ページ目はテキストを入力する画面があります
- 2ページ目は1ページ目で入力されたテキストが表示されます。ボタンを押すことで内容が変わります
- 3ページ目は画像が配置されたWebViewで、画像をクリックするとSafariに遷移します
- 4ページ目は空のページです
- 5ページ目は動画が配置されたWebViewで、動画をクリックするとSafariに遷移します
アプリを取得する
GitHubにてapkとappを公開してますが、自分でビルドしたい場合はexpo経由で以下のコマンドを実行してください。
自分でビルドする場合
- iOSの場合は
yarn build:ios
- Androidの場合は
yarn build:android
直接ダウンロードする場合
また、生成されたアプリは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では以下のような設定になります。
{
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はこんな感じです。
{
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
は並列化するときに違うポート番号を指定します
また、実機を接続してる場合は、以下のような設定で動かせます。
{
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
にテスト途中のスクショや全体の動画が以下のような感じで保存されていると思います。
Appiumのスタイルはjestなどのテストフレームワークで表示チェックを担保するのと、スクリーンショットや動画なのでアプリの全体的な動きのテストを担保することで自動化していくのが良いと思っています。
無理に全てテストコードで担保しようとせず、スクリーンショットや動画も積極的に使っていきましょう。
テストコードの説明
まず、ReactNativeとNativeで書かれたコードのテストコードは結構違う感じになることを念頭に置いておいてください。
また、今回はほんの一部しか機能を紹介しないので興味を持った方はドキュメントを見てみると良いでしょう。
ドキュメントの例: http://appium.io/docs/en/commands/element/find-element/
それに加え、テストコードを書くときにはアプリ内のそれぞれのElementに対してaccessibilityLabel
を振っておくと要素を特定しやすくなるのでオススメです。
動画を撮影する
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ページ目が存在している」かどうかのテスト
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ページ目で表示されている」かどうかのテスト
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ページにて、文字を入力し、それが2ページ目で入力された内容が表示されているかどうかのテストを行っています。
textInputElement.addValue('おりばー')
とすることで、textInputElement
に対して「おりばー」という文字が入力されます。
また、browser.hideKeyboard()
でキーボードを閉じることができるのも意外と盲点だったりします。
swipe('left')
はスワイプを行なっています。iOSとAndroidでスワイプの仕方が様々あるので、気になる方はコードを読んでみてください。
browser.$(
~${inputText})
ここの部分はコメントにも書いているのですが、iOSの特定のバージョンにおいて、getText()
がaccessibilityLabel
で指定した内容になってしまうというバグが存在しています。
追記: Xcodeのバージョンも大きく関係しているらしいです。詳しくはコメント欄をご覧ください。
ですので、その応急処置として、accessibilityLabel
にそのまま値を入れるという実装になってしまっています。
「2ページ目のボタンをタップすると内容が変化する」かどうかのテスト
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ページ目が存在している」かどうかのテスト
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ページ目が存在している」かどうかのテスト
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に触れてみてください。