visible true

技術的なメモを書く

Junit5でJetpack ViewModelのviewModelScopeを使っている関数のテストをする

viewModelScopeを使っている関数を持つViewModel

次のRecommendBookViewModelクラスは、loadRecommendBooks関数という内部でviewModelScopeを用いて非同期処理を行う関数を持っている。

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import hoge.fuga.recommendbook.RecommendBook
import hoge.fuga.recommendbook.RecommendBookRepository

data class ViewState(
  val loading: Boolean,
  val recommendBooks: List<RecommendBook>
)

class RecommendBookViewModel(
  private val recommendBookRepository: RecommendBookRepository
) : ViewModel() {

  private val _viewState = MutableStateFlow(ViewState(true, emptyList()))

  val viewState: StateFlow<ViewState> = _viewState

  fun loadRecommendBooks() {
    viewModelScope.launch {    
      val recommendBooks = recommendBookRepository.recommendBooks()
      _viewState.value = ViewState(false, recommendBooks)
    }
  }
}

loadRecommendBooks関数をいい感じにテストするためにはいくらかの準備が必要になる。

環境

次のような環境とする。

implementation "org.jetbrains.kotlin:kotlin-stdlib:1.4.21"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2'

testImplementation "org.junit.jupiter:junit-jupiter-api:5.6.1"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.6.1"

testImplementation 'org.robolectric:robolectric:4.4' // 多分今回のテストには関係ない
testImplementation 'androidx.test:runner:1.3.0'
testImplementation 'androidx.test.ext:junit:1.1.2'
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.4.2'
testImplementation "io.mockk:mockk:1.10.2"
testImplementation('androidx.test.ext:truth:1.3.0') {
  exclude group: 'com.google.auto.value', module: 'auto-value-annotations'
}

TestCoroutinesExtensionを用意する

Junit5ではorg.junit.rules.TestRule は無くなった。代わりにorg.junit.jupiter.api.extensionパッケージ内の各種インタフェースを実装したクラスを、ExtendWithで指定する形になった。そこでDispatchers.setMain()などを実行するTestCoroutinesExtensionを作る。

package hoge.fuga

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.jupiter.api.extension.*

// テストの前後の実行、テストのコンストラクタへのパラメータをサポートする
class TestCoroutinesExtension : AfterTestExecutionCallback, BeforeTestExecutionCallback, ParameterResolver {
  private val namespace = ExtensionContext.Namespace.create(javaClass)

  private val key: Any = TestCoroutineDispatcher::class.java

  private val dispatcher = TestCoroutineDispatcher()

  override fun afterTestExecution(context: ExtensionContext?) {
    Dispatchers.resetMain()
  }

  override fun beforeTestExecution(context: ExtensionContext?) {
    Dispatchers.setMain(dispatcher)
  }

  override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean {
    return parameterContext.parameter.type === TestCoroutineDispatcher::class.java
  }

  override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any {
    return extensionContext.getStore(namespace).getOrComputeIfAbsent(key, { dispatcher }, TestCoroutineDispatcher::class.java)
  }
}

ViewModelのテストを書く

TestCoroutinesExtensionを使ってRecommendBookViewModelのテストを書く。

import com.google.common.truth.Truth.assertThat
import io.mockk.coEvery
import io.mockk.mockk
import hoge.fuga.TestCoroutinesExtension
import hoge.fuga.recommendbook.RecommendBook
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runBlockingTest
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith

// エクステンションを使う
@ExtendWith(TestCoroutinesExtension::class)
class RecommendBookViewModelTest(val dispatcher: TestCoroutineDispatcher) {
  // コンストラクタでTestCoroutineDispatcherを受け取る↑

  @Nested
  inner class loadRecommendBooks {
    @Test
    fun `success`() = dispatcher.runBlockingTest {
      val viewModel = RecommendBookViewModel(
        mockk {
          coEvery { recommendBooks() } returns listOf(
            RecommendBook("a"), 
            RecommendBook("b"), 
            RecommendBook("c")
          )
        }
      )
      viewModel.loadRecommendBooks()

      val viewState = viewModel.viewState.value
      assertThat(viewState.loading).isFalse()
      assertThat(viewState.recommendBooks.size).isEqualTo(3)
    }
  }
}

割とシンプルにできて良さそう。