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
ハマった点
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とします。
- KJDevice.java
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