AndroidのJUnitテストは超ムズい
前編。AndroidのJUnitテストは超ムズいからの続きとなります。
それぞれのテストライブラリの用途について考えてみる。
前編でAndroidは複数のテストライブラリがあり、それぞれ得意なテスト、不得意(できない)テストがあるというのは説明しました。ここでは、それぞれのテストライブラリの用途について考えてみましょう。
-
Roboletric
最大の特徴はAndroid APIを呼び出しているクラスでもlocal test(ローカルなJVM上でのテスト)が可能ということです。その仕組みは、Robolectricは独自にShadowという仕組みを持っています。(mockとは違う)RobolectricTestRunnerがテストの実行時、JVMをフッキングし、クラスローダーがAndroid コンポーネントをロードするのではなく、RobolectricのShadowオブジェクトをロードするようにします。- local testなので軽い
- Android APIをShadowに置き換えているが、全てのAndroid APIをサポートしているわけではない。
- Shadowを自作できるが、その方法が難しい。
- 公式ドキュメントが少々怪しい。3.x台と4.x台があり、書き方が少々違う。
- Google公式ではないが、一応Android公式からリンクは貼られている。(準公式みたいな扱いか?)
- UIの部品がどこまでテストできるのかがよくわからない。EditTextに文字入力して、ボタンを押すくらいはできるが、Fragment遷移、RecyclerViewとAdapter、等々。あまり細かいところまではできなさそうな感じ。(Shadowを使っているせいもあるのだろうか?)
-
Espresso
主にUIのホワイトボックステストに用いる。Roboletricよりは細かいUI部品のテストができそう。- Instrument testなので少々重い。
一応、公式では
Espresso は、テストで onView() が呼び出されるたびに、以下の同期条件が満たされるまで、関連する UI アクションやアサーションの実行を停止して待機します。
・メッセージ キューが空になる。
・タスクを実行している AsyncTask のインスタンスがなくなる。
・開発者が定義したアイドリング リソースがすべてアイドル状態になる。
と書かれているのでonViewでUI部品取得する前には可能な限り非同期処理は吐き出してしまうような仕組みがあるようだ。
- UI Autometer
主にUIのブラックボックステストに用いる。
システムアプリとインストール済みアプリにまたがるアプリ間のテストが可能。複数のアプリを渡り歩いてテストができるので、アプリ間の連携を確認できる。例えば、カメラアプリで撮影した画像がフォトビューワーアプリで表示できることを確認するとができる。- Instrument testなので少々重い。
以上をふまえてテスト戦略的には
- UIに関係ないクラス(Utilクラス等)をlocal testで潰す。
- Robolectricでlocal testでできるところまでやる。
- EspressoでRobolectricで、できないテストをInstrument testをやる。
- アプリ間の連携があるならUI AutometerでInstrument testをやる。
- E2EテストをやりたいならUI AutometerでInstrument testをやる。
という順番になるかと思います。今のところ2.と3.の途中までの段階です。以降は順次公開していきたいと思います。
具体例、RoboletricでRecyclerViewのテストがどこまでできるか?
RoboletricでRecyclerViewのテストの例です。
@RunWith(AndroidJUnit4::class)
class MyFragmentTest {
@get:Rule
val mockkRule = MockKRule(this)
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun searchTest999() {
// 実行
val scenario = launchFragmentInContainer<MyFragment>()
scenario.onFragment { fragment ->
val recyclerView = fragment.view?.findViewById<RecyclerView>(R.id.searchView)
// assert
assertThat(recyclerView?.adapter?.itemCount).isEqualTo(4) // ★1
assertThat(recyclerView?.findViewHolderForAdapterPosition(0)).isNotNull()
assertThat(recyclerView?.findViewHolderForAdapterPosition(1)).isNotNull()
assertThat(recyclerView?.findViewHolderForAdapterPosition(2)).isNotNull()
assertThat(recyclerView?.findViewHolderForAdapterPosition(3)).isNotNull() // ★2
}
}
}
今、RecyclerViewのAdapterには4件設定されているような条件になっています。★1のadapter?.itemCountは4件でassertは成功していますが、★2でfailします。
件数を色々変えて試してみましたがどうやら findViewHolderForAdapterPosition だと先頭の3件しか取ってきていないようです。(こういうのがSahdowの制約なのかもしれない)
これだとテストになりません。
class MyAdapter: RecyclerView.Adapter<MyAdapter.ViewHolder>() {
companion object {
private val DIFF_CALLBACK = object : DiffUtil.ItemCallback<MyResult>() {
override fun areItemsTheSame(oldItem: MyResult, newItem: MyResult): Boolean {
・・・
}
override fun areContentsTheSame(oldItem: SearchResult, newItem: SearchResult): Boolean {
・・・
}
}
}
private val mdiffer: AsyncListDiffer<MyResult> = AsyncListDiffer(this, DIFF_CALLBACK)
class ViewHolder(binding: MyViewBinding): RecyclerView.ViewHolder(binding.root){
var field1: TextView // ★2
var field2: TextView // ★2
}
fun currentList() = mdiffer.currentList // ★1
}
RecyclerView.AdapterにAsyncListDifferを使用しています。このAsyncListDiffer.currentListをAdapterクラスの外から参照できるようにするしかありません。(★1)
ViewHolderのフィールドも具体的な値をassertしたいでしょうから、ここはprivateではなくpublicにする必要があります。(★2)
テストケースのコードはこのようになります。
assertThat(recyclerView?.adapter?.itemCount).isEqualTo(4)
// 1件目
assertThat((recyclerView?.adapter as MyAdapter).currentList().get(0)?.field1).isEqualTo("1111111111")
assertThat((recyclerView?.adapter as MyAdapter).currentList().get(0)?.field2).isEqualTo("AAAAAA")
・・・
// 4件目
assertThat((recyclerView?.adapter as MyAdapter).currentList().get(3)?.field1).isEqualTo("1111111111")
assertThat((recyclerView?.adapter as MyAdapter).currentList().get(3)?.field2).isEqualTo("AAAAAA")
テストの為にプロダクションコードを書き換えるというのは本末転倒なのですが、致し方ありません。
具体例、同一プロジェクトでRobolectricとEspressoの両方を実行できるようにbuild.gradleを設定する。
Androidはどういうテストをするかによって、テストライブラリを、複数使い分けなければいけません。当然、同一プロジェクトでRobolectricとEspressoの両方を実行できるよう設定する必要があります。
前編。AndroidのJUnitテストは超ムズいで、FragmentScenarioを依存ライブラリに追加しました。
debugImplementation 'androidx.fragment:fragment-testing:1.5.7'
この状態で空っぽのInstrument testを実行すると
Process: jp.co.suzuken.medication, PID: 9446
java.lang.NoClassDefFoundError: Failed resolution of: Landroidx/test/platform/io/PlatformTestStorageRegistry;
at androidx.test.internal.runner.RunnerArgs$Builder.<init>(RunnerArgs.java:248)
at androidx.test.runner.AndroidJUnitRunner.parseRunnerArgs(AndroidJUnitRunner.java:393)
at androidx.test.runner.AndroidJUnitRunner.onCreate(AndroidJUnitRunner.java:302)
at android.app.ActivityThread.handleBindApplication(ActivityThread.java:6704)
at android.app.ActivityThread.access$1300(ActivityThread.java:237)
at android.app.ActivityThread$H.handleMessage(ActivityThread.java:1913)
at android.os.Handler.dispatchMessage(Handler.java:106)
at android.os.Looper.loop(Looper.java:223)
at android.app.ActivityThread.main(ActivityThread.java:7656)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:592)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:947)
Caused by: java.lang.ClassNotFoundException: Didn't find class "androidx.test.platform.io.PlatformTestStorageRegistry" on path: DexPathList[[zip file
何やら、クラスが無いと言われます。
androidx.test.platform.io.PlatformTestStorageRegistry
しばらく色々試行錯誤していましたが、これはFragmentScenarioのライブラリのバージョンを変えたらあっさり、解決しました。
- debugImplementation 'androidx.fragment:fragment-testing:1.5.7'
+ debugImplementation 'androidx.fragment:fragment-testing:1.6.0'
他のライブラリとの依存関係だったのかもしれません。build.gradleのテスト関連の依存関係の全体は以下の様になります。(今のところ、ライブラリは全て最新)この状態で、FragmentScenarioはlocal testとInstrument testの両方で使えます。
debugImplementation 'androidx.fragment:fragment-testing:1.6.0'
testImplementation 'androidx.test.ext:junit-ktx:1.1.5'
testImplementation 'junit:junit:4.13.2'
testImplementation 'io.mockk:mockk:1.13.5'
testImplementation 'io.mockk:mockk-android:1.13.3'
testImplementation 'io.mockk:mockk-agent:1.13.3'
testImplementation "com.google.truth:truth:1.1.4"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
testImplementation 'androidx.arch.core:core-testing:2.2.0'
testImplementation 'org.slf4j:slf4j-api:2.0.7'
testImplementation 'org.slf4j:slf4j-simple:2.0.7'
testImplementation 'org.robolectric:robolectric:4.10.3'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
androidTestImplementation 'androidx.test:core:1.5.0'
androidTestImplementation 'androidx.test:core-ktx:1.5.0'
androidTestImplementation 'androidx.test:runner:1.5.2'
androidTestImplementation 'androidx.test:rules:1.5.0'
androidTestImplementation 'io.mockk:mockk-android:1.13.3'
androidTestImplementation 'io.mockk:mockk-agent:1.13.3'
androidTestImplementation 'com.google.truth:truth:1.1.4'
androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
やりかたがわからない
RecycleViewを左右にスワイプして、ItemTouchHelper でイベントを拾うような場合。
Robolectricで左右にスワイプって、どうやるのか?わからない。