Edited at

Android + Kotlinアプリで、Robolectric + Mockitoを使ったテスト環境を整えるときにハマったこと総まとめ


環境

以下のライブラリ等を使っています。


  • Android Studio 2.3.3

  • AndroidのcompileSdkVersion 26

  • Kotlin 1.1.50


  • Robolectric 4.3.2


  • Mockito-Kotlin 1.5.0


    • 内部で使用されているMockitoは 2.8.9




動機

KotlinでAndroidアプリを書いています。

今まではほとんどテストがなかったので、ちゃんと動くテストを書こうと思いたちました。

Android実機(もしくはエミュレーター)上で実行する Instrumented Test は、dexを作ったり実機に転送したりと、何かとテスト実行まで時間がかかります。

そこで、JVM上でテストを実行する Local Test をしようと決めました。

Local Testを実行する際はAndroidのランタイムがないので、Android依存のクラスなどを使うと例外が出て死んでしまいます。

そこで、Androidランタイムが持っているクラス類をモックしてくれる Robolectric を使用します。

自作のクラスもモックするため、Mockitoを導入することにしました。

全部 dependencies に記入して、さあこれで動くだろう!

と思ったら大間違いでした


問題

大きく、以下の問題に突き当たりました。


  1. RobolectricのRunnerを使うと VerifyError で落ちる


  2. data class をモックできない


RobolectricのRunnerを使うと VerifyError で落ちる

公式の説明通り@RunWith(RobolectricTestRunner.class) を設定したテストクラスを作成して、テストを実行します。

@RunWith(RobolectricTestRunner::class)

@Config(constants = BuildConfig::class, application = TestApp::class)
class HogeTest {
@Test
fun hogeTest() {
assertEquals(4, 2 + 2)
}
}

すると死にます。

java.lang.VerifyError: Expecting a stackmap frame at branch target 35

Exception Details:
Location:
com/sessionm/api/ConnectionReceiver.onReceive(Landroid/content/Context;Landroid/content/Intent;)V @6: ifeq
Reason:
Expected stackmap frame at this location.
Bytecode:
0x0000000: 1202 06b8 0011 9900 1d12 02b2 000e 1201
0x0000010: 05bd 000a 5903 2c53 5904 2a53 b800 17b8
0x0000020: 0010 57b8 0014 4e2d b600 16b6 0013 9a00
0x0000030: 04b1 2db6 0015 bb00 0659 2a2d 2bb7 0012
0x0000040: b900 1802 00b1

at java.lang.Class.getDeclaredConstructors0(Native Method)
at java.lang.Class.privateGetDeclaredConstructors(Class.java:2671)
at java.lang.Class.getConstructor0(Class.java:3075)
at java.lang.Class.getDeclaredConstructor(Class.java:2178)
at org.robolectric.util.ReflectionHelpers.callConstructor(ReflectionHelpers.java:320)
at org.robolectric.internal.bytecode.ShadowImpl.newInstanceOf(ShadowImpl.java:21)
at org.robolectric.shadow.api.Shadow.newInstanceOf(Shadow.java:36)
at org.robolectric.shadows.ShadowApplication.registerBroadcastReceivers(ShadowApplication.java:134)
at org.robolectric.shadows.ShadowApplication.bind(ShadowApplication.java:123)
at org.robolectric.shadows.CoreShadowsAdapter.bind(CoreShadowsAdapter.java:49)
at org.robolectric.android.internal.ParallelUniverse.setUpApplicationState(ParallelUniverse.java:119)
at org.robolectric.RobolectricTestRunner.beforeTest(RobolectricTestRunner.java:293)
at org.robolectric.internal.SandboxTestRunner$2.evaluate(SandboxTestRunner.java:222)
at org.robolectric.internal.SandboxTestRunner.runChild(SandboxTestRunner.java:110)
at org.robolectric.internal.SandboxTestRunner.runChild(SandboxTestRunner.java:37)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.robolectric.internal.SandboxTestRunner$1.evaluate(SandboxTestRunner.java:64)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:117)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:42)
at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:262)
at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:84)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)


解決策

調べたところ、原因はJVMのクラスローダーによるクラスの検査が厳密化されたことで、JRE 8からこのようになるようです。

使っているJavaは確かにJRE 8でした。

$ java -version

java version "1.8.0_121"
Java(TM) SE Runtime Environment (build 1.8.0_121-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.121-b13, mixed mode)

こちらのStackOverflowに書かれている通り、検査を無効化してやることでこの問題は回避できます。

Android StudioのJUnitランナーを使っている場合は、 Edit Configurations... からVM Optionsに -noverify を指定します。

Gradleの testXXXUnitTest タスクを使う場合は、build.gradleにオプションの記入が必要です。

android {

testOptions {
unitTests {
all.jvmArgs = "-noverify"
}
}
}

これで VerifyError は消えました!!


data class をモックできない

APIのレスポンスなど、パラメータが多くネストも深い data classがありました。

data class ですので自力で引数を入れてインスタンス化しても良いのですが、流石にパラメーターが無茶苦茶多いdata classを手でインスタンス化する気は起きません。

(テストをやり始めようという人間ですらこう思うのですから、後からテストをいじる人はもっと嫌がってテストを書いてくれなくなるかも…それは避けたい)

そこでモックを試みます。

data class ComplexData(...)

val complexData: ComplexData = mock()

すると死にます。

org.mockito.exceptions.base.MockitoException: 

Cannot mock/spy class net.kikuchy.example.ComplexData
Mockito cannot mock/spy because :
- final class

Kotlinの data class はfinalが付いているのと同じで、継承できません。

じゃあ open にすればいい!!

ちなみに opendata の順番を逆にしても変わりません。

どうしろと言うの!


解決策

Mockitoはデフォルトではfinal classをモックできません。

しかしMockito 2.1.0からfinal classをモックする機能があります

(最初はPowerMockでモックしようとして、そちらも別のエラーで使えなかったところだった)

外山さんに教えていただきました。感謝!

この機能は意図的に有効にしないと使えないそうですので、有効にします。

公式に書かれたこの方法はAndroidプロジェクトでは使用できないので、

代わりにこちらのStackOverflowに書かれたライブラリを導入することで解決します

dependencies {

testCompile "org.mockito:mockito-inline:2.8.9"
}

これで data class も、ついでに他の open がついていないクラスもモックできるようになりました!


余談:他にも苦しめられたこと

本題とはずれますが、以下の問題にも苦しめられました。


  1. ThreeTenABPを使っていると、「複数回初期化するな」と怒られる

  2. AndroidSchedulersを使っていると、いつまで経ってもSubscriberに値がやってこない


ThreeTenABPを使っていると、「複数回初期化するな」と怒られる

こちらのIssueに書かれている例外が、バージョン1.0.5でも起きました。

1.0.5ではすでに直っているはずですし、コードを見ても初期化フラグの実装はAtomicBooleanで、同時に更新されないようになっている模様…

Dependenciesが秘伝のタレと化してきているプロジェクトでもあるので、どこかで誰かが悪さしているのかもしれない…


解決策

こちらのIssueのコメントにある通り、使用するApplicationサブクラスを差し替えます。

@RunWith(RobolectricTestRunner::class)

@Config(constants = BuildConfig::class, application = TestApp::class)
class HogeTest {
...

テストコード中でThreeTenABPのAPIを叩かなければこれ以上のことをする必要もありません。

とりあえずこれでテストコードが動くようになりました!


AndroidSchedulersを使っていると、いつまで経ってもSubscriberに値がやってこない

テスト対象のコードの一部に observeOn(AndroidSchedulers.mainThread()) をしているStreamがあり、そのStreamのonNextがいつまで経っても呼ばれずタイムアウトするということがありました。

// 例えばテスト対象クラスがこんな感じで

class TestTarget {
private val publisher: PublishSubject = ...
val eventStream: Observable
get() = publisher.asObservable()

fun doHoge() {
getHogeFromRemote() // もちろん用意したダミーの値を返すようにしてある
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { result -> publisher.onNext(result)}
}
}

// テストコードがこんな感じ
@Test
fun testHoge() {
val target = TestTarget()
target.doHoge()
val events = target
.eventStream
.take(1)
.timeout(1, TimeUnit.SECONDS) // 値が落ちてこないのでタイムアウトする
.toBlocking()
assertEquals(events.last(), expect)
}


解決策

使用するSchedulerをコンストラクタ引数などから差し替えられるようにしても良いのですが、ちょっと今回は大変だったので、別の方法を取ることにしました。

黒川さんのブログにある通り、テスト時だけ使用するschedulerを別のものに差し替えます。

これで非同期のテストもバッチリ検証できるようになりました!!


まとめ

Robolectric の導入は意外と面倒でした。

また、MockitoはfinalクラスまでモックできるようにするとKotlinでは便利です。

(本当のところ、finalなものを無理にモックするのは良くないと思っているのですが…)

ただ、 新規に作ったプロジェクトに導入しても特にひっかかることはなかった ので、

プロジェクトがまだ作りたてのうちにテスト環境を用意しましょう!!!!!!