简体   繁体   中英

ViewModel Unit testing multiple view states with LiveData, Coroutines and MockK

I have a function in ViewModel with 2 states, first state is always LOADING, second state depends on result of api or db interactions.

This is the function

fun getPostWithSuspend() {

    myCoroutineScope.launch {

        // Set current state to LOADING
        _postStateWithSuspend.value = ViewState(LOADING)

        val result = postsUseCase.getPosts()

        // Check and assign result to UI
        val resultViewState = if (result.status == SUCCESS) {
            ViewState(SUCCESS, data = result.data?.get(0)?.title)
        } else {
            ViewState(ERROR, error = result.error)
        }

        _postStateWithSuspend.value = resultViewState
    }
}

And no error, test works fine for checking final result of ERROR or SUCCESS

   @Test
    fun `Given DataResult Error returned from useCase, should result error`() =
        testCoroutineRule.runBlockingTest {

            // GIVEN
            coEvery {
                useCase.getPosts()
            } returns DataResult.Error(Exception("Network error occurred."))

            // WHEN
            viewModel.getPostWithSuspend()

            // THEN
            val expected = viewModel.postStateWithSuspend.getOrAwaitMultipleValues(dataCount = 2)

//            Truth.assertThat("Network error occurred.").isEqualTo(expected?.error?.message)
//            Truth.assertThat(expected?.error).isInstanceOf(Exception::class.java)
            coVerify(atMost = 1) { useCase.getPosts() }
        }

But i couldn't find a way to test whether LOADING state has occurred or not, so i modified existing extension function to

fun <T> LiveData<T>.getOrAwaitMultipleValues(
    time: Long = 2,
    dataCount: Int = 1,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
): List<T?> {

    val data = mutableListOf<T?>()
    val latch = CountDownLatch(dataCount)

    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data.add(o)
            latch.countDown()
            this@getOrAwaitMultipleValues.removeObserver(this)
        }
    }
    this.observeForever(observer)

    afterObserve.invoke()

    // Don't wait indefinitely if the LiveData is not set.
    if (!latch.await(time, timeUnit)) {
        this.removeObserver(observer)
        throw TimeoutException("LiveData value was never set.")
    }

    @Suppress("UNCHECKED_CAST")
    return data.toList()
}

To add data to a list when LiveData changes and store states in that list but it never returns LOADING state because it happens before observe starts. Is there a way to test multiple values of LiveData ?

Using mockk you can capture the values and store it in the list, then you check the values by order.

    //create mockk object
    val observer = mockk<Observer<AnyObject>>()

    //create slot
    val slot = slot<AnyObject>()

    //create list to store values
    val list = arrayListOf<AnyObject>()

    //start observing
    viewModel.postStateWithSuspend.observeForever(observer)


    //capture value on every call
    every { observer.onChanged(capture(slot)) } answers {

        //store captured value
        list.add(slot.captured)
    }

    viewModel.getPostWithSuspend()
    
    //assert your values here
    

I assume you are using mockk library

  1. First you need to create observer object

     val observer = mockk<Observer<ViewState<YourObject>>> { every { onChanged(any()) } just Runs }
  2. Observe your livedata using previous observer object

    viewModel.postStateWithSuspend.observeForever(observer)
  3. Call your getPostWithSuspend() function

     viewModel.getPostWithSuspend()
  4. Verify it

     verifySequence { observer.onChanged(yourExpectedValue1) observer.onChanged(yourExpectedValue2) }

Hi you may simply solve this problem by modifying the extension a little bit. It's like:

The main points are you need to increase the latch count initially then don't remove the observer until the latch is count down to zero.

The rest would be easy, you just need to store your result in a list.

Good luck!

 /*
 Add for multiple values in LiveData<T>
 */
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValuesTest(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    maxCountDown: Int = 1,
    afterObserve: () -> Unit = {}
): List<T?> {
    val data: MutableList<T?> = mutableListOf()
    val latch = CountDownLatch(maxCountDown)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data.add(o)
            latch.countDown()
            if (latch.count == 0L) {
                this@getOrAwaitValuesTest.removeObserver(this)
            }
        }
    }
    this.observeForever(observer)

    try {
        afterObserve.invoke()

        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }

    } finally {
        this.removeObserver(observer)
    }

    return data.toList()
}
  • Observes [LiveData] and captures latest value and all subsequent values, returning them in ordered list.
inline fun <reified T > LiveData<T>.captureValues(): List<T?> {
    val mockObserver = mockk<Observer<T>>()
    val list = mutableListOf<T?>()
    every { mockObserver.onChanged(captureNullable(list))} just runs
    this.observeForever(mockObserver)
    return list
}

I wrote my own RxJava style test observer for LiveData

class LiveDataTestObserver<T> constructor(
    private val liveData: LiveData<T>
) : Observer<T> {

    init {
        liveData.observeForever(this)
    }

    private val testValues = mutableListOf<T>()

    override fun onChanged(t: T) {
        if (t != null) testValues.add(t)
    }

    fun assertNoValues(): LiveDataTestObserver<T> {
        if (testValues.isNotEmpty()) throw AssertionError(
            "Assertion error with actual size ${testValues.size}"
        )
        return this
    }

    fun assertValueCount(count: Int): LiveDataTestObserver<T> {
        if (count < 0) throw AssertionError(
            "Assertion error! value count cannot be smaller than zero"
        )
        if (count != testValues.size) throw AssertionError(
            "Assertion error! with expected $count while actual ${testValues.size}"
        )
        return this
    }

    fun assertValue(vararg predicates: T): LiveDataTestObserver<T> {
        if (!testValues.containsAll(predicates.asList())) throw AssertionError("Assertion error!")
        return this
    }

    fun assertValue(predicate: (List<T>) -> Boolean): LiveDataTestObserver<T> {
        predicate(testValues)
        return this
    }

    fun values(predicate: (List<T>) -> Unit): LiveDataTestObserver<T> {
        predicate(testValues)
        return this
    }

    fun values(): List<T> {
        return testValues
    }

    /**
     * Removes this observer from the [LiveData] which was observing
     */
    fun dispose() {
        liveData.removeObserver(this)
    }

    /**
     * Clears data available in this observer and removes this observer from the [LiveData] which was observing
     */
    fun clear() {
        testValues.clear()
        dispose()
    }
}

fun <T> LiveData<T>.test(): LiveDataTestObserver<T> {

    val testObserver = LiveDataTestObserver(this)

    // Remove this testObserver that is added in init block of TestObserver, and clears previous data
    testObserver.clear()
    observeForever(testObserver)

    return testObserver
}

And use it as

   val testObserver = viewModel. postStateWithSuspend.test()

        // WHEN
        viewModel. getPostWithSuspend()

        // THEN
        testObserver
            .assertValue { states ->
                (
                    states[0].status == Status.LOADING &&
                        states[1].status == Status.ERROR
                    )
            }

alternative you can use mockito-kotlin

//mock the observer 
@Mock
private lateinit var observer: Observer<AnyObject>

@Test
fun `Your test`(){

  //start observing
  viewModel.postStateWithSuspend.observeForever(observer)

  //capture the values
  argumentCaptor<AnyObject>().apply {

  Mockito.verify(observer, Mockito.times(2)).onChanged(capture())
     //assert your values
   Truth.assertThat("Loading").isEqualTo(allValues[0]?.state) 
   Truth.assertThat("Network error occurred.").isEqualTo(allValues[1]?.error?.message)            
   Truth.assertThat(allValues[1]?.error).isInstanceOf(Exception::class.java)
   
    }
 //do not forget to remove the observer
 viewModel.postStateWithSuspend.removeObserver(observer)
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM