📝

Androidアプリのオレ的テストコードの書き方

2025/02/28に公開

はじめに

Androidのテストコードを書いてきた経験をもとに、自分なりにこういうテストコードの書き方をすれば書きやすさやメンテナンス性を考慮したテストコードが書けるかなという視点で、自分のテストコードの書き方を振り返ってみたいと思います。

テスト用ライブラリの追加

Kotlin coroutineとFlowを使用したViewModelとRepositoryをテストしていくことを想定しています。
下記のテスト用ライブラリを追加します。

Junit4とmockK,Coroutinesテスト用のライブラリを導入します。

ViewModelはHiltを使用するなどしてコンストラクタで必要な引数を与えられるようになっていることを想定しています。

// build.gradle(app)

dependencies {
    // Junit4
    testImplementation("junit:junit:4.13.2")

    // mockk
    testImplementation("io.mockk:mockk:1.12.5")

    // Coroutines Test
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4")
}

各ライブラリのバージョンは最新のもので問題ないはずです。

Repositoryのテスト

Repositoryのテストは、特に難しい箇所はないと思います。
APIのクラスをモックしてエラーハンドリングなどが動作するか確認していきます。

 class LoadListTest : EnclosedMainRepositoryTestBase() {
        @Test
        fun `正常に値が返ってくる場合は取得した値を返す`() = runTest {
            // Given
            val listItems = (1..10).map { "test$it" }
            coEvery { api.loadList() } returns listItems

            val repository = MainRepository(api)

            // When
            val actual = repository.loadList().first()

            // Then
            assertEquals(listItems, actual)
        }

        @Test
        fun `APIエラーになった場合はエラーを返す`() = runTest {
            // Given
            val exception = Exception("error")
            coEvery { api.loadList() } throws exception

            val repository = MainRepository(api)

            // When
            var caughtException: Throwable? = null
            repository.loadList()
                .catch { error ->
                    caughtException = error
                }
                .singleOrNull()

            // Then
            assertEquals(exception.message, caughtException?.message)
        }
    }

ViewModelのテスト

ViewModelのようにLiveDataやCoroutineを使用したメソッドのテストでは、実行順序が想定したように動かないといったことで詰まることが多いです。

そこで、conference-app-2020で採用されていた ViewModelTestRule のように
https://github.com/DroidKaigi/conference-app-2020/blob/4c5533e4611d4bf6007284dd1f61db2fc92eb0ae/corecomponent/androidtestcomponent/src/main/java/io/github/droidkaigi/confsched2020/widget/component/ViewModelTestRule.kt#L9

記述した順にメソッドが実行されるようにテストルールを作成して、テストコードで使用するようにしておくと
ViewModelのテストコードが書きやすくなります。

使用するには下記のようにして、テストコードでViewModelTestRuleをテストルールとして設定します。

class HogeHogeViewModelTest {
    @get:Rule
    val rule = ViewModelTestRule()
}

その他はRepositoryのときと同様にRepositoryのクラスをモックしてエラーハンドリングなどが動作するか確認していきます。

テストコードの保守性

テストメソッドの名前は自然言語でつけてしまう

テストコードが増えてくるとどのテスト条件のテストがどこにあるか探すのが難しくなってきます。
そのため通常のコードとは違いテストコードではメソッド名に自然言語でどのようなテストなのかを記述するようにします。

@Test
 fun `正常に値が返ってくる場合は取得した値でUiStateに更新する`() = runTest {

 }

Given-When-Then構文

テストコードが肥大化してくると、しばらくしてからテスト対象を修正した際にテストコードも修正が必要になってきます。
その際にGiven-When-Then構文を使用してテストコードを記述しておくことで、テストのインプットアウトプットがわかりやすくなり修正しやすくなります。

        @Test
        fun `正常に値が返ってくる場合は取得した値でUiStateに更新する`() = runTest {
            val viewModel = MainViewModel(repository)
            val results: MutableList<MainViewModel.UiState> = mutableListOf()
            val job = launch(viewModelTestRule.coroutineRule.dispatcher) {
                viewModel.uiState.toList(results)
            }

            // Given
            val listItems = (1..10).map { "test$it" }
            coEvery {
                repository.loadList()
            } returns flowOf(listItems)

            // When
            viewModel.loadList()
            job.cancel()

            // Then
            assertEquals(
                MainViewModel.UiState(
                    isLoading = false,
                    listItems = listItems,
                    isShowError = false
                ),
                results.last()
            )
        }

テストコードを階層化する

同じメソッドに対して様々な条件でのテストを行うので、テストコードが肥大化してくるとどれがどのテストかわかりにくくなります。
そこでテストが大きくなってきたらテスト対象のメソッドごとに分離して、テストコードのメンテナンスを行いやすくしておきます。

Junit5だと標準でテストコードの階層化をサポートしているのですが、
Junit4はサポートしていないので、テストコードを階層化するにあたり下記のようにEnclosedを使用します。

また、Kotlinの場合にEnclosedを使用してテストコードを階層化するには、
抽象クラスを定義してそれを各メソッドをテストするクラスに継承させる必要があります。
こうすることでKotlinでもサブクラスにあるテストメソッドを認識させることができます。

package com.example.testcodesampleapp

import com.example.testcodesampleapp.testrule.MockkRule
import com.example.testcodesampleapp.testrule.ViewModelTestRule
import io.mockk.coEvery
import io.mockk.impl.annotations.MockK
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.experimental.runners.Enclosed
import org.junit.runner.RunWith
import java.lang.Exception

@ExperimentalCoroutinesApi
@RunWith(Enclosed::class)
class MainViewModelTest {

    abstract class EnclosedMainViewModelTestBase {
        @get:Rule
        val mockkRule = MockkRule(this)

        @get:Rule
        val viewModelTestRule = ViewModelTestRule()

        @MockK
        lateinit var repository: MainRepository
    }

    class LoadListTest : EnclosedMainViewModelTestBase() {
        @Test
        fun `正常に値が返ってくる場合は取得した値でUiStateに更新する`() = runTest {
            val viewModel = MainViewModel(repository)
            val results: MutableList<MainViewModel.UiState> = mutableListOf()
            val job = launch(viewModelTestRule.coroutineRule.dispatcher) {
                viewModel.uiState.toList(results)
            }

            // Given
            val listItems = (1..10).map { "test$it" }
            coEvery {
                repository.loadList()
            } returns flowOf(listItems)

            // When
            viewModel.loadList()
            job.cancel()

            // Then
            assertEquals(
                MainViewModel.UiState(
                    isLoading = false,
                    listItems = listItems,
                    isShowError = false
                ),
                results.last()
            )
        }

        @Test
        fun `APIエラーになった場合はUiStateのisShowErrorをtrueに更新する`() = runTest {
            val viewModel = MainViewModel(repository)
            val results: MutableList<MainViewModel.UiState> = mutableListOf()
            val job = launch(viewModelTestRule.coroutineRule.dispatcher) {
                viewModel.uiState.toList(results)
            }

            // Given
            coEvery {
                repository.loadList()
            } returns flow {
                throw Exception("error")
            }

            // When
            viewModel.loadList()
            advanceUntilIdle()
            job.cancel()

            // Then
            assertEquals(
                MainViewModel.UiState(
                    isLoading = false,
                    listItems = listOf(),
                    isShowError = true
                ),
                results.last()
            )
        }
    }

    class Method2Test : EnclosedMainViewModelTestBase() {
        @Test
        fun method2_givenAllNormalValues_returnSuccess() {
            assertEquals(6, 3 + 3)
        }

        @Test
        fun method2_givenAllNormalValues_returnError() {
            assertEquals(4, 2 + 2)
        }
    }
}

まとめ

現状実際にテストを書いてみて自分がしっくり来たテストの書き方を書いてみました。

まだテストコードを書いていないという場合は、
まずはテストコードを動作確認のPlayGroundのようにして気軽に書いてみて慣れてみてることから始めてみましょう。

だんだんとテストコードの書き方がわかってくるとすらすら書けるようになるので、
その後はテストコードの品質というところでメンテナンスのしやすいテストという観点にも目を向けていけばいいと思います。

株式会社ソニックムーブ

Discussion