中年エンジニアの開発と生活の日々

中年エンジニアがソフトウェア開発や日々の生活で得た知見の備忘録

iOSとAndroidのAppiumスクリプトを共通化する (その2)

前回、iOSAndroid の Appium スクリプトを共通化するお話を書きました。前回、分量の関係で書けませんでしたが、iOSAndroidスクリプトを共通化する場合、避けて通れない話があります。

iOSAndroid で find_element() の挙動が違うお話

iOSAndroid で Appium のスクリプトを共通化するに当たって困るパターンのうち、「Appium の API は同じなのにテストフレームワークの違いによって、挙動が異なる」パターンに分類されます。

accessibility id を 使用して find_element() するさいに、androidiOS で以下のような違いがあります。

  • iOS では画面外の UI コントロールが find_element() で見つけることができる
    • このとき、UI コントロールの displayed? の値は false になっている
  • Android では画面外の UI コントロールのIDを指定して find_element() を呼び出すと見つからず、NoSuchElementError が発生する
    • 当たり前だが、displayed? の値は常に true になる

と、言うわけで、UIコントロールの属性をチェックする際に Android ではいちいち画面をスクロールさせなければなりません。

そんなわけで、UI コントロールを探す場合、Android を念頭に置き、スクロールしながら探すようなロジックを組み込む必要があります。

こんなコードで対応してみました

  def checkTextValuesWithScroll(id, value)
    startX = @driver.manage.window.size.width / 2
    startY = @driver.manage.window.size.height * 0.65
    offsetY = @driver.manage.window.size.height * 0.53

    retryCounter = 0
    begin
        element = find_element(:accessibility_id, id)
    rescue Selenium::WebDriver::Error::NoSuchElementError
        #画面をスクロールさせてもう一度取り直す
        puts "finding #{textInfo['id']}...retry:#{retryCounter}"
        Appium::TouchAction.new.swipe(start_x: startX, start_y: startY, offset_x: 0, offset_y: - offsetY, end_x: startX, end_y: startY - offsetY, duration: 1000).perform
        sleep 1
        retryCounter += 1
        # 10回までリトライする
        retry if retryCounter < 11
    end

    expect(element.text).to eq value
  end

この例では指定した id のUI要素が value のテキストを持っているかチェックしています。 id の値で find_element() を呼び出して、要素が見つからない場合は画面をスクロールさせて再度リトライするようにしています。 画面をスクロールさせるためのパラメータは画面全体の座標から計算していますが、スクロールさせる View の大きさと位置から計算する必要があるかもしれません。 10回までリトライするようにしていますが、この回数はアプリの縦の画面サイズに応じて適宜変える必要があります。

このサンプルでは Android ではスクロールしながら UI コントロールを探しますし、iOS ではスクロールせずに UI の文字列をチェックします。

iOSとAndroidのAppiumスクリプトを共通化する

iOSAndroid のクロス開発を行っていますが、双方のプラットフォームで作成したアプリケーションを Appium を使用してシステム検証を行っています。

Appium はマルチプラットフォームのモバイルアプリケーションテストフレームワークなので、iOSAndroid も Appium で同じように動かすことができます。

ただ、実際にテストスクリプトを共通化しようとすると、いくつか気をつける箇所が出てきます。

具体的には2つのパターンがあります。 - iOSAndroid で Appium の API が異なる - API は同じだけれども、Appium の挙動が異なる

Swipe などの UI 操作

Swipe などの UI 操作は前者で iOSAndroidAPI が異なります。

iOSでは始点の座標と移動量をパラメータとして渡します

Appium::TouchAction.new.swipe(start_x: startX, start_y: startY, offset_x: offsetX, offset_y: offsetY,  duration: 1000).perform

Android では始点の座標と終点の座標を渡します。

Appium::TouchAction.new.swipe(start_x: startX, start_y: startY, end_x: endX, end_y: startY, duration: 1000).perform

なお、両方のパラメータを設定してもOKです。必要ないものは無視されます。両方とも設定することによって、どちらでも動くコードになります。

Appium::TouchAction.new.swipe(start_x: startX, start_y: startY, offset_x: offsetX, offset_y: offsetY, end_x: endX, end_y: startY, duration: 1000).perform

設定する座標系は iOSAndroid で異なりますので 操作の対象となる View などから取得するのが良いでしょう。

まとめ

Appium でも API レベルで AndroidiOS で異なるものがある

UI コントロールの取得

Appium では find_emenet() を使用して、UI パーツにアクセスします。このとき、ID で検索するか View の ツリー構造の xpath で検索することができます。

AndroidiOS ではどうしても View の構造などが合わないため、UIコントロールを find_element() で取得する際に xpath を使用すると、iOSAndroid で同じ値にはなりません。そのため、UI に ID を埋め込み、ID を使用して find_element() を実行して UI パーツにアクセスする必要があります。

iOS では Accessibility Identifier、 Android では View Tag などに埋め込むことができますが、Appium の制限事項で 2017年7月の時点では Android アプリの View Tag を使用して find_element() を使用することができません。そのため、現状の Android 版では Content Description に検証用の ID を埋め込んでいます。

まとめ

iOSAndroid で同じ UI パーツに対して同じ ID を割り当てる。そして Appium からアクセスする際は ID で検索する

表示文字列の値の取得方法

iOS では UI 上に表示している文字列を取得する際に、文字の配置されている View の text プロパティとして値が取得できるのに、Android では View の text プロパティとして取得することができません。

iOS でも Android でも表示文字のコントロールの text プロパティから取得することができます。一手間かかりますが、Android の流儀でテキストの表示文字の値を取得します。

まとめ

表示文字の値を取得する場合は表示文字列に ID を割り当てて、テキストコントロールから直に値をとる

最後に

いくつか AndroidiOS をクロス開発する際に Appium で効率的にテストスクリプトを書くためのトピックをまとめてみました。お役に立てればうれしいです。

Chain Reaction Cycles で自転車を買ってみた

たまには趣味の話をと言うことで…

Chain Reaction Cycles でマウンテンバイクを買ってみました。

Chain Reaction Cycles (以降 CRC) は Wiggle と並ぶ自転車の海外通販の大手です。海外通販は安いけど怖いと言う方に注文から乗り出しまで、こんな感じですとざっくりと紹介させていただきます。需要あるかしら。

購入まで

Chain Reaction Cycles で購入しました。

サイト自体は日本語化されているのですが、自転車のスペックなどは所々英語のままなので、英語が苦手な人は Google 翻訳のお世話になりながら、チェックしてみましょう。

また、自転車のサイズはどれを選べば良いのかよくわからないという問題もありますが、ほとんどの自転車の購入ページにサイズチャートへのリンクがあります。

f:id:munacky:20170723142914j:plain

サイズチャートは以下のように英語のままですが、自分の身長と足の長さを元に合いそうなフレームサイズを注文してください。

f:id:munacky:20170723142951j:plain

決済はクレジットカードなので、通常のインターネット通販と大差ありません。なお、自転車を買うと、送料で15,000 円ほどかかりました。

海外通販の魅力

海外通販の魅力をどこに見いだすかは人それぞれですが、自分の場合は値段意外にも品揃えの充実です。

最近は日本でもスポーツ自転車のお店が増えていますが、ロードバイククロスバイクに品揃えが偏っていたり、自分の体に合うサイズがなかったりします。(特に、自分は身長が大きめなので、国内通販でもなかなか品物がなかったりします。)

今回はマウンテンバイクが欲しかったので、家の近所のスポーツ自転車のお店、国内通販を探して、良さそうなモデルがなかったため、CRC からの購入を決意しました。

配送と関税

CRC の配送は DHL です。CRC から出荷されるとトラッキングナンバーがメールが送られてきます。

f:id:munacky:20170723143758j:plain

赤枠のリンクをクリックすると配送状態が DHL のサイトで調べることができます。スマホからだと現在の状態しかわからず、細かい履歴がわからないので、細かく見たい人はPCを使いましょう。いま、どこにあるのか詳しく書かれています。

f:id:munacky:20170723143840j:plain

CRC のサイトには大体10~15営業日で到着と書いてありますが、自分の場合は7営業日で来ました。

自転車本体には関税はかかりませんが、消費税を支払う必要があります。DHL の場合は引き渡しの際の支払いでした。事前に用意しておきましょう。

開梱

梱包は自転車の販売用の箱に入れられてきます。七分組よりはかっちりと組まれていますが、以下のような状態でした

  • ハンドルはステムから抜かれている
  • シートポストはフレームから抜かれている
  • 前輪は外れている
  • フロントのディスクブレーキは借り止め
  • ドロッパーシートポストのレバーはハンドルから外れている(ワイヤーは本体に組み付けられている)
  • フロントフォークのロックアウト用のレバーとワイヤーは同梱しているだけ
  • ブレーキの引きしろは調整済み
  • シフトワイヤーは調整済み
  • 前後の反射板は同梱されているが組み付けされてはいない
  • ブレーキが左前

そんなわけで組み付け作業

自分はロードバイクの組み立てはできるのですが、マウンテンバイクは初めてでした。そんなわけで、苦労したのはフロントのディスクブレーキの調整とドロッパーシートポスト、フロントフォークのロックアウトレバーの取り付けでした。

フロントのディスクブレーキの本止め

写真の赤丸のネジが緩んだ状態で出荷されています。

f:id:munacky:20170723150811j:plain

おう、ディスクブレーキのセンター出しなんてやったことないよ~。

いろいろ調べた結果、同梱されている説明書にディスクブレーキの説明書に調整の方法が書いてありました。説明書凄いな。

「前輪をつけた状態で、ブレーキレバーを引いた状態で固定する」とのことで、ブレーキレバーを引いた状態で、フォークとブレーキを止めるネジをアーレンキーで締めていきます。

ドロッパーシートポストのとりつけ

最近はやりのドロッパーシートポストの取り付けです。ガスチェアと同じ仕組みで、手元のレバーでシートポストの上げ下げができます。

基本、ワイヤー式なので、シフトワイヤーやブレーキワイヤーの取り回しをやってことがあれば、対応できます。ワイヤリングはすでに行われているので、ハンドルにレバーを取り付けるだけです。右側と左側のどちらにもつけられますが、左側につけました。

f:id:munacky:20170723151121j:plain

ただ、レバーをつけるために、ブレーキレバーとグリップを外す必要がありました。これらもアーレンキーで外してからレバーを取り付けます。

フロントフォークのロックアウトレバーの取り付け

最近のマウンテンバイクはフロントフォークを手元のレバーでサスペンションロックすることができますが、レバーとワイヤーを取り付けます。

レバーはドロッパーシートポストと同じく左につけます。

f:id:munacky:20170723151722j:plain

そしてワイヤーを通してアーレンキーで締め付けます。適切な長さにカットしてエンドキャップをするあたりは通常のワイヤーの取り回しと同じです。

f:id:munacky:20170723151830j:plain

ハンドルの組み付け

ハンドルはアーレンキーを使用して普通にステムにつけるだけでした。楽勝。

ペダルの組み付け

ペダルはペダルレンチを使用して取り付けます。

f:id:munacky:20170723151636j:plain

なんだ、このペダルのデザインは…

右ブレーキレバーと左ブレーキレバーの交換

意外と知られていないことですが、海外では左側が前ブレーキ、右側が後ろブレーキになっています。今回購入したものも左前ブレーキになっていました。さて、ブレーキレバーを交換しようと思ったのですが、油圧式のため、レバーの交換にはオイルとエアー抜きが必要なため、断念しました。いまも、左前ブレーキの設定で乗っています。

組み付けに使用した工具

結局、組み付けに使用した工具は以下の通りです。

ワイヤーカッターとペンチはサスペンションロックのワイヤーを取り付けるために使用しました。

まとめ

海外通販のイメージはつかめたでしょうか? スポーツ自転車をある程度自分で整備・調整できる人なら、是非海外通販を使ってみましょう。ただし、自分の自転車は自分の責任において面倒を見る覚悟が必要です。

Appium でタッチ操作を実行するには

事の発端

スマートフォン向けアプリのテストを Turnip と RSpec で作成していて、画面をスワイプしてスクロールする必要に迫られて、ユーザーのタッチ操作をどのようにスクリプト記述するのかを調べながら実装してみました。

一般に Appium でテストスクリプトを書く場合は SeleniumAPI を使用して書くのですが、Selenium は元々 Web のテストフレームワークだけあって、タッチジェスチャーなどのスマートフォンの操作を記述することは範囲外です。そのため、Appium の API で記述することになります。

以下は、本家のリファレンスです。

上記サイトを見てもらえると、一般的なタッチジェスチャーが網羅されていることがわかります。

呼び出し方

基本的にはメソッドチェーンを使用して使用するのが良いと思います。

  • TouchAction のインスタンスを生成
  • タッチアクションのメソッドを順次に呼び出し
  • perform メソッドを呼び出して実行

個人的には最後の perform メソッドを呼び出さないとタッチ操作が発動しないというのがわからなくて、しばらく悩みました。 (ドキュメント、ちゃんと読めという話ですが…)

一番簡単なタップは以下のコードで実現できます

pointX = 100
pointY = 200

Appium::TouchAction.new.tap(x:pointX, y:pointY).perform

TouchAction.new でインスタンスを作成し、tap で座標を指定、perform でコマンドを発動させます。tap の戻り値が自分自身なため、メソッドチェーンで次々にコマンドを呼び出すことができます。

メソッドチェーンを使って、複雑な操作を実現することもできます。

pointX = 100
pointY = 200
duration = 2000

Appium::TouchAction.new.press(x: pointX, y: pointY).wait(duration).release.perform

この例では (100, 200) の座標を押下(press)して2秒長押し(wait)した後に指を離す(release)ジェスチャーになります。

画面をスクロールさせるには

swipe を使用して画面をスクロールさせます。swipeは iOSAndroid で引数が違うので使いずらいのですが、iOS では始点の座標と動かすオフセット、何秒かけて動かすかのdurationを設定します。 画面をどの程度スクロールさせるかは始点の座標とオフセットの距離で決まります。画面の論理解像度を取得して、テスト対象のアプリケーションの実装と照らし合わせて、異なる機種でも大丈夫なように座標を計算するのがよいでしょう。

以下は Scroll View の画面サイズと位置を元に下から上に、だいたい0.8画面分スクロールさせるサンプルです

scrollView = find_element(:accessibility_id, 'idOfScrollView)

startX = scrollView.location.x + scrollView.size.width/2
startY = scrollView.location.y + scrollView.size.height * 0.8
offsetY = scrollView.size.height * 0.8
    
Appium::TouchAction.new.swipe(start_x: startX, start_y: startY, offset_x: 0, offset_y: - offsetY, duration: 400).perform

ちなみに、画面のスクロールに関しては Appium には Scroll 用の API があるのですが、テストフレームワークとの相性が合ったりして安定しないようで、皆さん苦労されているようです。

Turnip の RSpec で関数を定義して呼び出すには

現在、Turnip と RSpec を組み合わせて Ruby でテストスクリプトを作成しているのですが、共通な処理を関数にまとめる方法についてまとめてみました。

Turnip については以下のサイトが詳しいです

Rubyist Magazine - エンドツーエンドテストの自動化は Cucumber から Turnip へ

Turnipでは Feature ファイルと Stepファイルに分かれていて、Step ファイルは RSpec でテストスクリプトを記述していきます。

RSpecについては以下のサイトが詳しいです relishapp.com

テストスクリプトを書いていると実感するのですが、同じ処理をパラメータを変えて繰り返すことが多いです。そのため、同じ処理を関数として定義して呼び出さないと見通しが悪いですし、メンテナンスも大変です。ところが、通常の Ruby のやり方ではうまくいきません。

たとえば、Stepファイルに以下のようにして関数を定義して、Step の中で呼びだしてみます。

  def putsHogehoge
    puts "hogehoge"
  end

step "Step1" do
  putsHogehoge
end

通常の Ruby でしたら、"Step1" の中で putsHogehoge がコールされてコンソールに “hogehgoe” と表示されますが、Turnip で Feature ファイルからこのStep1を呼び出すと以下のようなエラーメッセージが表示されます。

Failures:

  1) Test Sample Given GetParam
     Failure/Error: putHogehoge

     NameError:
       undefined local variable or method `putsHogehoge' for #<RSpec::ExampleGroups::Test::Sample:0x007fb676a58ac8>
     # spec/steps/sample_steps.rb:13:in `block in <top (required)>'
     # ./vendor/bundle/ruby/2.3.0/gems/turnip-2.1.1/lib/turnip/execute.rb:25:in `step'
     # ./spec/features/login.feature:5:in `run_step'
     # ./vendor/bundle/ruby/2.3.0/gems/turnip-2.1.1/lib/turnip/rspec.rb:44:in `instance_eval'
     # ./vendor/bundle/ruby/2.3.0/gems/turnip-2.1.1/lib/turnip/rspec.rb:44:in `run_step'
     # ./spec/features/sample.feature:6:in `block (4 levels) in run_feature'
     # ./spec/features/sample.feature:5:in `each'
     # ./spec/features/sample.feature:5:in `block (3 levels) in run_feature'
     # ./spec/features/sample.feature:5:in `Step1'

Step1 は Turnip のフレームワークから呼び出されているようで、すぐ上に定義されている putsHogehoge という関数が見えないようです。

解決策

以下のサイトを参考に Turnip で拡張されている箇所のつじつまを合わせたら、Helper Method を呼び出すことができました。 relishapp.com

  • Spec フォルダに helpers.rb を作成します。別に stepsディレクトリに作成してもよい気もしますが、Step ではなく、Helper なので一つ上のディレクトリに格納しています。
  • helpers.rb に Helpers Module を定義して、その中に関数を定義します
# coding: utf-8

module Helpers
  def putsHogehoge
    puts "hogehgoe"
  end
end
  • spec_helper.rb に require ‘./spec/helpers’ を追加します。
require 'rubygems'
require 'selenium-webdriver'
require 'appium_lib'
require 'bundler/setup'
require './spec/helpers'  # <- 追加
  • spec_helper.rb の RSpec configure に c.include Helpers を追加。これにより、Helpers Module が各 Step でロードされます。
RSpec.configure do |c|
  c.include Helpers # <- 追加

  c.before(:each) {
    @driver = Appium::Driver.new(desired_caps).start_driver
    @driver.manage.timeouts.implicit_wait = 5
    Appium.promote_appium_methods Object
  }
  c.after(:each) {
    @driver.quit
  }
end

こうすることによって、Turnip の Step ファイルから自分で定義した関数を呼び出せるようになりました。

Appium のテストスクリプトで長めの Sleep を入れる

本日は小ネタです。

内部の実装上の都合で、Appium のテストスクリプト (Rubyで書いてます) の中で長めの Wait を入れる必要がありました。テストスクリプトの中で

  sleep 90

とか、長めの Sleep を入れたところ、テストスクリプトの実行時に以下のようなエラーメッセージが出ました。

     Failure/Error: @driver.quit
     
     Selenium::WebDriver::Error::NoSuchDriverError:
       A session is either terminated or not started

なにもしてないのに、なぜ故に@driver.quit がこけるのだ。すでにセッションが終了しているだと…

エラーの原因

Appium のログを見たところ以下のような記載がありました。

[BaseDriver] Shutting down because we waited 60 seconds for a command
[Appium] Closing session, cause was 'New Command Timeout of 60 seconds expired. Try customizing the timeout using the 'newCommandTimeout' desired capability'

どうも、コマンドのタイムアウトが60秒でそれを超えるとセッションが閉じるようです。何もしていないのに @driver.quit が失敗したのではなく、何もしなかったので @driver.quit が失敗してしまったというわけです。

解決策

ログの通りに、appium 起動時の設定オプションの desired capability に newCommandTimeout=120 と設定したら、90秒の Sleep を挟んでも問題なくテストスクリプトが動作しました。

Circle CI でバックグラウンド処理を行う

CI におけるバックグラウンドプロセスの実行について

Circle CI でバックグラウンドプロセスを起動したいことがままあります。たとえば、自動検証を行う際に appium のサーバープロセスを起動するなどです。

普通、Unix系のコマンドではコマンドの後ろに ‘&’ をつけて、実行するとバックグラウンド実行になります。

> appium &

しかしながら、circle.yml にコマンドとして以下のように書くと…

test:
  pre:
    - appium&

実行時に次のような警告が出てきます。

Probable error. It looks like you’re trying to run a command in the background by using ‘&’ and not the ‘background: true’ option. If so, the process will die quickly from the hangup (HUP) signal. See the the documentation on backgrounding.

と、言うわけで、Circle CI 本家のドキュメントを見てみます。

circleci.com

このドキュメントによると、バックグラウンド処理は & を使うのではなく、yml の記法で background オプションを true にせよとのことです。また、必要に応じてバックグラウンドプロセスが安定するまでスリープを入れてもよいとのことです。

そんなわけで、以下のように circle.yml に記載することによって、appium のサーバーがバックグラウンドプロセスとして起動することができます。

test:
  pre:
    - appium:
        background: true
    - sleep 10

バックグラウンドプロセスの標準出力をみるには

Circle CI ではバックグラウンドプロセスの標準出力とエラー出力をビルド後に確認することができます。通常では Circle CI の仮想マシンのログなどを引き上げるときは artifacts としてファイルパスを circle.yml に記載する必要がありますが、バックグラウンドプロセスの標準出力とエラー出力については自動的に artifacts として待避されます。ビルド、自動テスト中にエラーが出た場合にも後からログとして確認することができます。

Circle CI の結果画面で artifacts タブを選択すると以下のような表示になります。 f:id:munacky:20170524183848p:plain

このファイル群の stdout から始まるのが、標準出力、stderr から始まるのがエラー出力になります。

自分はこの機能を知らず、わざわざ、出力先を自前でリダイレクトして、リダイレクトしたファイルを artifacts に登録して出力結果を引き上げていました。とほほ。