【问题标题】:Android unit testing view model that receives flow接收流的Android单元测试视图模型
【发布时间】:2020-06-27 23:52:32
【问题描述】:

我有一个 ViewModel 与一个用例对话并得到一个流回,即Flow<MyResult>。我想对我的 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)
        }
    }
}

【问题讨论】:

  • 这里需要测试什么?需要测试的确切场景是什么?首先用文字描述测试。
  • @Code-Apprentice - 我需要测试视图模型是否从用例接收到 Flow 并在其上构建视图状态并提供给观察者
  • @Maria 哪种方法有效?
  • 视图模型中的onOptionsSelected()
  • @IgorGanapolsky 我的 Fragment 观察到一个 livedata,因此我需要为 viewModel 中的流做 asLiveData

标签: android kotlin junit4 kotlin-coroutines android-mvvm


【解决方案1】:

在这个测试环境中存在以下几个问题:

  1. flow 构建器将立即发出结果,因此始终会收到最后一个值。
  2. viewState 持有者与我们的模拟没有链接,因此没有用。
  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 repo 以获得完整的实现。

【讨论】:

  • 我们如何以这种方式测试 LiveData?
  • @IgorGanapolsky 有多种方法,如果您有一个模拟或伪造的存储库,那么您可以使用所需数据触发事件。或者您可以使用 livedatabuilder 或 flowasLiveData
  • @Pavneet_Singh 如果接收到的数据是PagingData&lt;ModelName&gt;怎么办?
  • @Dr.jacky 根据您的实现和流程,您可能需要设置模拟对象或面孔。
  • 对视图模型类进行单元测试的“src/test/”和“src/androidTest”文件夹有什么区别?它如何处理主线程中视图模型函数的执行?
【解决方案2】:

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

所以.. 假设您有一些用例组件公开了Flow&lt;String&gt;。在您的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 值。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-10-16
    • 1970-01-01
    • 2023-03-22
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多