Android 接收流的单元测试视图模型

Android unit testing view model that receives flow

我有一个 ViewModel,它与用例对话并获得回流,即 Flow<MyResult>。我想对我的 ViewModel 进行单元测试。我不熟悉使用流程。需要帮助请。这是下面的 viewModel -

class MyViewModel(private val handle: SavedStateHandle, private val useCase: MyUseCase) : ViewModel() {

        private val viewState = MyViewState()

        fun onOptionsSelected() =
            useCase.getListOfChocolates(MyAction.GetChocolateList).map {
                when (it) {
                    is MyResult.Loading -> viewState.copy(loading = true)
                    is MyResult.ChocolateList -> viewState.copy(loading = false, data = it.choclateList)
                    is MyResult.Error -> viewState.copy(loading = false, error = "Error")
                }
            }.asLiveData(Dispatchers.Default + viewModelScope.coroutineContext)

MyViewState 看起来像这样 -

 data class MyViewState(
        val loading: Boolean = false,
        val data: List<ChocolateModel> = emptyList(),
        val error: String? = null
    )

单元测试如下所示。断言失败总是不知道我在那里做错了什么。

class MyViewModelTest {

    @get:Rule
    val instantExecutorRule = InstantTaskExecutorRule()

    private val mainThreadSurrogate = newSingleThreadContext("UI thread")

    private lateinit var myViewModel: MyViewModel

    @Mock
    private lateinit var useCase: MyUseCase

    @Mock
    private lateinit var handle: SavedStateHandle

    @Mock
    private lateinit var chocolateList: List<ChocolateModel>

    private lateinit var viewState: MyViewState


    @Before
    fun setup() {
        MockitoAnnotations.initMocks(this)
        Dispatchers.setMain(mainThreadSurrogate)
        viewState = MyViewState()
        myViewModel = MyViewModel(handle, useCase)
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain() // reset main dispatcher to the original Main dispatcher
        mainThreadSurrogate.close()
    }

    @Test
    fun onOptionsSelected() {
        runBlocking {
            val flow = flow {
                emit(MyResult.Loading)
                emit(MyResult.ChocolateList(chocolateList))
            }

            Mockito.`when`(useCase.getListOfChocolates(MyAction.GetChocolateList)).thenReturn(flow)
            myViewModel.onOptionsSelected().observeForever {}

            viewState.copy(loading = true)
            assertEquals(viewState.loading, true)

            viewState.copy(loading = false, data = chocolateList)
            assertEquals(viewState.data.isEmpty(), false)
            assertEquals(viewState.loading, true)
        }
    }
}

此测试环境中几乎没有问题:

  1. flow 生成器会立即发出结果,因此总是会收到最后一个值。
  2. viewState 持有者没有 link 我们的模拟因此是无用的。
  3. 多值测试实际流量,需要延迟和快进控制。
  4. 需要收集响应值以进行断言

解决方案:

  1. 使用 delay 在流构建器中处理两个值
  2. 移除viewState.
  3. 使用MainCoroutineScopeRule来控制延迟执行流程
  4. 要收集断言的观察者值,请使用 ArgumentCaptor

源代码:

  1. MyViewModelTest.kt

    import androidx.arch.core.executor.testing.InstantTaskExecutorRule
    import androidx.lifecycle.Observer
    import androidx.lifecycle.SavedStateHandle
    import com.pavneet_singh.temp.ui.main.testflow.*
    import org.junit.Assert.assertEquals
    import kotlinx.coroutines.delay
    import kotlinx.coroutines.flow.flow
    import kotlinx.coroutines.runBlocking
    import org.junit.Before
    import org.junit.Rule
    import org.junit.Test
    import org.mockito.ArgumentCaptor
    import org.mockito.Captor
    import org.mockito.Mock
    import org.mockito.Mockito.*
    import org.mockito.MockitoAnnotations
    
    class MyViewModelTest {
    
        @get:Rule
        val instantExecutorRule = InstantTaskExecutorRule()
    
        @get:Rule
        val coroutineScope = MainCoroutineScopeRule()
    
        @Mock
        private lateinit var mockObserver: Observer<MyViewState>
    
        private lateinit var myViewModel: MyViewModel
    
        @Mock
        private lateinit var useCase: MyUseCase
    
        @Mock
        private lateinit var handle: SavedStateHandle
    
        @Mock
        private lateinit var chocolateList: List<ChocolateModel>
    
        private lateinit var viewState: MyViewState
    
        @Captor
        private lateinit var captor: ArgumentCaptor<MyViewState>
    
    
        @Before
        fun setup() {
            MockitoAnnotations.initMocks(this)
            viewState = MyViewState()
            myViewModel = MyViewModel(handle, useCase)
        }
    
        @Test
        fun onOptionsSelected() {
            runBlocking {
                val flow = flow {
                    emit(MyResult.Loading)
                    delay(10)
                    emit(MyResult.ChocolateList(chocolateList))
                }
    
                `when`(useCase.getListOfChocolates(MyAction.GetChocolateList)).thenReturn(flow)
                `when`(chocolateList.get(0)).thenReturn(ChocolateModel("Pavneet", 1))
                val liveData = myViewModel.onOptionsSelected()
                liveData.observeForever(mockObserver)
    
                verify(mockObserver).onChanged(captor.capture())
                assertEquals(true, captor.value.loading)
                coroutineScope.advanceTimeBy(10)
                verify(mockObserver, times(2)).onChanged(captor.capture())
                assertEquals("Pavneet", captor.value.data[0].name)// name is custom implementaiton field of `ChocolateModel` class
            }
        }
    }
    
  2. MainCoroutineScopeRule.kt源复制文件

  3. 列表 dependencies

    dependencies {
        implementation fileTree(dir: 'libs', include: ['*.jar'])
        implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
        implementation 'androidx.appcompat:appcompat:1.1.0'
        implementation 'androidx.core:core-ktx:1.2.0'
        implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
        implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
        implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0'
        testImplementation 'junit:junit:4.12'
        androidTestImplementation 'androidx.test.ext:junit:1.1.1'
        androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
        implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.0-alpha01'
        implementation 'org.mockito:mockito-core:2.16.0'
        testImplementation 'androidx.arch.core:core-testing:2.1.0'
        testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.3.5'
        testImplementation 'org.mockito:mockito-inline:2.13.0'
    }
    

输出(gif 通过删除帧进行了优化,所以有点滞后):

查看 Github 上的 mvvm-flow-coroutine-testing 回购以获得完整的实施。

我想我找到了一种更好的测试方法,即使用 Channel 和 consumeAsFlow 扩展函数。至少在我的测试中,我似乎能够测试通过通道发送的多个值(作为流量使用)。

所以..假设您有一些公开 Flow<String> 的用例组件。在您的 ViewModelTest 中,您想检查每次发出一个值时,UI 状态都会更新为某个值。 在我的例子中,UI 状态是 StateFlow,但这也应该适用于 LiveData。 另外,我正在使用 MockK,但使用 Mockito 应该也很容易。

鉴于此,这是我的测试结果:

@Test
fun test() = runBlocking(testDispatcher) {

    val channel = Channel<String>()
    every { mockedUseCase.someDataFlow } returns channel.consumeAsFlow()

    channel.send("a")
    assertThat(viewModelUnderTest.uiState.value, `is`("a"))

    channel.send("b")
    assertThat(viewModelUnderTest.uiState.value, `is`("b"))
}

编辑:我想您也可以使用任何类型的 hot 流程实现来代替 ChannelconsumeAsFlow。例如,您可以使用 MutableSharedFlow 使您能够在需要时 emit 值。