こんにちは。SWETグループの外山(@sumio_tym)です。
先日、DroidKaigi 2020で発表予定だったセッション「Robolectricの限界を理解してUIテストを高速に実行しよう」 の動画がYouTubeのDroidKaigiチャンネルで公開されました。
新型コロナウイルスの影響でDroidKaigi 2020が中止になってしまったのは残念でしたが、 発表したかった内容を皆さんに伝えることができて、とても嬉しいです。
発表スライドはこちらです。
合わせてサンプルコードも公開していますので、よろしければご覧ください。
さて、本記事では「RobolectricでもUIテストを動かしてみたいけど・・・動画を見る時間がない!」という方に向けて、 ぜひ押さえておきたいポイントを厳選してお伝えします1。
- 試してみる前に知っておきたいポイント
- テストがすぐ書ける環境を構築する
- 工夫が必要なJetpackコンポーネント
なお「何故そうしなければならないのか」といった理由や背景についての解説は割愛しています。 その点に興味がある方は動画やスライドを参照してください。
試してみる前に知っておきたいポイント
Robolectric上でUIテストを動かそうとする前に、最低限知っておきたいポイントは次の3点です。
- 最初はInstrumented Testで動かす
- 動かないと分かっているのを避ける
- ひとつの画面で完結するものから着手する
これらのポイントを事前に押さえておけば、大きな手戻りが発生するリスクを抑えられます。 「すぐに試してみたい!」という方もご一読ください。
ポイント1:最初はInstrumented Testで動かす
テストを高速に実行できるRobolectricですが、UIテスト実行における大きな弱点は「画面が無い」点です。 画面が無いため、テストコードが意図通り動作しているのか(=意図通りUIを操作しているのか)目視で確認できません。
そのため、同じテストコードをInstrumented Testでも動かせるようにしておきましょう。 Instrumented Testであれば、テストの動作状況を画面を見ながら確認できます。
特に、テストコードを書いている途中では、画面を目視しながらの動作確認は必須と言えます。 最初はInstrumented Testで動くようにテストを書き、その後にRobolectricでも動くようにしていく方法がおすすめです。 テストコードの具体的な配置方法については後で紹介します。
ポイント2:動かないと分かっているものを避ける
発表時点の調査で、Robolectricで意図通り動かないと分かっているものは次の通りです。
- ナビゲーションドロワー(NavigationView)の操作
- Paging Library を使ったRecyclerViewなどで、データを取得する前のリストアイテムの表示や操作
- EspressoのDrawerActions API
- EspressoのNavigationViewActions API
Robolectricで動かすUIテストでは、これらの操作は避けましょう。
ポイント3:ひとつの画面で完結するものから着手する
事前に動かない操作を知っていたとしても、テストを書き進めると意図せずそのような操作に直面してしまうことがあります。 そのリスクを避けるために、できるだけ操作するステップが少ないテストから書き始めましょう。 複数の画面をまたがるテストだと操作ステップが増えがちなので、ひとつの画面で完結するものから始めるのがおすすめです。
テストがすぐ書ける環境を構築する
事前に知っておきたいポイントを押さえたところで、テストがすぐ書ける環境を構築していきます。 既にプロダクトコードをAndroid Studioでビルドできる環境があることを前提とします。
ここで説明する手順に沿って構築すれば、次のすべてが揃った環境を入手できます。
- IdlingResourceに対応するように筆者が改変したRobolectric
- DataBindingの非同期処理を待ち合わせるDataBindingIdlingResource2
- Architecture Componentのバックグラウンドスレッドを待ち合わせるTaskExecutorWithIdlingResourceRule
- Instrumented TestとRobolectricが動作するLocal Testでテストコードを共有できるShared Test
手順1:必要なファイルをコピーする
サンプルコードをcloneし、次のディレクトリをご自身のプロジェクトにコピーします。
そのときに、コピー先も同じディレクトリ構成にしてください。
たとえばapp/src/test/java/androidx
ディレクトリは、
ご自身のプロジェクト側でもapp/src/test/java/androidx
ディレクトリとしてコピーしてください3。
app/local-repo
app/src/test/resources
app/src/test/java/androidx
app/src/sharedTest/java/com/android/example/github/util
app/src/sharedTest/java/com/example/android/architecture/blueprints/todoapp/util
手順2:build.gradle
に追記する
app/local-repo
ディレクトリを、Maven形式のリポジトリ参照先として追加します。
app/build.gradle
に、次のように追記してください。
repositories {
maven { url = file('local-repo') }
}
トップレベルのbuild.gradle
に書くこともできます。
その場合はfile('app/local-repo')
のように、local-repo
ディレクトリの相対パスが正しくなるように注意してください。
次に、Robolectricを使えるようにするためのandroid.testOptions.unitTests.includeAndroidResources
をtrue
にセットします。
app/build.gradle
に、次のように追記してください
android { ... testOptions { unitTests.includeAndroidResources = true ... } }
次に、Shared Testを置くapp/src/sharedTest
ディレクトリをInstrumented TestとLocal Test両方のソースセットに加えます。
app/build.gradle
に、次のように追記してください。
android { ... sourceSets { androidTest { java.srcDirs += file('src/sharedTest/java') } test { java.srcDirs += file('src/sharedTest/java') } } }
最後に、依存関係に必要なライブラリを追加します。
Robolectricはapp/local-repo
のものを参照する必要があるので、バージョン表記を4.3.1-modified
としてください。
なお、このRobolectricはEspressoのIdlingResourceに対応するように筆者が改変したものです。詳しくはスライドの34〜57ページを参照してください。
dependencies { ... androidTestImplementation "androidx.test:core-ktx:1.2.0" androidTestImplementation "androidx.test.ext:junit-ktx:1.1.1" androidTestImplementation "androidx.test.espresso:espresso-contrib:3.2.0" androidTestImplementation "androidx.test.espresso.idling:idling-concurrent:3.2.0" androidTestImplementation "androidx.arch.core:core-testing:2.0.0" testImplementation "org.robolectric:robolectric:4.3.1-modified" testImplementation "androidx.test:core-ktx:1.2.0" testImplementation "androidx.test.ext:junit-ktx:1.1.1" testImplementation "androidx.test.espresso:espresso-contrib:3.2.0" testImplementation "androidx.test.espresso.idling:idling-concurrent:3.2.0" testImplementation "androidx.arch.core:core-testing:2.0.0" }
これらのモジュールが提供する主な機能は次の通りです。
モジュール | 主な提供機能 |
---|---|
androidx.test:core-ktx |
Activity の起動を伴うテスト |
androidx.test.ext:junit-ktx |
AndroidJUnit4 |
androidx.test.espresso:espresso-contrib |
Espresso |
androidx.test.espresso.idling:idling-concurrent |
EspressoのIdling Resource |
androidx.arch.core:core-testing |
Android Architecture Componentのテスト |
org.robolectric:robolectric |
Robolectric |
Robolectric以外のモジュールは、Local TestとInstrumented Testの両方で使うため、
androidTestImplementation
とtestImplementation
で宣言します。
RobolectricはLocal Testだけで使いますので、testImplementation
のみで宣言します。
手順3:テストクラスを用意する
テストクラスは、Instrumented Test向け・Local Test向けのものをそれぞれ用意します。 ここで紹介する内容をコピー&ペーストして、クラス名とテスト対象Activity名を置換すれば、すぐに使い始められます。
Instrumented Test向けのテストクラス
Instrumented Test向けの典型的なテストクラス定義は次の通りです。
このクラス定義をapp/src/androidTest
に置いてください。
import androidx.test.espresso.IdlingRegistry import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.example.github.util.TaskExecutorWithIdlingResourceRule import com.example.android.architecture.blueprints.todoapp.util.DataBindingIdlingResource import com.example.android.architecture.blueprints.todoapp.util.monitorActivity import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class MyActivityTest { @get:Rule val activityScenarioRule = activityScenarioRule<MyActivity>() @get:Rule val taskExecutorWithIdlingResourceRule = TaskExecutorWithIdlingResourceRule() private val dataBindingIdlingResource = DataBindingIdlingResource() // ★ @Before fun setUp() { val idlingRegistry = IdlingRegistry.getInstance() // ★ dataBindingIdlingResource.monitorActivity(activityScenarioRule.scenario) // ★ idlingRegistry.register(dataBindingIdlingResource) // ★ } @After fun tearDown() { IdlingRegistry.getInstance().unregister(dataBindingIdlingResource) // ★ } @Test fun myTest() { // app/src/sharedTest/java にあるテストコード共通部分を呼び出す } }
これを雛形にすれば、AAC (Android Architecture Components)が使われたアプリのテストはだいたいカバーできると思います。
なお、★印を付けたdataBindingIdlingResource
に関係する処理は、Data Bindingを使っていない場合は不要です。
Local Test向けのテストクラス
次はLocal Testにおけるテストクラス定義です。
このクラス定義はapp/src/test
に置いてください。
import androidx.test.espresso.IdlingRegistry import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.example.github.util.TaskExecutorWithIdlingResourceRule import com.example.android.architecture.blueprints.todoapp.util.DataBindingIdlingResource import com.example.android.architecture.blueprints.todoapp.util.monitorActivity import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.LooperMode @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) class RobolectricMyActivityTest { @get:Rule val activityScenarioRule = activityScenarioRule<MyActivity>() @get:Rule val taskExecutorWithIdlingResourceRule = TaskExecutorWithIdlingResourceRule() private val dataBindingIdlingResource = DataBindingIdlingResource() // ★ @Before fun setUp() { val idlingRegistry = IdlingRegistry.getInstance() // ★ dataBindingIdlingResource.monitorActivity(activityScenarioRule.scenario) // ★ idlingRegistry.register(dataBindingIdlingResource) // ★ } @After fun tearDown() { IdlingRegistry.getInstance().unregister(dataBindingIdlingResource) // ★ } @Test fun myTest() { // app/src/sharedTest/java にあるテストコード共通部分を呼び出す } }
ほとんどInstrumented Testと同じですが、以下の点に注目してください。
- テストクラス名がInstrumented Testのものと違う
現状では、app/src/androidTest
とapp/src/test
で同じ名前のクラスを定義するとAndroid Studio上でエラーになってしまいます(バージョン3.6.3で確認)。 エラーを無視してビルドはできるものの、IDE上でエラーのままコードを書いていくのは苦痛です。 テストクラス名は別の名前にすることを推奨します。 @LooperMode
アノテーションが付いている
Robolectricのタスクスケジューラの種類をPAUSED
にしています。 詳しく知りたい方はスライドの22〜26ページを参照してください。
こちらも、★印を付けたdataBindingIdlingResource
に関係する処理は、Data Bindingを使っていない場合は不要です。
手順4:Instrumented Test・Local Test両方から呼びだせるようにテストを書く
手順3で作成したテストクラスにおける、テストの種類とテスト実行時に呼び出されるテストメソッドの関係は次のようになります。
テストの種類 | テスト実行時に呼び出されるテストメソッド |
---|---|
Instrumented Test | MyActivityTest クラスのmyTest メソッド |
Local Test | RobolectricMyActivityTest クラスのmyTest メソッド |
これら2つのmyTest
メソッドに同じテストコードを書けば、両者から同じテストを実行できるようになります。
とはいえ、両方のメソッドに同じコードを書くとコードが重複しメンテナンス性の低下を招きます。
その事態を避けるために、テストコード本体はapp/src/sharedTest
に配置し、
myTest
メソッドからはテストコード本体を呼び出すだけにします。
例えば、app/src/sharedTest
に配置したテストコード本体をdoMyTest()
関数としましょう。
// app/src/sharedTestに配置する fun doMyTest() { Espresso.onView(...).perform(click()) Espresso.onView(...).check(isDisplayed()) ... }
その場合2つのmyTest
メソッドでは、コード重複をできるだけ抑えるためにdoMyTest()
を呼び出すだけとします。
// app/src/androidTestに配置する ... class MyActivityTest { ... @Test fun myTest() { doMyTest() } }
// app/src/testに配置する ... class RobolectricMyActivityTest { ... @Test fun myTest() { doMyTest() } }
この例におけるdoMyTest()
部分の設計パターンはPage Objectデザインパターンがおすすめです。
Page Objectデザインパターンの詳細については割愛しますが、本パターンを適用するとmyTest
メソッドの本体は次のような感じになります。
fun myTest() { MyGardenPage .goPlantList() .showPlantDetail("Mango") .addToMyGarden() .goBackPlantList() .goMyGarden() .assertPlanted("Mango") }
スッキリ見通しが良くなりますね。
Page Objectについて詳しく知りたい方は、 DeNA CodelabsでSWETが公開している 「Espressoの知識ゼロでも書ける!Android UIテストはじめの一歩」に挑戦してみてください。
補足:テストクラスも共通化する方法
ここで紹介した方法ではmyTest
メソッド内のコード重複は避けられません。
その重複も回避したい場合、本記事で紹介した範囲であれば2つのテストクラスを共通化することもできます。
その場合はLocal Test向けのテストクラスだけをapp/src/sharedTest
に置きます。
app/src/androidTest
とapp/src/test
には対応するテストクラスは置かないでください。
そして、app/build.gradle
にInstrumented Testの依存関係としてorg.robolectric:annotations
を追加します。
dependencies { ... androidTestImplementation "org.robolectric:annotations:4.3.1" }
Robolectricのアノテーションの違いだけであれば、この方法でテストクラスを共通化できます。 ただし、筆者個人の意見としては、あまりこの方法はおすすめしません。 おすすめしない理由は、両者のテスト間で完全に同じコードを維持できない可能性が高いと考えるからです。
たとえば、RobolectricのShadowクラスを使いたくなる場合など、どうしても共通化できない部分がでてきたとします。
そうなった場合、共通化できない部分をapp/src/androidTest
とapp/src/test
へ切り出すことになります。
// app/src/sharedTestに配置したコード @Test fun myTest() { ... doActionOnlyLocalTest() // Local Testでしか実行したくない処理 } // app/src/testに配置したコード fun doActionOnlyLocalTest() { // ここにLocal Testでしか実行したくない処理を書く } // app/src/androidTestに配置したコード fun doActionOnlyLocalTest() { // 何も書かない }
ところがこの方法を取ってしまうと、前述した
「app/src/androidTest
とapp/src/test
で同じ名前のクラスを定義するとAndroid Studioでエラーになる」
というAndroid Studioの振舞いに抵触してしまいます。
エラーを無視してビルドはできるものの、現状ではおすすめするのが難しいです。
将来Android Studioの振舞いが改善されたら、この方法がベストな選択肢になると思います4。
工夫が必要なJetpackコンポーネント
Jetpack Componentのうち、RoomとWorkManagerでは更なる対応が必要です。 その対応方法を説明します。
Room
Roomを使うアプリのうち、RoomDatabase
(のサブクラス)への参照をstaticなフィールド(Kotlinのcompanion objectなどを含む)に保持しているケースが問題になります。
問題となる理由は、Robolectricの次のような振舞いと相性が良くないからです。
- テストごとに(ひとつのテストメソッド実行の度に)SQLiteのデータベースファイルが新規に作られる
- テストをまたいでもstaticな変数は初期化されない
RoomDatabase
のインスタンスは、データベースファイルと一対一対応しているため、
データベースファイルが作り直されたときは改めてインスタンスを作り直す必要があります。
また、RoomDatabase
のインスタンスが変わるとDAOのインスタンスも変わってしまう点にも注意が必要です。
そのため、そのようなアプリをRobolectricでテストするときは、テストのセットアップ時に次の処理を行うようにしましょう。
Room
クラスのdatabaseBuilder
メソッドを使って、RoomDatabase
インスタンスを作り直す- アプリ内で保持している古い
RoomDatabase
への参照を、作り直したインスタンスへの参照に更新する - 同様に古いDAOインスタンスへの参照を、新しい
RoomDatabase
から取得し直したDAOインスタンスへの参照に更新する
RoomDatabase
の初期化をアプリケーションクラスで行っている場合は、Robolectric用に別のアプリケーションクラスを用意するのが簡単です。
Robolectricではテストクラスに次のようなアノテーションを指定できます。
@Config(application = TestApplication::class) class RobolectricMyActivityTest { ... }
これで、Robolectricはこのアノテーションで指定されたアプリケーションクラスを使って初期化するようになります。
この例ではTestApplication
クラスのonCreate
メソッドが呼び出されるようになりますので、
そのonCreate
メソッドの中でRoomDatabase
インスタンスを作り直すなどの処理をすると良いでしょう。
なお、Robolectricではアプリケーションクラスの初期化もテストごとに行われます。
テスト対象アプリによって事情が異なるため具体的なコード例は割愛しますが、 Android Sunflowerアプリの修正例をスライド70〜75ページに掲載していますので、あわせて参考にしてください。
WorkManager
WorkManagerのデフォルトの振舞いでは、未完了のWorkはアプリが終了しても消えることがありません。 次回アプリ起動時に再開されます。
そのようなWorkの特徴により、前回のテストで未完了だったWorkが次のテストで意図せず起動することがあります。
その事態を回避するためにはWorkManagerTestInitHelper
を使います。具体的な手順を見ていきます。
まず、app/build.gradle
にLocal Testの依存関係としてandroidx.work:work-testing
を追加します。
dependencies { ... testImplementation "androidx.work:work-testing:2.3.1" }
次に、app/src/test/AndroidManifest.xml
を次の内容で作成します。
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <application> <provider android:name="androidx.work.impl.WorkManagerInitializer" android:authorities="${applicationId}.workmanager-init" tools:node="remove" /> </application> </manifest>
既にファイルが存在する場合は<provider>
部分を<application>
タグの中に追記してください。
最後に、テストのセットアップフェーズで、次のようにWorkManagerを初期化します。
Roomのケースと同様にテスト用アプリケーションクラスのonCreate
メソッドで初期化するのが良いと思います。
class TestApplication : Application() { override fun onCreate() { ... WorkManagerTestInitHelper.initializeTestWorkManager(this) ... } }
テスト用アプリケーションクラスを用意する場合は、忘れずにテストクラス側でアプリケーションクラスを指定するようにしましょう。
@Config(application = TestApplication::class) class RobolectricMyActivityTest { ... }
この対応で、未完了だったWorkが起動することはなくなります。 また、起動したWorkが同期的に実行されるようにもなるので、UIテストの安定化に寄与するはずです。
おわりに
RobolectricでUIテストを動かすときのポイントを紹介しました。 これらを実践すれば、UIテストの大部分がRobolectricでも動作するようになっていることと思います。
是非、簡単なところから少しずつ書きはじめてみてみてください!
-
この文脈における「UIテスト」は、EspressoのAPIを使ったテストとします↩
-
このコードは孫FragmentにDataBindingが使われているケースをサポートしていませんでした。サンプルコードではその問題を修正しています↩
-
特に
app/src/test/java/androidx
配下のクラスは、パッケージ名を変更すると動かなくなりますので注意してください。app/src/sharedTest
配下のクラスについては、パッケージ名を変更した上で別のディレクトリに配置しても問題ありません↩ -
途中から違う事象について議論しているので確証はありませんが、Issue 140375151が関連するバグチケットのようです↩