2014-08-12
Mockito+dexmakerはART; Android Runtimeでも使えるよ
概要
「ART; Android RuntimeになったらMockitoのテスト動かない!」なんてことはないけれども、現状は罠があるという話。詳細はそれぞれの項を参照してください。
引数なしのインターフェイスのメソッドのテストで失敗する
AndroidのデベロッパーサイトのVerifying App Behavior on the Android Runtime (ART)というページでInvocationHandler.invoke()の挙動が変わった旨の記載があります。
Proxy InvocationHandler.invoke() now receives null if there are no arguments instead of an empty array. This behavior was documented previously but not correctly handled in Dalvik. Previous versions of Mockito have difficulties with this, so use an updated Mockito version when testing with ART.
https://developer.android.com/guide/practices/verifying-apps-art.html#Object_Model_Changes/
要約すると、InvocationHandler.invoke()はこれまでは引数なしのメソッドの呼び出しでは空の配列が渡されていたが、ARTではnullが渡される。そして、Mockitoはそれを良しとしないのでこの挙動を許容するアップデートが入ったMockitoを使ってくれ、ということです。
Javaではインターフェイスに対するプロキシオブジェクトを生成する機構が備わっており、InvocationHandlerはプロキシオブジェクトに対するメソッド呼び出しを処理するためのインターフェイスです。Mockitoはインターフェイスに対するMock、およびSpyの生成を、このInvocationHandlerを使って実現しています。
つまり、ARTでは以下のようにインターフェイスの引数なしのメソッドをモック化しようとすると失敗します。
public interface TargetInterface { String sayGoodbye(); }
モック化したインターフェイスの引数なしのメソッドをテストする
public class NoArgsInterfaceMethodTest extends TestCase { @Mock TargetInterface underTest; public void testThatWorkProperly() throws Exception { when(underTest.sayGoodbye()).thenReturn("Goodbye, Mockito"); assertThat(underTest.sayGoodbye()).isEqualTo("Goodbye, Mockito"); verify(underTest).sayGoodbye(); } }
テスト失敗時のトレース
java.lang.IllegalArgumentException at com.google.dexmaker.mockito.InvocationHandlerAdapter.invoke(InvocationHandlerAdapter.java:49) at java.lang.reflect.Proxy.invoke(Proxy.java:397) at $Proxy1.sayGoodbye(Unknown Source) at com.uphyca.mockitoonart.NoArgsInterfaceMethodTest.testThatWorkProperly(NoArgsInterfaceMethodTest.java:22) at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:191) at android.test.AndroidTestRunner.runTest(AndroidTestRunner.java:176) at android.test.InstrumentationTestRunner.onStart(InstrumentationTestRunner.java:555) at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:1763)
エラーが発生しているMockito(正確にはdexmaker-mockito)のソースの該当箇所を見てみると、次のようにInvocationHandlerに渡されるargs引数がnullの場合は例外を投げるように実装されています。
33. final class InvocationHandlerAdapter implements InvocationHandler { 34. private MockHandler handler; 35. private final ObjectMethodsGuru objectMethodsGuru = new ObjectMethodsGuru(); 36. 37. public InvocationHandlerAdapter(MockHandler handler) { 38. this.handler = handler; 39. } 40. 41. public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { 42. if (objectMethodsGuru.isEqualsMethod(method)) { 43. return proxy == args[0]; 44. } else if (objectMethodsGuru.isHashCodeMethod(method)) { 45. return System.identityHashCode(proxy); 46. } 47. 48. if (args == null) { 49. throw new IllegalArgumentException(); 50. }
このInvocationHandlerの変更の影響を受けるのはビルド設定でtargetSdkVersionを'L'にしている場合です。targetSdkVersionが20以下の場合はART環境であっても従来どおりの挙動になります。
というわけで、この現象はそのうちdexmaker-mockitoの該当箇所が修正されて問題なく実行できるようになるのではないでしょうか。
NoClassDefFoundErrorが発生してテストがクラッシュする
NoClassDefFoundErrorが発生してテストがクラッシュする場合があります。
:app:connectedAndroidTest Tests on Nexus 5 - L failed: Instrumentation run failed due to 'java.lang.NoClassDefFoundError'
トレースには以下のようにorg.mockito.internal.runners.RunnerImplがない旨が出力されています。
08-12 14:17:40.636 19797-19797/com.uphyca.mockitoonart E/AndroidRuntime﹕ FATAL EXCEPTION: main Process: com.uphyca.mockitoonart, PID: 19797 java.lang.NoClassDefFoundError: org.mockito.internal.runners.RunnerImpl at java.lang.Class.classForName(Native Method) at java.lang.Class.forName(Class.java:308) at android.test.ClassPathPackageInfoSource.createPackageInfo(ClassPathPackageInfoSource.java:88) at android.test.ClassPathPackageInfoSource.access$000(ClassPathPackageInfoSource.java:39) at android.test.ClassPathPackageInfoSource$1.load(ClassPathPackageInfoSource.java:50) at android.test.ClassPathPackageInfoSource$1.load(ClassPathPackageInfoSource.java:47) at android.test.SimpleCache.get(SimpleCache.java:31) at android.test.ClassPathPackageInfoSource.getPackageInfo(ClassPathPackageInfoSource.java:72) at android.test.ClassPathPackageInfo.getSubpackages(ClassPathPackageInfo.java:48) at android.test.ClassPathPackageInfo.addTopLevelClassesTo(ClassPathPackageInfo.java:61) at android.test.ClassPathPackageInfo.getTopLevelClassesRecursive(ClassPathPackageInfo.java:55) at android.test.suitebuilder.TestGrouping.testCaseClassesInPackage(TestGrouping.java:156) at android.test.suitebuilder.TestGrouping.addPackagesRecursive(TestGrouping.java:117) at android.test.suitebuilder.TestSuiteBuilder.includePackages(TestSuiteBuilder.java:100) at android.test.InstrumentationTestRunner.onCreate(InstrumentationTestRunner.java:367) at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4388) at android.app.ActivityThread.access$1500(ActivityThread.java:143) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1317) at android.os.Handler.dispatchMessage(Handler.java:102) at android.os.Looper.loop(Looper.java:135) at android.app.ActivityThread.main(ActivityThread.java:5070) at java.lang.reflect.Method.invoke(Native Method) at java.lang.reflect.Method.invoke(Method.java:372) at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:836) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:631)
しかし、パッケージされたテストモジュールにはこのクラスが存在します。
パッケージにorg.mockito.internal.runners.RunnerImplが存在することを確認する
$ ./d2j-dex2jar.sh classes.dex dex2jar classes.dex -> classes-dex2jar.jar $ jar tf classes_dex2jar.jar | grep org.mockito.internal.runners.RunnerImpl org/mockito/internal/runners/RunnerImpl.class
logcatを見ていくと、クラスのロードに失敗しているっぽい感じになっています。
クラスのロードに失敗しているっぽいlogcat
08-12 14:09:50.424 18582-18582/com.uphyca.mockitoonart W/ClassPathPackageInfoSource﹕ Cannot load class. Make sure it is in your apk. Class name: 'org.mockito.cglib.transform.AbstractProcessTask'. Message: org.mockito.cglib.transform.AbstractProcessTask java.lang.ClassNotFoundException: org.mockito.cglib.transform.AbstractProcessTask at java.lang.Class.classForName(Native Method) at java.lang.Class.forName(Class.java:308) at android.test.ClassPathPackageInfoSource.createPackageInfo(ClassPathPackageInfoSource.java:88) at android.test.ClassPathPackageInfoSource.access$000(ClassPathPackageInfoSource.java:39) at android.test.ClassPathPackageInfoSource$1.load(ClassPathPackageInfoSource.java:50) at android.test.ClassPathPackageInfoSource$1.load(ClassPathPackageInfoSource.java:47) at android.test.SimpleCache.get(SimpleCache.java:31) at android.test.ClassPathPackageInfoSource.getPackageInfo(ClassPathPackageInfoSource.java:72) at android.test.ClassPathPackageInfo.getSubpackages(ClassPathPackageInfo.java:48) at android.test.ClassPathPackageInfo.addTopLevelClassesTo(ClassPathPackageInfo.java:61) at android.test.ClassPathPackageInfo.getTopLevelClassesRecursive(ClassPathPackageInfo.java:55) at android.test.suitebuilder.TestGrouping.testCaseClassesInPackage(TestGrouping.java:156) at android.test.suitebuilder.TestGrouping.addPackagesRecursive(TestGrouping.java:117) at android.test.suitebuilder.TestSuiteBuilder.includePackages(TestSuiteBuilder.java:100) at android.test.InstrumentationTestRunner.onCreate(InstrumentationTestRunner.java:367) at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4388) at android.app.ActivityThread.access$1500(ActivityThread.java:143) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1317) at android.os.Handler.dispatchMessage(Handler.java:102) at android.os.Looper.loop(Looper.java:135) at android.app.ActivityThread.main(ActivityThread.java:5070) at java.lang.reflect.Method.invoke(Native Method) at java.lang.reflect.Method.invoke(Method.java:372) at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:836) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:631) Caused by: java.lang.ClassNotFoundException: Didn't find class "org.mockito.cglib.transform.AbstractProcessTask" on path: DexPathList[[zip file "/system/framework/android.test.runner.jar", zip file "/data/app/com.uphyca.mockitoonart.test-1.apk", zip file "/data/app/com.uphyca.mockitoonart-1.apk"],nativeLibraryDirectories=[/data/app-lib/com.uphyca.mockitoonart.test-1, /data/app-lib/com.uphyca.mockitoonart-1, /vendor/lib, /system/lib]] at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56) at java.lang.ClassLoader.loadClass(ClassLoader.java:511) at java.lang.ClassLoader.loadClass(ClassLoader.java:469) at java.lang.Class.classForName(Native Method) at java.lang.Class.forName(Class.java:308) at android.test.ClassPathPackageInfoSource.createPackageInfo(ClassPathPackageInfoSource.java:88) at android.test.ClassPathPackageInfoSource.access$000(ClassPathPackageInfoSource.java:39) at android.test.ClassPathPackageInfoSource$1.load(ClassPathPackageInfoSource.java:50) at android.test.ClassPathPackageInfoSource$1.load(ClassPathPackageInfoSource.java:47) at android.test.SimpleCache.get(SimpleCache.java:31) at android.test.ClassPathPackageInfoSource.getPackageInfo(ClassPathPackageInfoSource.java:72) at android.test.ClassPathPackageInfo.getSubpackages(ClassPathPackageInfo.java:48) at android.test.ClassPathPackageInfo.addTopLevelClassesTo(ClassPathPackageInfo.java:61) at android.test.ClassPathPackageInfo.getTopLevelClassesRecursive(ClassPathPackageInfo.java:55) at android.test.suitebuilder.TestGrouping.testCaseClassesInPackage(TestGrouping.java:156) at android.test.suitebuilder.TestGrouping.addPackagesRecursive(TestGrouping.java:117) at android.test.suitebuilder.TestSuiteBuilder.includePackages(TestSuiteBuilder.java:100) at android.test.InstrumentationTestRunner.onCreate(InstrumentationTestRunner.java:367) at android.app.ActivityThread.handleBindApplication(ActivityThread.java:4388) at android.app.ActivityThread.access$1500(ActivityThread.java:143) at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1317) at android.os.Handler.dispatchMessage(Handler.java:102) at android.os.Looper.loop(Looper.java:135) at android.app.ActivityThread.main(ActivityThread.java:5070) at java.lang.reflect.Method.invoke(Native Method) at java.lang.reflect.Method.invoke(Method.java:372) at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:836) at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:631) Suppressed: java.lang.NoClassDefFoundError: Failed resolution of: Lorg/apache/tools/ant/Task; at dalvik.system.DexFile.defineClassNative(Native Method) at dalvik.system.DexFile.defineClass(DexFile.java:222) at dalvik.system.DexFile.loadClassBinaryName(DexFile.java:215) at dalvik.system.DexPathList.findClass(DexPathList.java:321) at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:54) ... 27 more Caused by: java.lang.ClassNotFoundException: Didn't find class "org.apache.tools.ant.Task" on path: DexPathList[[zip file "/system/framework/android.test.runner.jar", zip file "/data/app/com.uphyca.mockitoonart.test-1.apk", zip file "/data/app/com.uphyca.mockitoonart-1.apk"],nativeLibraryDirectories=[/data/app-lib/com.uphyca.mockitoonart.test-1, /data/app-lib/com.uphyca.mockitoonart-1, /vendor/lib, /system/lib]] at dalvik.system.BaseDexClassLoader.findClass(BaseDexClassLoader.java:56) at java.lang.ClassLoader.loadClass(ClassLoader.java:511) at java.lang.ClassLoader.loadClass(ClassLoader.java:469) ... 32 more Suppressed: java.lang.ClassNotFoundException: org.apache.tools.ant.Task at java.lang.Class.classForName(Native Method) at java.lang.BootClassLoader.findClass(ClassLoader.java:781) at java.lang.BootClassLoader.loadClass(ClassL 08-12 14:09:50.424 18582-18582/com.uphyca.mockitoonart I/art﹕ Rejecting re-init on previously-failed class java.lang.Class<org.mockito.cglib.transform.AbstractProcessTask> 08-12 14:09:50.426 18582-18582/com.uphyca.mockitoonart W/ClassPathPackageInfoSource﹕ Cannot load class. Make sure it is in your apk. Class name: 'org.mockito.cglib.transform.AbstractTransformTask'. Message: org.mockito.cglib.transform.
Mockitoおよび依存ライブラリに存在する、JVMでは実行できるがDalvik/ARTでは実行できないクラスをロードしようとするとクラッシュしてしまうようなので、これらをロードしないようにします。
AndroidのInstrumentationTestRunnerはパッケージやクラスを明示せずに実行した場合、.apkファイルに含まれる全クラスをスキャンしてTestCaseのサブクラスを探します。この際にAndroidでは問題のあるクラスがロードされてクラッシュを引き起こします。そこでテストが含まれるパッケージ(マニフェストに記載するパッケージではなく、テスト対象のJavaクラス郡のパッケージ)を明示することでスキャンを抑止してい問題のあるクラスがロードされないようにします。
例えば、テストモジュールのパッケージが"com.uphyca.mockitoart"の場合、そのパッケージを明示してInstrumentaitonTestRunnerを構成します。(ここでは直に書いていますがシステム環境変数などを見るようにするほうが柔軟性があるでしょう)
パッケージを明示したInstrumentationTestRunner
package com.uphyca.mockitoonart; import android.os.Bundle; import android.test.InstrumentationTestRunner; public class MyInstrumentationTestRunner extends InstrumentationTestRunner{ private static final String ARGUMENT_TEST_PACKAGE = "package"; @Override public void onCreate(Bundle arguments) { arguments.putString(ARGUMENT_TEST_PACKAGE, "com.uphyca.mockitoonart"); super.onCreate(arguments); } }
このテストランナーをテストの実行に使うようにビルド構成を変更します。
apply plugin: 'com.android.application' android { defaultConfig { testInstrumentationRunner "com.uphyca.mockitoonart.MyInstrumentationTestRunner" }
これで、問題なくART環境でMockitoを使ったテストが実行できます。
Android Studioから実行するときは、テストの設定で先述のテストランナーを指定するのを忘れないようにしましょう。指定したパッケージとそのサブパッケージ、クラス単位でも実行できます。
本エントリの検証に使ったソースコードはgithubのMockitoOnARTから参照できます。
いじょう。
参考
- 9 http://t.co/eTrnslSqMD
- 3 http://t.co/SCLQGZiuh0
- 2 http://news.google.com/
- 1 http://api.twitter.com/1/statuses/show/499075213435236352.json
- 1 http://api.twitter.com/1/statuses/show/499076559269949441.json
- 1 http://bit.ly/1mEIxWx
- 1 http://blog.nkzn.info/entry/2014/03/21/015914
- 1 http://l.facebook.com/l.php?u=http://d.hatena.ne.jp/esmasui/20140812/1407823776&h=hAQFsuspZAQF_kDp5IM9vmNSwoPqcTb6YBVtytKHKIdw9CQ&s=1
- 1 http://t.co/d0D575q7jf
- 1 http://www.google.co.jp/search?hl=ja&lr=lang_ja&output=rss&q=Android&tbm=blg
- 2014-08-12 Cli@ 4/60 6%