WKWebViewを使うアプリのUI自動テストにCalabash-iOSを使ってみた（色々実験編）


# はじめに

[環境準備編](http://qiita.com/k5n/items/f2ddc1f35f048fe90f3a), [日本語準備編](http://qiita.com/k5n/items/06d8d1b1056f23a2fe1e) の続きです。

今回は実際に Calabash を使ってテストを書いたり実行したりする際に、やりたいことができるかどうかの実験結果、ノウハウを書いていきます。

Calabash は Cucumber というテスティングフレームワークを使って iOS, Android の自動 UI テストを行うツールです。Cucumber 自体は Ruby on Rails でのテストなど Calabash に限らず広く使われいるツールです。なので Gherkin 言語によるテストの記述方法や、テストを実行する cucumber コマンドの使い方は、Cucumber の情報を検索すると多くの情報が見つかります。

bundle exec は シェルで be というエイリアスを設定している想定にします。利用した実機デバイスの iOS バージョンは 9.0.2 です。

# UIButton以外のボタンを押す場合

どうも UIButton 以外のボタン・・・例えばナビゲーションバーの「戻る」ボタンや、UITabBarController のタブ切り替えボタンなど・・・は、ボタンとは認識されていないようです。

なので、「"戻る"ボタンを押す」と書いてもエラーになってしまい、「"戻る"を押す」と記述する必要があります。

# featureファイルをサブディレクトリで分類

feature ファイルはサブディレクトリに格納してもちゃんと再帰的に探してくれます。ただし特定の feature ファイルだけを指定して実行する時は、

```
$ be cucumber -r features features/authentication/login.feature
```

のように -r features を付けないとステップファイルがどこにあるか認識できずに実行できないので注意です。

# Calabashコンソールを使う

[Public API](http://calabashapi.xamarin.com/ios/) にあるクラスを駆使すれば、色々とゴニョゴニョできるはず！試してみましょう。

```
$ be calabash-ios console
Running irb...

#########################  Useful Methods  ##########################
     ids => List all the visible accessibility ids.
  labels => List all the visible accessibility labels.
    text => List all the visible texts.
   marks => List all the visible marks.
    tree => The app's visible view hierarchy.
   flash => flash(<query>); Disco effect for views matching <query>
 verbose => Turn debug logging on.
   quiet => Turn debug logging off.
    copy => Copy console commands to clipboard.
   clear => Clear the console.

Calabash says, "Det ka æn jå væer ei jált"

calabash-ios 0.20.3>
````

ごめん。俺には君が何を言ってるのかわからないよ・・・。ノルウェー語？（これ毎回言うこと変わる）

```
calabash-ios 0.20.3> launcher = Calabash::Cucumber::Launcher.launcher
calabash-ios 0.20.3> launcher.relaunch()
```

おお！アプリが起動した！

起動時に表示される Useful Methods にある tree が気になりますね。

```
calabash-ios 0.20.3> tree
[UIWindow]
  [UILayoutContainerView]
    [UINavigationTransitionView]
      [UIViewControllerWrapperView]
      （以下略）
```

こんな感じでビュー階層が表示されます。

```
calabash-ios 0.20.3> calabash_exit
```

これでアプリ終了。アプリ起動・終了の使い方は features/support/01_launch.rb を参考にしました。calabash_exit が名前空間を指定しなくても動くってことは、Calabash::Cucumber::Core にあるものは名前空間を読み込み済みってことっぽい。あと tree などが所属してる Calabash::Cucumber::ConsoleHelpers も。ほとんどコマンドは名前空間を指定しなくても動作します。

これでビュー階層を確認しつつ、API を叩いて動かしてみることで、ステップファイルにどう書いたらいいのか確認できます。

WKWebView を使って HTML を表示している画面で tree を実行すると・・・

```
calabash-ios 0.20.3> tree
[UIWindow]
  [UILayoutContainerView]
    [UINavigationTransitionView]
      [UIViewControllerWrapperView]
        [WKWebView] [id:WebView]
          [dom:#document] [id:] [nodeType:DOCUMENT_NODE]
            [dom:HTML] [id:] [nodeType:ELEMENT_NODE]
              [dom:BODY] [id:] [nodeType:ELEMENT_NODE]
                [dom:DIV] [id:main-page] [nodeType:ELEMENT_NODE]
                  [dom:DIV] [id:] [nodeType:ELEMENT_NODE]
（以下略）
```

ちゃんと DOM を認識してくれています。テキスト入力ボックスに文字列を入力してみます。

```
calabash-ios 0.20.3> touch("WKWebView css: 'input'")
calabash-ios 0.20.3> keyboard_enter_text "0123"
calabash-ios 0.20.3> tap_keyboard_action_key
```

クエリの書式は [Calabash iOS版 Query-Language](https://github.com/calabash/calabash-ios/wiki/Query-Language) や [Calabash Query Syntax - Xamarin](https://developer.xamarin.com/guides/testcloud/calabash/calabash-query-syntax/) が参考になります。が WKWebView の扱いに関しては Xamarin の方法はうまくいかず [Calabash iOS - 06 WebView Supoort](https://github.com/calabash/calabash-ios/wiki/06-WebView-Support) を参考にしました。

query 関数を使うと、合致したもののリストを表示してくれます。これを参考にさらに条件を絞り込めば、目的の要素を指定できるようになると思います。

```
calabash-ios 0.20.3> query("WKWebView css: 'input'")
```

以下のように試しに日本語を入力してみたんですが、エラーになってしまい、うまくいかなかったです。日本語の文字入力に関しては試行錯誤が必要そうな印象。

```
calabash-ios 0.20.3> keyboard_enter_text "名古屋駅"
```

# 日本語入力する

色々試してみた結果、どうも画面に表示されている文字以外は入力できないようです。つまりフリック入力できません。例えば「名古屋駅」を入力しようとして

```
calabash-ios 0.20.3> keyboard_enter_text  "なごやえき"
```

とやっても最初の "な" は入力できますが、次の "ご" でエラーになります。じゃあとばかりに

```
calabash-ios 0.20.3> keyboard_enter_text  "なかかかかか"
```

と打てば、「か」を５回タップして「こ」を入力してくれました。ちなみにこれをやるには iOS デバイスの「フリックのみ」は OFF にしておく必要があります。

濁点をどうやって入力したらいいんでしょう？ tree すると以下が濁点入力キーのようです。

```
[UIAccessibilityElementKBKey] [id:゛] [label:有声子音および母音キー]
```

色々試しましたが Calabash のクエリではタップできませんでした。UIAccessibilityElementKBKey は view 扱いじゃないみたいです。じゃあどうするかは後述します。

## ローマ字入力を使う

iOS の設定でローマ字入力できるようにキーボードを追加してみました。キーボードを切り替えてローマ字入力にしておきます。

```
calabash-ios 0.20.3> keyboard_enter_text  "nagoyaeki"
calabash-ios 0.20.3> touch("view marked: '名古屋駅'")
```

まずローマ字入力で「なごやえき」を入力し、候補の中から「名古屋駅」をタップします。入力できました！

あと問題は日本語、数字、アルファベットが混ざった文字列を入力できるのかどうかです。

```
calabash-ios 0.20.3> keyboard_enter_text '0123'
calabash-ios 0.20.3> keyboard_enter_text 'eki'
```

'0123' はちゃんと自動的に数字キーボードに切り替えて入力してくれました。アルファベットは・・・日本語ローマ字入力になります。そりゃこれだけの情報じゃ英字キーボード使ったらいいのかローマ字入力キーボード使ったらいいのか区別つかないですよね？ 

英単語であれば、日本語ローマ字入力した時の変換候補に出てくるので入力できます。

```
calabash-ios 0.20.3> keyboard_enter_text  "location"
calabash-ios 0.20.3> touch("view marked: 'Location'")
```

困るのは英単語以外を入力したい時、例えばログイン画面ではアルファベットと数字で入力、その他の画面で日本語入力したい場合などですね。

何とかキーボードを切り替えられないか色々試しました。tree で表示されるものを何とか touch できないか色々な方法で query してみましたが、Calabash の query では選択できないようです。Calabash は裏で Apple が提供している UIAutomation を使っています。それを uia 関数経由で直接叩くことができるみたいです。

```
calabash-ios 0.20.3> uia("UIATarget.localTarget().frontMostApp().keyboard().buttons()['次のキーボード'].tap()")
```

これでキーボードが切り替えられました。

UIAutomation の使い方に関しては  [UIAutomation JavaScript Reference 日本語版](https://gist.github.com/kuronekomichael/11032094) が参考になります。

あとは何のキーボードが表示されているか判定できれば、キーボード切り替え関数が作成できます。ローマ字入力と英字入力の差は "ー" があるかないかで判定できそうです。

とりあえず

```
calabash-ios 0.20.3> uia("UIATarget.localTarget().frontMostApp().keyboard().keys()")
```

で value の数が違います。英字だと 28 個、ローマ字入力だと 29 個です。これでもいけそうですが、 'ー' があるかどうかで判定できないか試してみます。

```
calabash-ios 0.20.3> uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['ー']")
{
    "status" => "success",
     "value" => {},
     "index" => 32
}
```

英字入力でも status は success になります。value の中身も空。うーん、どうやって存在確認するんだろう？ .tap() を付けるとタップできるんですが・・・。試しに .name()  を付けてみると・・・ローマ字入力と英字入力で value の値が変わりました。

```
calabash-ios 0.20.3> uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['ー'].name()")
{
    "status" => "success",
     "value" => "ー",
     "index" => 14
}
calabash-ios 0.20.3> uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['ー'].name()")
{
    "status" => "success",
     "value" => nil,
     "index" => 15
}
```

これでローマ字入力と英字入力を切り替える関数が作れます。

```ruby
def qwerty_keyboard?
  uia("UIATarget.localTarget().frontMostApp().keyboard().keys()[0].name()")["value"] == "q"
end

def romaji_keyboard?
  uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['ー'].name()")["value"] == "ー"
end

# romaji に true を渡すとローマ字入力、false を渡すと英字入力に切り替える
def change_keyboard_to_qwerty(romaji)
  until qwerty_keyboard? && !(romaji ^ romaji_keyboard?)
    uia("UIATarget.localTarget().frontMostApp().keyboard().buttons()['次のキーボード'].tap()")
  end
end
```

## ローマ字入力を使わない

なんとか濁点が入力できればデフォルトの日本語入力キーボードも使えるはずです。keyboard_enter_text に「あかさたなはまやらわ」のどれかの文字を渡せばキーボードの切り替えは自動でやってくれます。先ほどの uia 関数を使ってなんとか濁点を入力してみましょう。

```
uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['゛'].tap()")
```

これでいけました。素のまま使うとローマ字入力に比べて何をやっているのか分かりにくいです。

```ruby
def tap_voiced_sound_mark
  uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['゛'].tap()")
end

# 「名古屋駅」を入力する
keyboard_enter_text "なかかかかか"
tap_voiced_sound_mark
keyboard_enter_text "やああああかか"
touch("view marked: '名古屋駅'")
```

うわぁぁこれは書きたくない。何とか関数化してみましょう。

```ruby
HIRAGANA_WITHOUT_VOICED_SOUND_MARK = [
  "あいうえおぁぃぅぇぉ",
  "かきくけこ",
  "さしすせそ",
  "たちつてとっ",
  "なにぬねの",
  "はひふへほ",
  "まみむめも",
  "やゆよゃゅょ",
  "らりるれろ",
  "わをんー",
]

HIRAGANA_WITH_VOICED_SOUND_MARK = [
  "",
  "がぎぐげご",
  "ざじずぜぞ",
  "だぢづでど",
  "",
  "ばびぶべぼ",
]

HIRAGANA_WITH_SEMI_VOICED_SOUND_MARK = [
  "",
  "",
  "",
  "",
  "",
  "ぱぴぷぺぽ",
]

HIRAGANA_TABLES = [ HIRAGANA_WITHOUT_VOICED_SOUND_MARK,
                    HIRAGANA_WITH_VOICED_SOUND_MARK,
                    HIRAGANA_WITH_SEMI_VOICED_SOUND_MARK ]

def hiragana_indexes(kana)
  tap_voiced_sound_mark_count = 0  # 濁点入力キーの押下回数
  HIRAGANA_TABLES.each do |table|
    key_index = 0  # 「あかさたなはまやらわ」のどれを押すか
    table.each do |line|
      tap_count = line.index("#{kana}")
      if tap_count!= nil
        return [key_index, tap_count + 1, tap_voiced_sound_mark_count]
      end
      key_index += 1
    end
    tap_voiced_sound_mark_count += 1
  end

  [-1, -1, -1]  # 見つからなかった（平仮名じゃない）
end

def tap_voiced_sound_mark
  uia("UIATarget.localTarget().frontMostApp().keyboard().keys()['゛'].tap()")
end                    

def keyboard_enter_hiragana_text(text)
  text.each_char do |ch|
    key_index, tap_count, tap_voiced_sound_mark_count = hiragana_indexes(ch)
    raise "平仮名以外の文字を含んではいけません！" if key_index < 0
    key = HIRAGANA_WITHOUT_VOICED_SOUND_MARK[key_index][0]
    keyboard_enter_text tap_count.times.inject("") { |str, _| str += key }
    tap_voiced_sound_mark_count.times { tap_voiced_sound_mark }
  end
end

Then /^"([^"]*)"を"([^"]*)"に変換して入力する$/ do |input_text, target_text|
  keyboard_enter_hiragana_text input_text
  touch("view marked: '#{target_text}'")
end
```

これで以下のようにステップを記述できます。

```
  ならば "なごやえき"を"名古屋駅"に変換して入力する
```

ステップ定義以外の部分は features/support の下に入れておいた方が行儀が良いでしょう。japanse_keyboard.rb とか何とか適当なファイル名で。

この関数は入力がちょっと遅いのが欠点です。

 - "な", "かかかかか", 濁点, "や", "ああああ", "かか"

上記のように５つの文字列を入力するので、文字列と文字列の間で待ち時間が発生するためです。以下の入力になるように改造すればもう少し速くなるでしょう（面倒なのでとりあえずここまで）。

 - "なかかかかか", 濁点, "やああああかか"

# 名前を付けてスクリーンショットを保存

スクリーンショットを沢山撮ると後からどれがどれやら分からなくなります。ファイル名を付けて保存したいんです。それにデフォルトだとプロジェクトルートに大量のスクリーンショットファイルが出力されるし。

[screenshot 関数の含まれるソースコード](https://github.com/calabash/calabash-ios/blob/develop/calabash-cucumber/lib/calabash-cucumber/failure_helpers.rb) を見る限り、オプションを渡せば簡単に実現できそうです。

あらかじめプロジェクト直下に screenshots ディレクトリを作成しておきます。

```ruby
screenshot({:prefix => 'screenshots/', :name => 'test'})
```

これで screenshots/test_0.png に出力されました。環境変数 SCREENSHOT_PATH を設定しておくと、:prefix を省略した時にデフォルトで利用されるようです。私は UUID やら IP アドレスやらを設定するファイルに以下を追記しておきました（注：最後のスラッシュは必須）。

```bash
export SCREENSHOT_PATH="screenshots/"
```

あとはステップファイルに以下を追加します。

```ruby
Then /^"([^)]*)"という名前で(?:スクリーンショット|スクショ|画面キャプチャ)を撮る$/ do |name|
  sleep(STEP_PAUSE)
  screenshot({:name => name})
end
```

# テスト実行前に再インストールする

Android 版はシナリオ実行の度にアプリを再インストールするのがデフォルト動作なのですが、iOS 版はそうではないようです。初回インストール直後を想定しているシナリオもあるため、再インストールできるか確認してみました。

ideviceinstaller というツールを使うと iOS アプリのインストール、アンインストールが行えます。

## ideviceinstallerを用意する

Homebrew 経由で ideviceinstaller をインストールしておきます。

```
$ brew update
$ brew install ideviceinstaller
```

このコマンドを使うと、アプリのアンインストールやインストールを行うことができます。ただ、[El Captan にアップグレードすると問題が発生したという報告](https://github.com/libimobiledevice/ideviceinstaller/issues/48) があります。

```
$ ideviceinstaller -l
Could not connect to lockdownd. Exiting.
$ sudo ideviceinstaller -l
Password:
Total: 7 apps
（略）
```

このように sudo を付けないと実行できなくなっています。これだと自動テストするときに困ります。

```
$ sudo chmod -R 777 /var/db/lockdown
```

を実行することで sudo を付けずに実行できるようになります。

じゃあアンインストールを試してみます。com.example.MyApp の部分はアプリの Bundle ID に置き換えてください。

```
$ ideviceinstaller --uninstall com.example.MyApp
```

アンインストールできました！次にインストールを試します。パスはアプリの Calabash 用のビルド結果を指すように指定してください。

```
$ ideviceinstaller --install ~/Library/Developer/Xcode/DerivedData/MyApp-xxxxxxxxxxxxxxxxxxxxxxxxxxxx/Build/Products/Calabash-iphoneos/MyApp.app
```

おお！インストールできました。

## 特定のシナリオの時だけ再インストールする

次に特定のシナリオの時だけ再インストールを行うように設定してみます。Cucumber にはそういった[フック処理を書ける仕組み](https://github.com/cucumber/cucumber/wiki/Hooks)が用意されています。features/support 下にあるファイルに書けばいいみたいです。Calabash の場合は 01_launch.rb というファイルがあるので、ここに記述することにします。

```ruby
Before('~@reinstall') do |scenario|
  launcher = Calabash::Launcher.launcher
  options = {
    # Add launch options here.
  }

  launcher.relaunch(options)
end

Before('@reinstall') do
  system("ideviceinstaller --uninstall #{APP_BUNDLE_ID}")
  system("ideviceinstaller --install #{APP_PACKAGE}")
  Calabash::Launcher.launcher.relaunch
end
```

元々の Before には '~@reinstall' を付けて @reinstall タグを付けたシナリオでは実行されないようにしておきます。そして @reinstall タグが付けられたシナリオの前に再インストール処理を行うようにしています。

あとは feature ファイルの中で再インストールを行いたいシナリオに @reinstall タグを付ければやりたいことが実現できます。

```gherkin
# language: ja
フィーチャ: インストール後の初期化

    @reinstall
    シナリオ: インストール後に初期化処理が行われる
        前提 アプリが実行中
        ならば "initialization_after_install"という名前でスクリーンショットを撮る
```

# まとめ

Calabash-iOS の利用環境を整えて、日本語でテストを記述できるようにし、他にやりたいことを試して実現できることを確認しました。最大の関門は日本語入力でしたが、何とか使えそうなところまで漕ぎ着けました。

使っていけばまだまだ「こうしたいけど、どうすれば？」というのは出てくると思いますが、とりあえず十分使い物になりそうな感触は得られました。