メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

JavaからKotlinに変換してSupport LibraryとMockitoとRobolectricでハマった話とその解決方法

先日勤務先で開発合宿に行きました。プロダクトのKotlin化が途中だったので、仕上げてきました。

スライドでは省略した、ハマった点の詳細を共有します。

確認できるサンプルを用意してあります。サンプルの環境は次の通りです。

  • Android Studio: 2.3.3
  • Support Library: 25.3.1
  • Kotlin: 1.1.3-2
  • Mockito: 2.8.47
  • Robolectric: 3.3.2

github.com

ハマった点

  • Non-NullにNullが入る
  • Abstractクラスのテストで変更が反映されない
  • @Jvmアノテーションを消せない

Non-NullにNullが入る

JavaからKotlinへは、Android Studioの機能でファイル単位で変換できます。

Javaで定義されているメソッドを呼び出す際に、次のエラーが起きてアプリがクラッシュすることがあります。

Caused by: java.lang.IllegalArgumentException: Parameter specified as non-null is null: method kotlin.jvm.internal.Intrinsics.checkParameterIsNotNull

これはNon-Nullな引数にNullが入って起きます。

自分の環境では Activity#onActivityResult で起きました。

回避するには @Nullable アノテーションを付けておけば、変換時にも考慮されてNullableになります。

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
    }
  • Kotlin
    override fun onCreate(savedInstanceState: Bundle?) { // Nullableになってる
        super.onCreate(savedInstanceState)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) { // dataがNullableになっていない
        super.onActivityResult(requestCode, resultCode, data)
    }

Kotlinで定義されていればどこかで不整合が起きて気づくか、上の例外は発生しません。

プロジェクト内でKotlinからJavaを呼び出している箇所があればこれは起こり得るので、注意してください。

Abstractクラスのテストで変更が反映されない

Abstractクラスを継承したクラスで、変更したプロパティが反映されないというものがありました。

次のようなクラスです。要点だけ抜き出してます。

  • Abstractクラス
abstract class Ticket {
    abstract var memo: String
    ...
}
  • 継承したクラス
class MovieTicket(...) : Ticket() {
    override var memo: String = "memo"
    ...
}

こちらはこのような結果になります。

val mock = Mockito.mock(MovieTicket::class.java)
Assert.assertNull(mock.memo) // nullになる

mock.memo = "test memo"
Assert.assertNull(mock.memo) // 代入してもnullになる

似たようなものJavaで書きます。

  • Abstractクラス
public abstract class JTicket {
    @NonNull String memo;
    ...
}
  • 継承したクラス
public class JMovieTicket extends JTicket {

    JMovieTicket(...) {
        memo = "memo";
        ...
    }
    ...
}

こちらはこのような結果になります。

JMovieTicket mock = Mockito.mock(JMovieTicket.class);
Assert.assertNull(mock.memo); // nullになる

mock.memo = "test memo";
Assert.assertNotNull(mock.memo); // 代入されるのでNullにはならない

Javaは直接フィールドに、Kotlinはアクセサーを利用している違いが、この挙動の変化の理由です。

はじめに定義したKotlinをcompileしてからdecompileしてJavaへ変換してみます。便宜上KJTicketとしておきます。

  • Abstractクラス
public abstract class KJTicket {

    public KJTicket() {
    }

    public abstract String getMemo();
    public abstract void setMemo(String s);
    ...
}
  • 継承したクラス
public final class KJMovieTicket extends KJTicket {

    ....
    private String memo;

    public KJMovieTicket() {
        this(...);
    }

    public KJMovieTicket(...) {
        ...
        memo = "memo";
        ...
    }
    ...

    public String getMemo() {
        return memo;
    }

    ...
    public void setMemo(String s) {
        Intrinsics.checkParameterIsNotNull(s, "<set-?>");
        memo = s;
    }
}

KJTicketクラスにはフィールドがありません。そして、継承したKJMovieTicketにフィールドが生成されていますが、privateになっています。なので、フィールドには、メソッドでアクセスしていることになります。

このフィールドはBacking fieldsと呼ばれるもので、自動で生成されるものです。 Backing fieldsにアクセスするメソッドをアクセサーと言います。

このクラスを使うとこのような結果となります。

KJMovieTicket mock = Mockito.mock(KJMovieTicket.class);
Assert.assertNull(mock.getMemo()); // nullになる

mock.setMemo("test memo");
Assert.assertNull(mock.getMemo()); // 代入してもnullになる

無事Kotlinのときと同じ結果になりました。

MockitoのMockは、実際のメソッドを呼ばないので、アクセサーがうまく機能していません。

なので、MockをMockito.CALLS_REAL_METHODSオプションを付けて生成することで、実際のメソッドを呼ぶようにしてあげます。

val mockWithOption = Mockito.mock(MovieTicket::class.java, Mockito.CALLS_REAL_METHODS)
Assert.assertNull(mockWithOption.memo) // nullになる

val memo = "test memo"
mockWithOption.memo = memo
Assert.assertEquals(memo, mockWithOption.memo) // test memoが代入されてる

これでうまくいきます。

もっとシンプルに解決したい場合はSpyすれば良いのですが、インスタンスを作れない制約が既存コードにあったので、このような解決方法を取りました。

見出しでAbstractクラスのテストと書いたのですが、これは普通のクラスでもKotlinなら起きます

  • 普通のクラス
class CouponTicket(var memo: String = "coupon")
  • Mockを用いたテスト
val mock = Mockito.mock(CouponTicket::class.java)
Assert.assertNull(mock.memo) // nullになる

mock.memo = "test memo"
Assert.assertNull(mock.memo) // 代入してもnullになる

Abstractクラスでもmockを作れます。 Abstractクラスでは、Mockito.CALLS_REAL_METHODSオプションを付けてもnullになります。まだ実装されてないので、何もしないメソッドが呼ばれるだけだからです。

val mockWithOption = Mockito.mock(Ticket::class.java, Mockito.CALLS_REAL_METHODS)
Assert.assertNull(mockWithOption.memo) // nullになる
        
val memo = "test memo"
mockWithOption.memo = memo
Assert.assertNull(mockWithOption.memo) // nullになる

Mockito.when を使うことでメソッドをmockできます。

val mock = Mockito.mock(Ticket::class.java)
Assert.assertNull(mock.memo) // nullになる

val memo = "test memo"
Mockito.`when`(mock.memo).thenReturn(memo) // メソッドの返り値を置き換えている
Assert.assertEquals(memo, mock.memo) 

@Jvmアノテーションを消せない

フルKotlinなら、Jvm系のアノテーションが削除できると思って、全て削除したところ一部期待通りの挙動にならない部分がありました。

Androidに依存する部分のtestを、ReobolectricのShadowを使って置き換えていた部分で期待通りに動きませんでした。

  • Device.kt
object Device {
    /**
     * 端末の日付の設定を自動にしているかどうか。
     * */
    fun enabledAutoTime(context: Context): Boolean {
        try {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                return Settings.Global.getInt(context.contentResolver, Settings.Global.AUTO_TIME) > 0
            } else {
                return Settings.System.getInt(context.contentResolver, Settings.System.AUTO_TIME) > 0
            }
        } catch (e: Settings.SettingNotFoundException) {
            return false
        }
    }
}

Device#enabledAutoTimeは、Roblectricで呼ぶとSettings.SettingNotFoundExceptionが発生して常にfalseになってしまうので、Shadowを作成して挙動を制御していました。

ObjectのままShadowを作成するとpublicなコンストラクタがないのでエラーになります。

Caused by: java.lang.RuntimeException: Could not instantiate shadow, missing public empty constructor.
    at org.robolectric.internal.bytecode.ShadowWrangler.getShadowCreator(ShadowWrangler.java:385)
    at org.robolectric.internal.bytecode.RobolectricInternals.getShadowCreator(RobolectricInternals.java:34)
    at org.robolectric.internal.bytecode.InvokeDynamicSupport.bindInitCallSite(InvokeDynamicSupport.java:115)
    at org.robolectric.internal.bytecode.InvokeDynamicSupport.bootstrapInit(InvokeDynamicSupport.java:53)
    at java.lang.invoke.CallSite.makeSite(CallSite.java:283)
    at java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:307)
    at java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:297)
    at com.sakebook.android.sample.cornercasetestsample.Device.$$robo$init(Device.kt)
    at com.sakebook.android.sample.cornercasetestsample.Device.<init>(Device.kt)

そのため、次のようなShadowを作成しました。

  • ShadowDevice.kt
@Implements(Device::class)
class ShadowDevice {

    companion object {
        @Implementation
        fun enabledAutoTime(context: Context): Boolean {
            return true
        }
    }
}

こちらも、KotlinからJavaに変換してみました。便宜上KJDeviceとします。

public final class KJDevice {

    private KJDevice() {
    }

    static {
        new KJDevice();
    }

//    public static final KJDevice INSTANCE = (KJDevice)this;
    public static final KJDevice INSTANCE = new KJDevice();


    public final boolean enabledAutoTime(Context context) {
        Intrinsics.checkParameterIsNotNull(context, "context");
        try {
            if(android.os.Build.VERSION.SDK_INT >= 17) {
                return android.provider.Settings.Global.getInt(context.getContentResolver(), "auto_time") > 0;
            } else {
                return Settings.System.getInt(context.getContentResolver(), "auto_time") > 0;
            }
        } catch (Settings.SettingNotFoundException e) {
            return false;
        }
    }
}

コンストラクタがPrivateになっています。 コメントアウトしているところは表現できなかったのでコメントアウト直後のものに変えてます。

こちらもJavaから呼び出すとわかるのですが、 enabledAutoTime を呼び出すとこうなります。

KJDevice#INSTANCE#enabledAutoTime(context)

staticメソッドになっていないので、INSTANCE越しに呼び出すことになります。

ObjectはそのままShadowを作成できないので、classでShadowを作成する必要があります。その際、 @JvmStaticアノテーション を付けないと、Objectで定義しているメソッドと同じSyntaxにならないので、Shadowでの置き換えがされません。

利用しているクラスも、ShadowクラスもKotlinで書いていても、こちらは`@JvmStaticアノテーションが必要になります。

まとめ

長々と書きましたが、ハマった点の要点は以下です。

  • KotlinからJavaを呼び出している箇所でNon-NullにNullが入る可能性があるので、可能ならば@Nullableアノテーションを付けてからJava -> Kotlin変換をする
  • Kotlinはプロパティのアクセスはメソッドを利用するので、MockするときはMockito.CALLS_REAL_METHODSを使う
  • ObjectのShadowを作るときはClassで作成し、Syntaxが同じになるように@JvmStaticアノテーションをつける

Javaからどう見えるかというところを意識する必要があったり、言語仕様的な部分を意識する必要がたまにあるのですが、最終的に腑に落ちたので良いハマり方をしました。

参考

海で開発!? 2泊3日で伊豆に開発合宿行ってきました! / JX通信社 エンジニアブログ

Null Safety / Kotlin Programming Language

KotlinコードをJavaコードに変換してみた / アクトインディ技師部隊報告書

Working with the Command Line Compiler / Kotlin Programming Language

apkをコマンド一つでjavaにデコンパイルする / Qiita

Properties and Fields / Kotlin Programming Language

心地良すぎるモックライブラリ Mockito 〜その3〜 / A Memorandum

Robolectric 3.0基本のキ / Qiita