spek2+mockkでLiveData+Coroutineをテストする

配車UX部 孫世明
「JapanTaxi」アプリのAndroidを担当している孫世明です。JapanTaxi AdventCalendarの24日目を担当することになりました。

「JapanTaxi」アプリの Android版では、Coroutineを導入して使っています。それでCoroutineが入ったロジックをテストするためにCoroutineのテスト方法を調査したので共有します。

テスト対象

  • 基準金額:1000円
  • 入力した金額で基準金額の物を買えられるのか状態確認
  • 金額が足りないのか残るのかメッセージ表示
    • 基準金額未満:~円が不足です。
    • 丁度基準金額:丁度です。
    • 基準金額超過:~円が残りました。

Sample Code

テスト対象コード作成

class CheckAmountUseCase {
    suspend operator fun invoke(
        money: Int
    ): AmountState = withContext(Dispatchers.IO) {
        when {
            money in 0 until price -> AmountState.Insufficient(price - money)
            money >= price -> AmountState.Sufficient(money - price)
            else -> throw IllegalStateException()
        }
    }

    companion object {
        private const val price = 1000
    }
}
  • 入力金額を確認して状態を返すUseCaseを作ります。
class GetMessageCheckAmountUseCase(private val context: Context) {
    operator fun invoke(
        state: AmountState
    ): String = when (state) {
        is AmountState.Insufficient -> {
            context.getString(R.string.insufficient_amount_message, state.balance)
        }
        is AmountState.Sufficient -> {
            if (state.balance == 0) {
                context.getString(R.string.exact_amount_message)
            } else {
                context.getString(R.string.sufficient_amount_message, state.balance)
            }
        }
    }
}
  • 確認した状態を見てテキストを返すUseCaseを作ります。
class MainViewModel(
    private val checkAmountUseCase: CheckAmountUseCase,
    private val getMessageCheckAmountUseCase: GetMessageCheckAmountUseCase
) : ViewModel() {

    val moneyText = MutableLiveData()

    private val _infoMessage = MutableLiveData()
    val infoMessage: LiveData
        get() = _infoMessage

    fun selectedCheckButton() {
        val money = moneyText.value?.toInt() ?: return
        checkAmount(money)
    }

    private fun checkAmount(money: Int) {
        viewModelScope.launch {
            runCatching {
                val state = checkAmountUseCase(money)
                getMessageCheckAmountUseCase(state)
            }.onSuccess {
                _infoMessage.value = it
            }.onFailure {
                _infoMessage.value = it.message
            }
        }
    }
}

上記のUseCaseを使ってViewModelを作りました!
このViewModelのユニットテストコードを作って見ましょう。

テストコード作成

Feature("金額確認機能テスト") {
    val spyInfoMessage by memoized { spyk>() }
    val viewModel by memoized {
        MainViewModel(
            checkAmountUseCase = CheckAmountUseCase(),
            getMessageCheckAmountUseCase = GetMessageCheckAmountUseCase(mockk())
        )
    }

    beforeEachGroup {
        viewModel.infoMessage.observeForever(spyInfoMessage)
    }
    afterEachGroup {
        viewModel.infoMessage.removeObserver(spyInfoMessage)
    }

    Scenario( "足りない金額で金額確認する") {
        val price = 1000
        val money = 300
        Given("300円で金額設定する") {
            viewModel.moneyText.value = money.toString()
        }
        When("金額確認実行") {
            viewModel.selectedCheckButton()
        }
        Then("足りない分テキスト表示") {
            verify {
                spyInfoMessage.onChanged(
                    eq((price - money).toString())
                )
            }
        }
    }
}

上記のテストコードを実行すると下記のエラーが発生します。

問題点1。

Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.
java.lang.RuntimeException: Method getMainLooper in android.os.Looper not mocked. See http://g.co/androidstudio/not-mocked for details.
  at android.os.Looper.getMainLooper(Looper.java)
  at androidx.arch.core.executor.DefaultTaskExecutor.isMainThread(DefaultTaskExecutor.java:77)
  at androidx.arch.core.executor.ArchTaskExecutor.isMainThread(ArchTaskExecutor.java:116)
  at androidx.lifecycle.LiveData.assertMainThread(LiveData.java:460)
  at androidx.lifecycle.LiveData.removeObserver(LiveData.java:242)
・・・

問題はLiveDataだと思います。LiveDataを実行するThreadが実機しか働かないThreadなのでテストマシンにも動くThreadで変更する必要があります。

ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
    override fun executeOnDiskIO(runnable: Runnable) {
        runnable.run()
    }

    override fun isMainThread(): Boolean {
        return true
    }

    override fun postToMainThread(runnable: Runnable) {
        runnable.run()
    }
})

上記のコードで問題は解決しましたが、また他の問題が起きます。

問題点2。

Exception in thread "DefaultDispatcher-worker-2" java.lang.IllegalStateException: Module with the Main dispatcher had failed to initialize. For tests Dispatchers.setMain from kotlinx-coroutines-test module can be used
  at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.missing(MainDispatchers.kt:95)
  at kotlinx.coroutines.internal.MissingMainCoroutineDispatcher.isDispatchNeeded(MainDispatchers.kt:69)
  at kotlinx.coroutines.test.internal.TestMainDispatcher.isDispatchNeeded(MainTestDispatcher.kt:39)
  at kotlinx.coroutines.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:268)
  at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:26)
・・・

上記の問題はDispatcherDefault、つまりMainDispatcherが動かないので起きている問題です。これはKotlin側で公式で対応する方法がありまして下記のように対応しました。

@ExperimentalCoroutinesApi
fun Root.mockCoroutineDispatcher(): TestCoroutineScope {
    val testDispatcher = TestCoroutineDispatcher()
    val testScope = TestCoroutineScope(testDispatcher)
    beforeEachTest {
        Dispatchers.setMain(testDispatcher)
    }
    afterEachTest {
        Dispatchers.resetMain()
        testScope.cleanupTestCoroutines()
    }
    return testScope
}

上記のコードを対応してまた実行してみましたが、下記のようにエラーが発生しました。今回は論理エラーですね。

問題点3。

Verification failed: call 1 of 1: Observer(#4).onChanged(eq(700))). Only one matching call to Observer(#4)/onChanged(Any) happened, but arguments are not matching:
[0]: argument: no answer found for: Context(#3).getString(2131427369, [700]), matcher: eq(700), result: -

上記の問題はcontextを単純にmockk()を使ってモックしてしまってgetStringでテキストが出てない問題があります。

簡単にmockkを使ってcontextをモックして見ましょう。

val resultSlot by memoized { slot() }
val context by memoized {
    mockk().apply {
        every {
            getString(R.string.exact_amount_message)
        }.returns("exact_amount_message")
        every {
            getString(R.string.sufficient_amount_message, capture(resultSlot))
        }.answers { resultSlot.captured.toString() }
        every {
            getString(R.string.insufficient_amount_message, capture(resultSlot))
        }.answers { resultSlot.captured.toString() }
    }
}
  • by memoizedはテストするたびに新しいインスタントを作ります。
  • このようにgetStringargumentが必要な時には私は上記のようにslotを使って対応しました。
  • slotは簡単に言えば値の箱であり、captureと一緒に使って該当する関数のagumentの値が入って来ます。

contextをモックしてGetMessageCheckAmountUseCaseを生成して見ましょう。

val viewModel by memoized {
    MainViewModel(
        checkAmountUseCase = CheckAmountUseCase(),
        getMessageCheckAmountUseCase = GetMessageCheckAmountUseCase(context)
    )
}

これでテストコードを実装すると成功します!!!(いよいよ)


    Scenario( "値段より超える金額で金額確認する") {
        val price = 1000
        val money = 1300
        Given("1300円で金額設定する") {
            viewModel.moneyText.value = money.toString()
        }
        When("金額確認実行") {
            viewModel.selectedCheckButton()
        }
        Then("残り分テキスト表示") {
            verify {
                spyInfoMessage.onChanged(
                    eq((money - price).toString())
                )
            }
        }
    }

    Scenario( "丁度金額で金額確認する") {
        val money = 1000
        Given("1000円で金額設定する") {
            viewModel.moneyText.value = money.toString()
        }
        When("金額確認実行") {
            viewModel.selectedCheckButton()
        }
        Then("丁度テキスト表示") {
            verify {
                spyInfoMessage.onChanged(
                    eq("exact_amount_message")
                )
            }
        }
    }

ところが、他のテストもやりたくて上記の二つのテストシナリオを追加した所、理由のわからないエラーが発生してしまいました。

問題点4。

  • エラーメッセージを見てもなんのエラーか分からないですね。
  • 何時間を迷って探したのはCheckAmountUseCaseにあるwithContext(Dispatchers.IO)を使ってDispatchersを指定していて起きた問題です。
  • 「問題2」から解決したのはDispatchers.Mainをモックすることなので、Dispatchers.IOはモックしていませんでした。
  • それでDispatchers.IOを指定したら他のThreadで実行してしまって、返す値がこの分を処理しておらず、違う値だったため論理エラーが発生してしまったようです。
  • なぜかは分からないですが、Kotlin側でDispatchers.IOをモックする方法はサポートしてないようです。

上記の問題のため私はmockkを使ってDispatchers.IOをモックしようと思いました。下記のような関数です。

fun Root.mockDispatcherIO(coroutineDispatcher: CoroutineDispatcher) {
    beforeEachTest {
        mockkStatic(Dispatchers::class).apply {
            every {
                Dispatchers.IO
            } returns (coroutineDispatcher)
        }
    }
    afterEachTest {
        clearStaticMockk(Dispatchers::class)
    }
}

上記の関数を使ってテストコード上に関数を配置しました。


mockDispatcherIO(Dispatchers.Unconfined)

対応してテストを実行すればいよいよ!成功しました!

全体コードはこちら