DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

DroidKaigi 2020発表動画公開記念:RobolectricでUIテストを動かすのに必要なことのまとめ

こんにちは。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で意図通り動かないと分かっているものは次の通りです。

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.includeAndroidResourcestrueにセットします。 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の両方で使うため、 androidTestImplementationtestImplementationで宣言します。 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/androidTestapp/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/androidTestapp/src/testには対応するテストクラスは置かないでください。

そして、app/build.gradleにInstrumented Testの依存関係としてorg.robolectric:annotationsを追加します。

dependencies {
    ...
    androidTestImplementation "org.robolectric:annotations:4.3.1"
}

Robolectricのアノテーションの違いだけであれば、この方法でテストクラスを共通化できます。 ただし、筆者個人の意見としては、あまりこの方法はおすすめしません。 おすすめしない理由は、両者のテスト間で完全に同じコードを維持できない可能性が高いと考えるからです。

たとえば、RobolectricのShadowクラスを使いたくなる場合など、どうしても共通化できない部分がでてきたとします。 そうなった場合、共通化できない部分をapp/src/androidTestapp/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/androidTestapp/src/testで同じ名前のクラスを定義するとAndroid Studioでエラーになる」 というAndroid Studioの振舞いに抵触してしまいます。 エラーを無視してビルドはできるものの、現状ではおすすめするのが難しいです。

将来Android Studioの振舞いが改善されたら、この方法がベストな選択肢になると思います4

工夫が必要なJetpackコンポーネント

Jetpack Componentのうち、RoomWorkManagerでは更なる対応が必要です。 その対応方法を説明します。

Room

Roomを使うアプリのうち、RoomDatabase(のサブクラス)への参照をstaticなフィールド(Kotlinのcompanion objectなどを含む)に保持しているケースが問題になります。 問題となる理由は、Robolectricの次のような振舞いと相性が良くないからです。

  • テストごとに(ひとつのテストメソッド実行の度に)SQLiteのデータベースファイルが新規に作られる
  • テストをまたいでもstaticな変数は初期化されない

RoomDatabaseのインスタンスは、データベースファイルと一対一対応しているため、 データベースファイルが作り直されたときは改めてインスタンスを作り直す必要があります。 また、RoomDatabaseのインスタンスが変わるとDAOのインスタンスも変わってしまう点にも注意が必要です。

そのため、そのようなアプリをRobolectricでテストするときは、テストのセットアップ時に次の処理を行うようにしましょう。

  1. RoomクラスのdatabaseBuilderメソッドを使って、RoomDatabaseインスタンスを作り直す
  2. アプリ内で保持している古いRoomDatabaseへの参照を、作り直したインスタンスへの参照に更新する
  3. 同様に古い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でも動作するようになっていることと思います。

是非、簡単なところから少しずつ書きはじめてみてみてください!


  1. この文脈における「UIテスト」は、EspressoのAPIを使ったテストとします

  2. このコードは孫FragmentにDataBindingが使われているケースをサポートしていませんでした。サンプルコードではその問題を修正しています

  3. 特にapp/src/test/java/androidx配下のクラスは、パッケージ名を変更すると動かなくなりますので注意してください。app/src/sharedTest配下のクラスについては、パッケージ名を変更した上で別のディレクトリに配置しても問題ありません

  4. 途中から違う事象について議論しているので確証はありませんが、Issue 140375151が関連するバグチケットのようです