はじめに
「Appium+CucumberでiOSの自動UIテストを日本語で書く(環境構築編)」で「Appium + Ruby クライアント + Cucumber」 という構成を試しました。
Cucumber には JavaScript 版もあるようなので、そちらを使えば Ruby 環境を用意する手間が省けるはずです。
というわけで、今回は「Appium + JS クライアント + JS 版 Cucumber」の環境構築を試みました。Appium は Android でも利用できますが、ここでは iOS ターゲットで Mac 上にて作業します。
Node.js環境の準備
Appium を動かすための Node.js を Homebrew 1を使ってインストールします。Homebrew はインストール済みの想定です。
もし複数バージョンの node.js を管理したい場合や、指定バージョンの node.js をインストールしたい場合は、node ではなく nodebrew をインストールしてそちらで管理してください。
$ brew update
$ brew install node
Appiumをインストール
公式の手順では -g を付けてグローバルインストールしています。それに倣います。
$ npm install -g appium
実機にアプリをインストールするのに ideviceinstaller というツールが使われます。これを Homebrew でインストールしておきます。
$ brew install ideviceinstaller
私の環境 (El Capitan) では ideviceinstaller が権限の問題で sudo 付けないと動作しない問題が発生しました。こちらを参考に解決しました。
$ ideviceinstaller -l
Could not connect to lockdownd. Exiting.
$ sudo ideviceinstaller -l
Password:
Total: 7 apps
(略)
ご覧の通り、アプリをインストールするツールである ideviceinstaller が sudo を付けないと動作しなくなっています。
$ sudo chmod -R 777 /var/db/lockdown
これで解決しました。これ Mac を再起動したり?何かのタイミングで元に戻ってしまうようで、たまに実行しないといけません。最新版では直ってるみたいな記述もありましたが、記載通りにインストールしなおしてもやはり発生します。
AppiumのJSクライアントをインストール
各プロジェクトごとに別々のバージョン管理をすることにします。-g を付けずに npm install すれば実行時のディレクトリ下の node_modules にインストールされると思ってましたが、~/node_modules にインストールされてしまいました2。package.json がないとダメなのかもしれません。以下を package.json に記述します(内容はどうでもいい)。
{
"name": "myapp",
"version": "1.0.0",
"description": ""
}
依存項目を書いてから npm install を実行してもいいのですが、--save を付けて package.json に追記してもらう方法が楽です。--save は実行時に必要な依存関係、--save-dev は開発時に必要な依存関係ですが、今回は単にローカルにインストールしたいだけなのでどっちでもいいです。
$ npm install --save wd
これでローカルの node_modules にインストールされました。package.json にも以下が追記されます。
"dependencies": {
"wd": "^1.0.0"
}
試しに動かしてみる
Appium は Apple が提供している UIAutomation を利用しているので、テストする端末の
設定 - デベロッパ - Enable UI Automation
を有効にしておいてください。
まずは Appium Server を起動します。停止するには Ctrl + C でいいです。
$ appium
別のコンソールを立ち上げます。
とりあえず動かしてみましょう。ファイル sample.js に以下のテストコードを記述します。deviceName は実デバイスの名前に、udid は実デバイスの UDID に、app はアプリの Bundle ID に置き換えてください。これらの調べ方は WKWebViewを使うアプリのUI自動テストにCalabash-iOSを使ってみた(環境構築編)- 実機でテストを実行する を参考にしてください。
"use strict";
var wd = require("wd");
var desired_caps = {
platformName: 'iOS',
deviceName: 'username の iPhone',
udid: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
app: 'com.example.MyApp'
};
var driver = wd.promiseChainRemote("0.0.0.0", 4723);
driver
.init(desired_caps)
.sleep(5000)
.elementById("Menu").click()
.sleep(5000)
.quit();
テストコードのサンプルが Node.js samples にあるんですが、もっと単純かつ実機動作するものにしてみました。
アプリは事前にインストールしておきます。または app の部分にアーカイブしたアプリへのパスを指定しておくと、インストールされていなければインストールしてくれます。
ここでは 'Menu' というID(accessibilityIdentifier に 'Menu' と設定してあります)のボタンをクリックしていますが、適当に他のクリックできるものに置き換えるか、コメントアウトするかしてください。利用できるコマンドについては API を参照してください。
さて実行します。
$ node sample.js
うまくいきました!
イベントループとPromiseの話
ここの話は Node.js の仕組みや Promise が何か分かっている人は飛ばして構いません。
Node.js はシングルスレッドでイベントループによるマルチタスク管理を行っています。裏側で色々なタスクが動いているときに、時間が掛かるタスクを実行してしまうと、シングルスレッドなのでその間他の処理をロックしてしまいます。そのため wd の提供する各コマンドは、1つ1つバラバラのタスクに分解してイベントループに登録して非同期実行する仕組みになっています。
先ほどのテストステップはメソッドチェーンで繋げていましたが、あれを以下のようにバラして記述することはできません。これだと前のコマンドが終了する前に次のコマンドが開始してしまいます。
driver.init(desired_caps)
driver.sleep(5000)
driver.elementById("Menu").click()
driver.sleep(5000)
driver.quit();
バラすなら以下のように記述する必要があります。メソッドチェーンはこれと同等のことを楽に書く方法です。
driver.init(desired_caps, function() {
driver.sleep(5000, function() {
driver.elementById("Menu").click(function() {
driver.sleep(5000, function() {
driver.quit();
});
});
});
});
詳しくは JavaScript の Promise について調べてみてください。
cucumber-jsをインストール
以下で cucumber-js をインストールします。
$ npm install --save cucumber
初期化コマンドとかないみたいなので、下準備をします。以下の構成のディレクトリとファイルを用意してください。
features
├── sample.feature
├── step_definitions
│ └── appium_steps.js
└── support
├── hooks.js
└── world.js
feature ファイルには以下のように記述しておきます。書き方は Cucumber のフィーチャの文法 - Gherkin が参考になります。
# language: ja
フィーチャ: 起動
シナリオ: 起動してメイン画面が表示される
前提 アプリが実行中
ならば "./screenshots/test.png"というファイル名でスクリーンショットを撮る
スクリーンショットの保存先ディレクトリを作成しておきます。
$ mkdir screenshots
Cucumber-JS の World の説明 を参考に world.js を記述します。
"use strict";
var wd = require("wd");
function MyWorld() {
this.driver = wd.promiseChainRemote("0.0.0.0", 4723);
}
module.exports = function() {
// ここでWorldに入れたものは、他のhooksやsteps内でthisでアクセスできる
this.World = MyWorld;
};
Cucumber-JS の Hooks の説明 を参考にシナリオ実行前、実行後のフック処理を記述します。シナリオ実行前にアプリを起動し、実行後にアプリを終了しています。
"use strict";
var myHooks = function() {
// timeoutはデフォルトで5000msだが、それだとアプリ起動でタイムアウトした。
this.Before({ timeout: 10 * 1000 }, function(senario, callback) {
var desired_caps = {
platformName: 'iOS',
deviceName: 'developer01 の iPhone',
udid: 'c3be1870049a53aed8272e12c659d75165e3f249',
app: 'jp.co.mapmaster.Navista'
};
this.driver.init(desired_caps, callback);
});
this.After(function(senario, callback) {
this.driver.quit(callback);
});
};
module.exports = myHooks;
デフォルトのままだとアプリ起動時にタイムアウトしてしまったため、Cucumber-JS の Timeouts の説明 を参考に、タイムタウトを延長して指定しています。
ステップ定義ファイルは以下のように記述します。正規表現で自然言語で書ける関数を定義している感じになります。 ( ) にマッチした部分が関数の引数に渡されます。引数に渡したくない場合は (?:) で記述します。
"use strict";
module.exports = function() {
this.Then(/^アプリ(?:が|を)?実行中$/, function(callback) {
// 特にすることない
callback();
});
// 注: スクリーンショット取得に時間がかかるのでタイムアウト延長
this.Then(/^"([^\"]*)"というファイル名でスクリーンショットを撮る$/,
{ timeout: 15 * 1000 }, function(filename, callback) {
this.driver.saveScreenshot(filename, callback);
});
};
コマンドラインの利用方法の説明 を参考に実行してみます。
$ ./node_modules/.bin/cucumberjs
動作しました!
まとめ
Ruby を用意せずに Node.js だけで済むように、Cucumber-JS を使って日本語でテストを書ける環境を構築してみました。
結構ハマりました。シナリオ実行前のタイムアウトはそれと分かるメッセージが Appium サーバー側のログに表示されていて気付きましたが、スクリーンショット撮る時は
[MJSONWP] Encountered internal error running command: TypeError: Cannot read property 'sendCommand' of null
というエラーが発生していて、何のことだか分かりませんでした。このエラーでググっても appium-ios-driver 1.11.0 で直ってるって書いてあるし・・・。結局スクリーンショット撮るのに時間がかかるので、タイムアウトした結果、オブジェクトが解放されて null になっちゃってたってことっぽいです。うまくいった時には以下のログが出ていました。
[debug] [iOS] Waiting 10000 ms for screenshot to be generated.
Promise があるとはいえ、はっきり言って非同期で書くの面倒くさいですし、慣れてないとトラブルの元になります。Ruby 版の方が楽に直感的に書けるので、JavaScript に慣れてないなら Cucumber-JS を使うより Ruby 版の Cucumber を使う方がオススメかも。ステップを用意してしまえば後は日本語で書くので面倒なのは最初だけですが。
追記: 2016/11/10現在、wd の REAME に「モバイルはフルサポートできてないか、またはbuggyだ」と書いてあるのもあって、JavaScript 版の採用はやめました。