「JapanTaxi」アプリの Android版では、Coroutineを導入して使っています。それでCoroutineが入ったロジックをテストするためにCoroutineのテスト方法を調査したので共有します。
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())
)
}
}
}
}
上記のテストコードを実行すると下記のエラーが発生します。
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()
}
})
上記のコードで問題は解決しましたが、また他の問題が起きます。
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)
・・・
上記の問題はDispatcherのDefault、つまりMainのDispatcherが動かないので起きている問題です。これはKotlin側で公式で対応する方法がありまして下記のように対応しました。
@ExperimentalCoroutinesApi
fun Root.mockCoroutineDispatcher(): TestCoroutineScope {
val testDispatcher = TestCoroutineDispatcher()
val testScope = TestCoroutineScope(testDispatcher)
beforeEachTest {
Dispatchers.setMain(testDispatcher)
}
afterEachTest {
Dispatchers.resetMain()
testScope.cleanupTestCoroutines()
}
return testScope
}
上記のコードを対応してまた実行してみましたが、下記のようにエラーが発生しました。今回は論理エラーですね。
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() }
}
}
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")
)
}
}
}
ところが、他のテストもやりたくて上記の二つのテストシナリオを追加した所、理由のわからないエラーが発生してしまいました。
上記の問題のため私は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)
対応してテストを実行すればいよいよ!成功しました!
Mobility Technologies では共に日本のモビリティを進化させていくエンジニアを募集しています。話を聞いてみたいという方は、是非 募集ページ からご相談ください!