Hatena::ブログ(Diary)

_development, RSSフィード Twitter

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の生成を、この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&#65109; 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&#65109; 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を構成します。

ここでは直に書いていますがシステム環境変数などを見るようにするほうが柔軟性があるでしょう。(現状サポートされていませんが将来的にGradleプラグインからテストランナーに引数を渡せるようになれば、このような派生クラスは不要です。)


パッケージを明示した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から実行するときは、テストの設定で先述のテストランナーを指定するのを忘れないようにしましょう。指定したパッケージとそのサブパッケージ、クラス単位でも実行できます。


本エントリの検証に使ったソースコードgithubMockitoOnARTから参照できます。

いじょう。


参考