7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AndroidのJUnitテストは超ムズいぞ・・・

Andorid開発者みなさん、JUnitテストコード書いてちゃんとテストしてますか?
Android開発歴1年程度ですが、JUnitテストが超ムズいので打ちひしがれています。他の分野の話題に比べ、話題が少ないので、世間一般でどうなのかな?と思いました。

ムズい、その訳は?

  1. いまだに、標準がJUnit4。(JUnit5にする方法もあるらしいが、公式ではいまだにJUnit4)
  2. GUIに密接に関連している。
  3. 非同期処理を多用している。
  4. 複数のテスト方法がある

1.に関しては、何でいまだにJUnit4なんだろう?と疑問を持つくらい。JUnit5が出てもうだいぶ経つのに(2017年)。assertAllとか使いたいんだけどな。
2.に関しては、一番困るのがContext類(Context、ApplicationContext)。これが至る所に出てくる。strings.xmlからgetStringするのにもContextが必要。
3.はkotlinだとcoroutineですね。Dispacherとかscopeとかを考えないといけません。
4.についてはこの後、詳しく説明します。

自分の思うところとしては・・・
最初はAndroid未経験だったので、とりあえずスケジュールもあるし、プロダクションコードの開発に専念しました。AndroidのJUnitテストコードを書くにはプロダクションコードを書くのとはまた、別なノウハウが必要になります。(後述)

結果的に、プロダクションコード一通り書き終えて、ある程度手動テストしてから(完成!)JUnitテストコードを書き始めたのですが、結果、プロダクションコードも一部書き直さないといけないところがいくつか出てきました。

やはり、テストコードはプロダクションコードを書きながら作るのが定石です。(痛感)

Androidのテスト方法

Androidのテスト方法には大きく分けて二種類あります。

  • Local Test
    • マシンのローカル Java 仮想マシン(JVM)で実行される。
    • $module-name/src/test/java/ に作る
    • 軽い
  • Instrument test
    • 物理デバイスまたはエミュレータで実行される。
    • $module-name/src/androidTest/java/ に作る
    • 重い(エミュレータで実行場合は特に)

Androidのテストライブラリ

これに対して、Androidのテストの書き方が複数存在しています。公式でも複数組み合わせることを推奨しています。

何故か?

AndroidのアプリはAndroidの仕組みに依存しているものが多々あります。一つの書き方、テストライブラリでは全てをカバーできないので、それぞれの得意なところを組み合わせてテストします。

それぞれのテストライブラリでそれぞれのノウハウと書き方を要求されます。なので超ムズい。

テストライブラリとしても、これだけあります。

  1. 使わない(Context、GUIに依存しない、素のUtilクラスなどの場合)
  2. Espresso
  3. Robolectric
  4. UI Autometer

テストライブラリとテスト方法を対比してみると

テストライブラリ テスト方法
使わない local test
Espresso Instrument test
Robolectric local test
UI Autometer Instrument test

RobolectricはGUIのテストをしますが、何故かlocal testです。(後述)

mockライブラリ、assrtionライブラリ

これに対して、mockライブラリ、assrtionライブラリが複数存在します。
個人的にお気に入りは、mockライブラリはmockk、assertionライブラリはtruthを使っています。(AssertJに近いと言われている)

テスト対象のクラスの種類

テスト対象のクラスが

  1. Activity
  2. Fragment
  3. RecyclerView、Adapter等
  4. VeiwModel(coroutineの部分も含む)
  5. room(使っていれば)
  6. 等々・・・

によっても書き方が違います。

さあ、これらを組み合わせるだけで、もうお腹いっぱい・・・って感じじゃないでしょうか?

Robolectricは何故local testでAndroid APIが使えるのか?

  • Robolectricは独自にshadowという仕組みを持っています。(mockとは違うと行っている)
  • Androidのフレームワークに依存性があるコードをshadowに変換して処理します。
  • Robolectricを利用したユニットテスト実行時、RobolectricTestRunnerがJVMをフッキングし、クラスローダーがAndroid コンポーネントをロードするのではなく、RobolectricのShadowオブジェクトをロードするようにします。

かと言って、どんなAndroid APIのテストもできるわけではありません。できないものもあります。

  • WorkManagerを使った非同期
  • NavigationView(ナビゲーションドロワー)の操作
  • Pageingを使ったRecyclerViewの操作
  • 等々・・・

具体例、roomのテストケース

ここからは具体的なテストケースの例、わかりやすいところから。まずroomのdaoのテスト。
roomはinMemoryDatabaseBuilderを使います。

@RunWith(AndroidJUnit4::class)
class MyDaoTest {
    /** DB */
    private lateinit var myDb: MyDb
    /** DAO */
    private lateinit var myDao: MyDao

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        myDb = Room.inMemoryDatabaseBuilder(
            context,
            MyDb::class.java
        ).build()
        println("setup!!")
        // DAOのテスト用インスタンスを取得
        myDao = myDb.myDao()
        runBlocking {
            // 初期データが必要な時はここで入れる
            myDao.insert( ・・・・)
        }
    }

    @After
    @Throws(IOException::class)
    fun cleanup() {
        myDb.close()
    }

上の例のMyDb、MyDaoはプロダクションコードのクラスです。
@BeforeでinMemoryDatabaseBuilderで初期化してやります。daoのインスタンスも取ってきてプロパティにセットします。初期データが必要であればここで入れます。Room.inMemoryDatabaseBuilderはテストメソッド毎に空になりますので注意が必要。

roomのdaoの関数はsupend関数である場合が多いです。supend関数はcoroutineのscopeの中にないと動かないので。各テストメソッドは

    @Test
    @Throws(Exception::class)
    fun selectByXXXTest() = runTest {
            val rslt = myDao.selectXXXX("XXX")
            assertThat(rslt.size).isEqualTo(2)
    }

のようにrunTestでテスト用のscopeで囲ってやる必要があります。
daoの関数がObserveパターンでFlowを返す場合、assertの部分はrslt.first()で取り出す必要があります

    assertThat(rslt.first().size).isEqualTo(2)

roomのテストケースはInstrument testで作ります。

具体例、ViewModelのテストケース

ここから徐々に難しくなってきます。
ViewMocdelはAAC(Androidアーキテクチャコンポーネント)に準じていればRpositioryクラスに依存しています。そしてコンストラクタの引数にRpositioryクラスのインスタンスを持っています。そして、大抵の場合、中で非同期でDBのCRUD、HTTP通信を行います。Rpositioryクラスはmock化済のインスタンスをViewModelに渡してやる必要があります。

viewModelScope.launch(Dispatchers.IO) {
    databaseRepository.insert(entity)
    // あるいはHTTP通信など・・・
}

ViewModelのクラスの中で「Dispatchers.IO」が直書きされていると、テスト的には非常に具合が悪いです。(テストケースのTestDispacherとViewModelのDispacherが別なのですれ違う)
ここはテストケースからTestDispacherをViewModelのクラスに渡してやるように修正が必要になります。

class MyViewModel(
    private val databaseRepository: DatabaseRepository,
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO): ViewModel()  {
・・・
    @WorkerThread
    fun insertTest(entity: MyEntity) {
        viewModelScope.launch(dispatcher) {
            databaseRepository.insert(entity)
        }
    }

}

のようにViewModelのコンストラクタの引数にdispatcherを追加してデフォルトをDispatchers.IOを指定します。こうすることにより、JUnitテストの時はTestDispacherを渡し、プロダクションの場合はDispatchers.IOで動きます。

ViewModelのテストメソッドもViewModelのviewModelScopeをテストするのでrunTestで囲ってやる必要があります。

    @MockK
    private lateinit var databaseRepository: DatabaseRepository

    @Test
    fun deleteTest() = runTest(UnconfinedTestDispatcher()) {
        // Mock
        val dispacher = UnconfinedTestDispatcher(testScheduler)
        val viewModel = MyViewModel(databaseRepository, dispacher)
        // 実行
        viewModel.deleteByXXXX(1)
        advanceUntilIdle()
        // assert
    }

Repositryについてはmock済のインスタンスをViewModelのコンストラクタに渡してやります。
UnconfinedTestDispatcher()とadvanceUntilIdle()についてはいつもお世話になっているここを参考にしてください。

ViewModelでObserveパターンでroomからSELECTした結果を取ってくるようなテストはひと捻りが必要です。プロダクションコードでは

MyViewModel.kt
fun selectHogehoge(): LiveData<List<MyEntity>> = ・・・

のような場合、Activity、Fragment側で

viewModel.selectHogehoge().observe(viewLifecycleOwner) { entityList ->
    ・・・
}

になりますが、テストケースの中でviewLifecycleOwnerは使えません。
テストケースの中ではobserve → observeForever を使います。

@Test
fun selectTest() = runTest() {
    // Mock
    val vmdispacher = UnconfinedTestDispatcher(testScheduler)
    val viewModel = MyViewModel(databaseRepository, vmdispacher)
    val dispatcher = UnconfinedTestDispatcher()
    Dispatchers.setMain(dispatcher)
    val testflow = flowOf (listOf( ・・・ ))
    every { databaseRepository.selectHogeHoge(any()) } returns testflow
    // 実行
    advanceUntilIdle()
    // assert
    viewModel.selectHogeHoge().observeForever {
        assertThat(it).isEqualTo(expectedList)
    }
    Dispatchers.resetMain()
}

Observeパターンの実行部分を
Dispatchers.setMain(dispatcher)
Dispatchers.resetMain()
で囲ってやる必要があります。

具体例、RobolectricでActivityをテストする場合

今のところテストライブラリを全て使いこなしているわけではないので、Robolectricだけ説明します。build.gradleに依存ライブラリを追加します。

build.gradle
android {
    ・・・
    testOptions {
        unitTests {
            includeAndroidResources = true // robolectric
        }
    }
}
dependencies {
    ・・・
    testImplementation 'androidx.test.ext:junit-ktx:1.1.5'
    testImplementation 'junit:junit:4.13.2'
    testImplementation 'io.mockk:mockk-android:1.13.3'
    testImplementation 'io.mockk:mockk-agent:1.13.3'
    testImplementation 'com.google.truth:truth:1.1.4' // truth
    testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1'
    testImplementation 'androidx.arch.core:core-testing:2.2.0'
}

mockkとtruthも追加します。
testOptionsも必要、これがないとRobolectricは動かない。

テストケースの書き方ですが、これも書き方が色々あって、

@RunWith(AndroidJUnit4::class)
//@RunWith(RobolectricTestRunner::class)
class MainActivityTest {
    @Test
    fun buttonTest() {
        val activity = Robolectric.setupActivity(MainActivity::class.java)
        val button = activity.findViewById<Button>(R.id.button)
        ・・・
    }
}

@RunWith
@RunWith(AndroidJUnit4::class)
@RunWith(RobolectricTestRunner::class)
どちらでも動きます。AndroidJUnit4だとInstrument testからも呼び出すことができますので(一緒に実行する)、こちらの方がベターだと思います。
上の書き方は古いかきかたでdeprecatedになっています。ActivityのlifeCycleを制御できません。新しい書き方は、

    @Test
    fun buttonTest() {
        Robolectric.buildActivity(MainActivity::class.java).use { controller ->
            val activity: MainActivity = controller
                .setup()
                // .pause() // ActivityのLifeCycleを制御できる
                .start()
                .get()
            val button = activity.findViewById<Button>(R.id.button)
            ・・・
        }
    }

pause()、start()等でActivityのlifeCycleを制御できます。

具体例、RobolectricでFragmentをテストする場合

Fragmentをテストする場合はActivityと違ってFragmentScenarioを使います。
build.gradleの依存ライブラリに以下を追加します。

build.gradle
dependencies {
    debugImplementation 'androidx.fragment:fragment-testing:1.5.7'
}

テストコードは以下の様になります。

    @Test
    fun mainTest001() {
        val scenario = launchFragmentInContainer<MyFragment>()
        scenario.onFragment { fragment ->
            val button = fragment.view?.findViewById<Button>(R.id.myButton)
            // assert
            assertThat(・・・)
        }
    }

launchFragmentInContainerでFragmentScenarioのインスタンスを取得して、scenario.onFragmentでFragmentのインスタンスを取得します。

具体例、Activity、FragmentでViewModelをmock化する

さて、以上これだけで全てのテストができるわけではありません。まだまだ、道半ばです。AAC(Androidアーキテクチャコンポーネント)に準じていればActivity、FragmentはViewModelに依存しています。Activity、Fragmentの処理だけに閉じてテストしたいのであれば、ViewModelをmock化する必要があります。

@RunWith(AndroidJUnit4::class)
class MyFragmentTest {

    @get:Rule
    val mockkRule = MockKRule(this)

    @MockK
    private lateinit var mockMyViewModel: MyViewModel

    @Before
    fun setUp() {
        MockKAnnotations.init(this)
    }

    @Test
    fun mainTest001() {
        // Mock
        every { mockMyViewModel.getHogeHoge() } returns Unit

        mockkConstructor(ViewModelProvider::class)
        every { anyConstructed<ViewModelProvider>().get(MyViewModel::class.java) } returns mockMyViewModel
        // 実行
        val scenario = launchFragmentInContainer <MyFragment>()
        scenario.onFragment { fragment ->
            val button = fragment.view?.findViewById<Button>(R.id.myButton)
            // assert
            assertThat(・・・)
        }
    }
}

ミソはMyViewModelをmock化。
ViewModelProviderのコンストラクタをmock化してgetメソッドの戻り値にMyViewModelをmock化したインスタンスを返すことです。

具体例、Activity、FragmentでActionbarのmenuを使っているとテストが落ちる

ここでひとつ困った問題が。Activity、FragmentでActionbarのmenuを使っています。

MainActivity.kt
private fun setupMenuBar() {
    addMenuProvider(object : MenuProvider {
        override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
            menuInflater.inflate(R.menu.actionbar_menu, menu)
        }

        override fun onMenuItemSelected(item: MenuItem): Boolean {
            when (item.itemId) {
                ・・・
            return true
        }
    })
}
MyFragment.kt
fun setupMenuBar() {
    val menuHost: MenuHost = requireActivity()
    menuHost.addMenuProvider(object : MenuProvider {
        override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
        }

        override fun onPrepareMenu(menu: Menu) {
            super.onPrepareMenu(menu)
            menu.findItem(R.id.myIcon).isVisible = true // ★
        }

        override fun onMenuItemSelected(menu: MenuItem): Boolean {
            when (menu.itemId) {
                ・・・
            }
            return true
        }
    }, viewLifecycleOwner, Lifecycle.State.RESUMED)
}

のような場合で、FragmentScenarioを使ったテストはFragmentの// ★の部分でnullポで落ちます。恐らく、FragmentScenarioだとActivityでのmenuのinflateがされないでFragmentのインスタンスを取得するので、menu.findItem(R.id.myIcon)はnullが返ります。nullのisVisibleでnullポになります。
setupMenuBar()はonViewCreated()から必ず呼ばれるので全てのテストが失敗します。

FragmentScenarioのlaunchFragmentInContainerの引数にFragmentFactoryの引数があるのでFragmentFactoryでFragmentのsetupMenuBar()関数だけspykしたインスタンスを返せばいいんじゃないか?と思ってやってみましたがダメでした。

結局、クラスと関数をopenにしてoverride可能にし、

MyFragment
open class MyFragment : Fragment() {
・・・
    open fun setupMenuBar() {
        ・・・
    }
}

テストコード側は

@RunWith(AndroidJUnit4::class)
class MyFragmentTest {

    @Test
    fun mainTest001() {
        val scenario = launchFragmentInContainer<MyFragment4Test>()
        scenario.onFragment { fragment ->
            val button = fragment.view?.findViewById<Button>(R.id.myButton)
            // assert
            assertThat(・・・)
        }
    }
}

class MyFragment4Test: MyFragment() {
    override fun setupMenuBar() {
    }
}

という苦肉の策を取りました。が、テストのためにプロダクションコードを書き換える(openにする)のは本末転倒なので、極力避けたいところですが他に方法がありませんでした。

最後に

一旦は、今回はここで閉じますが、今後も色々と変化球的な具体例をご披露したいと思います。
この辺のノウハウ等を共有している方がいれば、ご意見等を伺いたいですので、よろしくお願いします。

AndroidのJUnitテストは超ムズいぞ!

7
10
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?