Roppongi.aar #2で、UI Automator 2について発表しました #roppongi_aar
2015/11/17(火)に開催された、Roppongi.aar #2で「UI Automator Becomes Friendly with Espresso」というタイトルで発表してきました。以下が発表資料です。
新しくなったUI Automator 2では、Espressoと混ぜて使うことができる、という内容です。
Espressoを使ったテストを書いている時に、連携先のアプリを操作したくなることがあると思います。 そういうときに、連携先のアプリに遷移したところだけUI AutomatorのAPIを使ってテストを書くことができるのです。
新しいUI AutomatorがAndroid Instrumentation Testをベースに書き直されたことによって、このようなことが可能になりました。
具体的な動作例はGitHubにアップロードしましたので、参照してみてください。
UI Automator 2の仕組みについて
ところで、Android Instrumentation Testは、テストコードと同一の開発者署名が施されたアプリケーションしか操作できなかったはずです。 それにもかかわらず、UI Automatorは、異なる署名のアプリを操作できています。これは、どのような経緯によるものなのでしょうか。
UI Automator 2のソースコードを辿っていくと、最終的にInstrumentationUiAutomatorBridgeというクラスからUiAutomationを参照していることが分かります。
このUiAutomation
クラスのインスタンスはInstrumentation.getUiAutomation()から取得することができますので、Instrumentation Testから呼び出すことができるのです。
UI Automator 2は、UiAutomation
が提供しているメソッドを駆使して第三者が署名したアプリの操作を実現している、ということになります。これらのAPIの利用には、特殊な権限はいりません。
アクセシビリティまわりのAPI構造を理解していないとなかなか使いこなすのが難しそうですが、これらのAPIを駆使すればUI Automatorの気に入らない部分だけ自作したり、あるいは新しいテストフレームワーク全体を作ることもできるはずで、色々と夢は広がります!
最後に
今回の勉強会は「英語推奨」ということで、スライドだけはなんとか英語にして本番に臨んだのですが、 喋りについては日本語で発表させていただきました。 海外の参加者も何人かいらっしゃったので、発表も英語でできれば良かったのですが、そこまでは手が回りませんでした。 また発表の機会があったら、次回は頑張りたいと思います。
発表後の懇親会では、Androidのテストについての苦労話を、色々な方から伺うことができました。大変だという思いは皆さん一緒なのだと改めて思いました。Androidのテストについて、色々な方の考えを聴けるのは本当に参考になります!
今回の勉強会を主催してくださった@petitvioletさん、@tomoaki_imaiさん、株式会社メルカリのみなさん、どうもありがとうございました。
Appiumについての記事を@ITに寄稿しました
Android/iOS向けのテスト自動化ツールAppiumについての記事を、@ITさんの連載 「スマホ向け無料システムテスト自動化ツール」 に寄稿しました。
今回の記事ではAppiumのGUIフロントエンドを使ってテストを実行するところまでの手順を説明しています。 すぐに試せるように、Android向けサンプルのソースコードも一緒に公開していますので、興味の有る方はお試しください。
なお、執筆タイミングの関係で、Appiumのバージョン1.3.4を元にした記事になっています。
現在の最新版である1.3.7を動かしてみたところ、いくつかの違いがありましたので、このエントリーで紹介したいと思います。
最新版のAppiumで試すときの参考にしてください。
Windows向けのインストールパッケージについて
Windows向けのAppium 1.3.4.1は、zipファイルを展開するだけでインストール完了だったのですが、最新のAppium 1.3.7.2では、インストーラを実行してインストールするようになっています。
zipファイルの中にappium-installer.exe
がありますので、それを実行するとインストールすることができます。
Mac OS X向けの外観について
Mac OS X向けのAppium Desktop Appの外観が少しだけ変わりました。バージョン1.3.4では、記事に掲載した画面スナップショットのように、設定ボタン類は左側に寄っていたのですが、1.3.7では以下のようなレイアウトになりました。
また、悲しいことに、各種設定ダイアログの外観が見にくくなってしまいました。
この見にくいダイアログへの変更は意図されたものではないようで、開発元のIssueとして登録されています。
いずれ改善されるとは思いますが、それまでは眼を凝らして設定項目を見るしかないですね…。
UIAutomator 2.0の日本語入力を試してみた
少し前に、UIAutomator 2.0が、Android Testing Support Libraryに入るようになりました。
https://plus.google.com/+AndroidDevelopers/posts/WCWANrPkRxg
既にセットアップ方法や、APIリファレンスも公開されています。
早速APIリファレンスを眺めてみたところ、UiObject.setText(String)の説明)に、以下のような注記があるのを見付けました。
Improvements: Post API Level 19 (KitKat release), the underlying implementation is updated to a dedicated set text accessibility action, and it also now supports Unicode.
どうも、Lollipop以降であれば、UiObject.setText(String)
で日本語(Unicode)が入力できるようになっているようです。
ちょうど、Googleが公開しているサンプルが、文字を入力するものだったので、 ChangeTextBehaviorTest.javaの以下の部分を日本語に変更して動作を確認してみました。
private static final String STRING_TO_BE_TYPED = "こんにちは";
その結果、手持ちのNexus7 (Android 5.0.2)で、普通に日本語が入力できることが確認できました。
これでUiautomator Unicode Input Helperの助けを借りずとも、日本語が入力できそうです(KitKat以前では従前通り助けが必要です)。
第9回potatotipsでRobolectricでPowerMockを使う方法を発表しました #potatotips
2014/9/24(水)に開催された【第9回】potatotips(iOS/Android開発Tips共有会)で発表してきました。以下が発表資料です。
Androidの単体テストフレームワークRobolectricでPowerMockを使う方法(開発環境にAndroid Studioを使う場合)を紹介する内容です。
当日の発表は、持ち時間が5分ということもあり、ほとんど結論だけの内容にしたのですが、このエントリーでは、この結論に至った経緯について少し補足したいと思います。
JDKのバージョンについて
RobolectricとPowerMockを共存させるために利用しているPowerMockAgentの問題で、JDK1.7でテストを実行しようとすると、以下のような例外が発生し、実行に失敗してしまいます。
java.lang.IllegalStateException: Unable to load Java agent; please add lib/tools.jar from your JDK to the classpath
この問題は、PowerMockのissueに登録されていて、Mavenを使う場合の回避策は書かれているのですが、Gradle/Android Studioで解決する方法がわかりませんでした。
具体的には、テスト実行時に-javaagent
オプションに加えて-XX:-UseSplitVerifier
を指定すればうまくいきそうなのですが、自分の環境では解決しませんでした。
この点を解決する方法が見付かれば、JDK1.7以降でも動作させることができると思います。
robolectric-gradle-pluginのバージョンについて
発表時点におけるrobolectric-gradle-pluginのリリースバージョンは0.12.0だったのですが、0.12.0ではテスト実行時のオプション引数を指定するためのjvmArgs
機能が実装されていませんでした。
PowerMockAgentの方法を使ってテストを実行するときは、テスト実行時に-javaagent
オプションを使って、powermock-module-javaagent-1.5.5.jar
へのパスを指定する必要があります。
そのため、jvmArgs
機能が実装されているmasterブランチの先端(資料作成当時のリビジョンはこちら)を自分でビルドする方法を紹介したのですが、今確認したら、発表当日に0.13.0がリリースされていたようです。
0.13.0にはjvmArgs
を指定できる機能も含まれているようなので、こちらを利用すれば、わざわざ自分でビルドする必要はなさそうです。まだ0.13.0での動作確認はできていないので、近々確認してみようと思います。
RobolectricのConfigファイルの配置について
Robolectricでテストを実行するには、テスト対象アプリのAndroidManifest.xml
の場所をorg.robolectric.Config.properties
ファイルに指定する必要があります。
このファイルを置くディレクトリについて、発表ではsrc/test/res
と説明しましたが、少なくともrobolectric-gradle-pluginの0.12.0ではsrc/test/resources
が正しい置き場所でした。
その後のどこかでsrc/test/res
配下を見るように仕様変更したようなのですが、意図したものなのかバグなのか分かっていません。今後またsrc/test/res
から変更されるかも知れませんので、うまくテスト対象アプリが見付けられていないようなら、このファイルが正しく読み込まれているかどうか、以下の方法で確認してみると良いと思います。
gradle clean testDebugClasses
を実行する。build/test-classes
ディレクトリにorg.robolectric.Config.properties
ファイルが存在するかどうかを確認する。存在していれば問題なし。
Android Studioで実行できるようにするための設定について
Android StudioでRobolectricのテストを実行する方法については、id:STAR_ZERO さんによる以下の記事が非常に参考になりました。
この記事に書かれている内容をrobolectric-gradle-pluginの最新版に対応させ、PowerMock対応のための実行時オプションを追加したものが、今回の発表のベースになっています。
Javaエージェント方式 vs JUnit Rule方式
RobolectricとPowerMockの併存が難しい根本的な理由は、JUnit4の@RunWith
アノテーションで指定できるテストランナーが1クラスに限定されているからです(発表スライドp.8参照)。一方、PowerMockには@RunWith
を使わない方法として、以下の2通りが用意されています。
このどちらの方法を採用しても、@RunWith
の問題を解決できるはずですなのですが、自分の環境では、JUnit Rule方式ではXStream周りで例外が出て起動に失敗してしまい、うまく動作しませんでした。
そのような経緯で、Javaエージェント方式を採用したのですが、Javaエージェント方式は公式ドキュメントにexperimentalと書かれていることもあり、もし例外発生を回避できる方法が見付かるなら、JUnit Rule方式がより望ましいと思います。
JUnit Rule方式を使うことができるようになれば、JDK1.6縛りの問題も解決されますし、-javaagent
オプションをテスト実行時に指定する必要がなくなるため、build.gradle
もかなりシンプルになるはずです。
もし解決方法をご存知の方がいらっしゃいましたら、コメントいただけると有り難いです。
さいごに
potatotipsに参加するのは今回が2回目だったのですが、前回と同様、どの発表も自分にとっては知らないことばかりで、「今度機会があったら使ってみよう」と思えるTipsが揃っていました。自分の発表もそのように感じて下さった方がいらっしゃったとしたら大変嬉しいです。
発表後の懇親会では、Androidアプリ開発にまつわる色々な視点の話を伺うことができ、とても参考になると共に、楽しい時間を過ごすことができました。自分では思い至らないような視点を持っている方が多く、自分ももっと視野を広げねば、と思った1日でした。また何かネタを見つけて発表したいと思います!
主催してくださった @ninjinkun さんを始めとするFablic,inc.の皆さん、運営サイドの皆さん、どうもありがとうございました。
android.test.casual2で発表してきました #androidcasualtest
2014/4/3(木)に開催された android.test.casual #2 でLTをしました。以下が発表資料です。
「もしもの時にも安心なuiautomatorのwatcher機能」 というタイトルで、 uiautomatorフレームワークが提供しているUiWatcher APIの振舞いを紹介しました。
今回ご紹介したUiWatcherというのは、
- テスト対象アプリの強制終了ダイアログ表示
- テスト対象アプリのANRダイアログ表示
のような、テストシナリオの進行とは無関係に発生する事象に対して、 どのように操作したいかを登録できる機能です。
uiautomatorが持つ、かなり特徴的な機能だと思うのですが、 公式ドキュメントにはほとんど記載がなく、 あまり知られていなさそうな雰囲気がしたので、今回発表してみました。
詳細は上記発表スライドを見ていただきたいのですが、 うまく使うと非常に便利だと思いますので、是非お試しください。
今回も、色々な立場の方の話を聴くことができて、とても励みになりました。 また次回も参加したいですね!
第1回Android Test Casual Talks でuiautomatorの話をしました #androidtest
12/13(金)に開催された Android Test Casual Talks #1 でLTをしました。以下が発表資料です。
「uiautomatorを使うときのひと工夫」というタイトルで、 拙作のUiautomator Unicode Input Helperの紹介をベースに、 日本語環境でuiautomatorを快適に利用する工夫について発表しました。
勉強会では初めての発表だったということもあり、 かなりドキドキしていたのですが、皆さんに興味を持って聴いていただけたようで、 ほっとしています。
LTの補足
発表の中で、
UiObject.clearTextField()
の実装を日本語ロケール向けにローカライズを行った、
というお話もさせていただきました。
それについて、帰宅後に少し調べてみたところ、
意外と簡単に国際化対応できそうな事に気付きましたので、
このブログで補足させてください。
このような対応が必要となる原因は、
UiObject.clearTextField()
の実装で、
以下のように"Select all"
がハードコーディングされているからでした。
public void clearTextField() throws UiObjectNotFoundException { // (省略) getInteractionController().longTapNoSync(rect.left + 20, rect.centerY()); // check if the edit menu is open UiObject selectAll = new UiObject(new UiSelector().descriptionContains("Select all")); if(selectAll.waitForExists(50)) selectAll.click(); // wait for the selection SystemClock.sleep(250); // delete it getInteractionController().sendKey(KeyEvent.KEYCODE_DEL, 0); }
"Select all"
を表すリソースIDは
R.string.selectAll
なので、通常のAndroidアプリであれば"Select all"
を
context.getString(android.R.string.selectAll)
(context
はContext
のインスタンス)
に置き換えれば自然に国際化できるのですが、
uiautomatorのテストコードからはContext
を取得する手段が無いため、
getString()
できないだろうと思い込んでいました。
ところが、その思い込みは間違いで、システムリソース
(android.R.
から始まるリソース)に限り
Resources.getSystem()
を使えば、Context
無しにリソースにアクセスできることが分かりました*1。
恥ずかしながら、このようなAPIが有ることを今まで知りませんでした。
手持ちの以下の2つの端末で確認してみた感じでは、 uiautomatorから上記を呼び出すと、端末の言語設定に応じて、 適切な文字列を返してくれているようです。
ですので、原理的には、以下のように書き換えれば、
端末の言語設定にかかわらず、clearTextField()
が動作するはずです。
AOSPへのバグ報告は別途行いたいと思います。
public void clearTextField() throws UiObjectNotFoundException { // (省略) String selectAllDesc = Resources.getSystem().getString(android.R.string.selectAll); UiObject selectAll = new UiObject(new UiSelector().descriptionContains(selectAllDesc)); // (省略) }
各自で回避する場合の多言語対応版は以下のようになります。
public void myClearTextField(UiObject editText) throws UiObjectNotFoundException { editText.longClickTopLeft(); // "Select all"のリソース文字列を取得 String selectAllDesc = Resources.getSystem().getString(android.R.string.selectAll); UiObject selectAll = new UiObject(new UiSelector().descriptionContains(selectAllDesc)); if (selectAll.waitForExists(50)) selectAll.click(); SystemClock.sleep(250); getUiDevice().pressDelete(); }
さいごに
他の皆さんのLTも、どれも新しい発見があり、とても興味深く聴かせていただきました。 これからも、このような勉強会で引き続き情報交換していきたいです。
今回の勉強会を主催し、LTに誘って下さった@hotchemiさんをはじめとする運営の皆さん、 コメント下さった参加者の皆さん、本当にありがとうございました。
*1:実は、「Contextを取得できない」というのも勘違いの可能性が高いです。Android4.3からはUiAutomatorTestCaseがInstrumentationTestCaseを継承するようになっていました。ということは、getInstrumentation().getContext()が呼び出せるかもしれません。今度調べてみようと思います。
KitKatの文字コード変換バグを回避する
はじめに
先日、手許のNeuxs7にもKitKatが降ってきました。
早速アップグレードしてみたところ、「Uiautomatorで日本語を入力する」で紹介した、拙作のuiautomator-unicode-input-helper(以後、UUIHと略します)が全く動作しなくなってしまいました…。
原因を調べたところ、KitKatには、Javaで文字コード変換を担っているCharsetEncoder
にバグがあることが分かりました。一応の回避策も判明したので、書き留めておこうと思います。
事象
KitKatでUUIHを使うときに、以下のように、自作のヘルパーライブラリを使って日本語を入力しようとすると、文字列によって、全部入力できたり、一部だけ入力できたり、全く入力できなかったりします。
UiObject editText = ...; editText.setText(Utf7ImeHelper.e("こんにちは!UiAutomatorで入力しています。")); // うまく入力できない!
ここで、Utf7ImeHelper.e(String)
の実装は以下のような単純なものです。
private static final Charset CHARSET_MODIFIED_UTF7 = new CharsetProvider().charsetForName("X-MODIFIED-UTF-7"); public static String e(String text) { byte[] encodedByte = text.getBytes(CHARSET_MODIFIED_UTF7); return new String(encodedByte, Charset.forName("US-ASCII")); }
ところが、手動でModified UTF-7にエンコードしたものを指定すると、全く問題なく入力できます。
UiObject editText = ...; // 「こんにちは!UiAutomatorで入力しています。」を修正UTF-7でエンコードしたもの String e = "&MFMwkzBrMGEwb,8B-UiAutomator&MGdRZVKbMFcwZjBEMH4wWTAC-"; editText.setText(e); // 問題なく入力できる!
そこで、この文字列とUtf7ImeHelper.e(...)
の変換結果と比べてみると、text.getBytes(CHARSET_MODIFIED_UTF7)
した時点で、既に末尾の"-"
が欠けていることが判明しました。
次に、似た事象を検索しみたところ、こちらの記事ではSJISで似たような事象が起きていることがわかりました。これはどうもKitKatの文字コード変換まわりに問題がありそうです。
解析
String.getBytes(Charset)
が内部で呼び出していると思われる、CharsetEncoder.encode(CharBuffer)
のソースコードをあたってみたところ、Android4.4では以下のようになっていました。
public final ByteBuffer encode(CharBuffer in) throws CharacterCodingException { ... reset(); while (in.hasRemaining()) { CoderResult result = encode(in, out, true); if (result == CoderResult.UNDERFLOW) { break; } else if (result == CoderResult.OVERFLOW) { out = allocateMore(out); continue; } else { checkCoderResult(result); } // resultがUNDERFLOWかOVERFLOWである限り、ここに到達することはない! result = flush(out); .... } .... }
このコードから呼び出されているCharsetEncoder.encode(CharBuffer in, ByteBuffer out, boolean endOfInput)
が、文字コード変換の要となるメソッドです。APIリファレンスによると、このメソッドを呼び出す時には、以下のプロトコルに従う必要があります。
- まず
reset()
を呼び出す encode(CharBuffer in, ByteBuffer out, boolean endOfInput)
を呼び出してエンコードする。- 一度に全入力を渡さず、何回かに分けて呼び出す場合には、一番最後の呼び出しのときだけ、第3引数
endOfInput
をtrue
にして呼び出す。そうでない場合には第3引数にはfalse
を指定する。 - 1回で全入力を渡せる場合は、いきなり第3引数に
true
を指定して呼び出せばよい。 - 入力が壊れていない場合、
encode(CharBuffer, ByteBuffer, boolean)
の結果は、CoderResult.UNDERFLOW
か、CoderResult.OVERFLOW
のいずれかになる。OVERFLOW
の場合は、出力バッファのサイズが足りていないので、出力バッファを拡張して、encode(CharBuffer, ByteBuffer, boolean)
を呼び直さなければならない。UNDERFLOW
は正常終了を表す。
- 一度に全入力を渡さず、何回かに分けて呼び出す場合には、一番最後の呼び出しのときだけ、第3引数
- 変換がおわったら
flush(ByteBuffer)
を呼ぶ
ところが、上記コードを見ると、encode(CharBuffer, ByteBuffer, boolean)
の結果がOVERFLOWとUNDERFLOWしか取らない場合では、(必ずbreak
かcontinue
に到達するので)絶対にflush(ByteBuffer)
が呼び出されることはありません。
自分がModified UTF-7への変換に使っているライブラリjutf7は、flush(ByteBuffer)
呼び出し時に'-'
を出力する実装になっていたので、末尾の'-'
が欠ける結果となっていたのでした。
回避策
以上をふまえると、きちんと最後にflush(ByteBuffer)
を呼び出してあげれば良いことがわかりますので、AOSPのソースコードから、バグの無いところを抜粋して、以下のように書き換えてみたところ、KitKatでも動作するようになりました。
/* * このコードは、 * * https://android.googlesource.com/platform/libcore/+/master * * の以下のファイルから一部抜粋し、改変したものです。 * - libdvm/src/main/java/java/lang/String.java * - luni/src/main/java/java/nio/charset/CharsetEncoder.java * * この部分のライセンスはApache License, Version 2.0とします。 */ /** * Modified UTF-7形式にエンコードした文字列に変換する。 */ public static String e(String text) { byte[] encoded = getBytes(text, CHARSET_MODIFIED_UTF7); return new String(encoded, Charset.forName("US-ASCII")); } /** * String.getBytes(Charset)を、 * 自作の{@link #encode(CharBuffer, CharsetEncoder)} * を呼び出すように改造したもの。 */ public static byte[] getBytes(String input, Charset charset) { CharBuffer chars = CharBuffer.wrap(input.toCharArray()); CharsetEncoder encoder = charset.newEncoder() .onMalformedInput(CodingErrorAction.REPLACE) .onUnmappableCharacter(CodingErrorAction.REPLACE); ByteBuffer buffer; buffer = encode(chars.asReadOnlyBuffer(), encoder); byte[] bytes = new byte[buffer.limit()]; buffer.get(bytes); return bytes; } /** * CharsetEncoder.encode(CharBuffer) のバグ修正版。 * ただし、{@link #getBytes(String, Charset)} * からしか呼び出されない前提で、処理を簡略化している。 */ private static ByteBuffer encode(CharBuffer in, CharsetEncoder encoder) { int length = (int) (in.remaining() * (double) encoder.averageBytesPerChar()); ByteBuffer out = ByteBuffer.allocate(length); encoder.reset(); CoderResult flushResult = null; while (flushResult != CoderResult.UNDERFLOW) { CoderResult encodeResult = encoder.encode(in, out, true); if (encodeResult == CoderResult.OVERFLOW) { out = allocateMore(out); continue; // No point trying to flush to an already-full buffer. } // getBytes()から呼び出されるときは、CodingErrorActionは // REPLACEなので、ここでエラーチェックする必要はない。 flushResult = encoder.flush(out); if (flushResult == CoderResult.OVERFLOW) { out = allocateMore(out); } // getBytes()から呼び出されるときは、CodingErrorActionは // REPLACEなので、ここでエラーチェックする必要はない。 } out.flip(); return out; } private static ByteBuffer allocateMore(ByteBuffer output) { System.out.println("allocateMore()"); if (output.capacity() == 0) { return ByteBuffer.allocate(1); } ByteBuffer result = ByteBuffer.allocate(output.capacity() * 2); output.flip(); result.put(output); return result; }
SJISへの変換がおかしくなる問題も、この方法で回避できましたので、お困りの方はお試しください。具体的には、上記コードをコピペした上で、以下のように使ってみてください。
// 「あ」をSJISに変換する。 byte[] sjisEncoded = getBytes("あ", Chrset.forName("Shift_JIS"));
github上のUUIHは近いうちに更新しようと思います。
バグ報告について
この事象について、AOSPにバグ報告しようと調べてみたところ、既に韓国語でバグ報告がなされていることがわかりました。
これによると、もうmaster
ブランチには修正版コミットされており、次回の4.4のアップデートでは配信される見込みのようです。もうしばらくの辛抱ですね…。