architecture-components-samples icon indicating copy to clipboard operation
architecture-components-samples copied to clipboard

LiveDataSample Unit Test - Unable to Observe Nested LiveData Values

Open adam-hurwitz opened this issue 6 years ago • 0 comments

Overview

Expected - Saving nested LiveData values in a local unit test, and then asserting their values.

Observed - Saving nested LiveData values in a ViewModel are observed successfully in production code, but fail in the local unit test. This is potentially due to the lack of threading in local unit tests vs. running on the Android environment.

Code

  • ViewModel contains LOADING, CONTENT, and ERROR (LCE) conditions for when a user selects content to open.
  • The LiveData NotifyItemChangedEffect state is saved in order to update the view.
  • NotifyItemChangedEffect is saved inside of the function to save content sent to the view. Only in the CONTENT condition, the item selected is sent to the view with a LiveData object saved, ContentToPlay.
  • In production, this works with the view's UI updating during LOADING, CONTENT, and ERROR, while ContentToPlay is only returned in the successful CONTENT condition.

ContentViewModel.kt

    is ContentSelected -> {
        _feedViewState.value = _feedViewState.value?.copy(

                // LiveData value for ContentToPlay initiated here.
                contentToPlay = switchMap(getAudiocast(contentSelected)) { lce ->
                    liveData {
                        when (lce) {
                            is Loading ->
                                _viewEffect.value = _viewEffect.value?.copy(
                                        notifyItemChanged = liveData {
                                            emit(Event(NotifyItemChangedEffect(...)))
                                        })
                            is Lce.Content -> {
                                _viewEffect.value = _viewEffect.value?.copy(
                                        notifyItemChanged = liveData {
                                            emit(Event(NotifyItemChangedEffect(...)))
                                        })

                                // LiveData value for ContentToPlay saved here.
                                emit(Event(lce.packet))
                            }
                            is Error -> {
                                _viewEffect.value = _viewEffect.value?.copy(
                                        notifyItemChanged = liveData {
                                            emit(Event(NotifyItemChangedEffect(...)))
                                        })
                                _viewEffect.value = _viewEffect.value?.copy(
                                        snackBar = liveData {
                                            emit(Event(SnackBarEffect(...)))
                                        })
                            }
                        }
                    }
                })
        ...
    }
  • By design - ContentToPlay is not returned in the LOADING and ERROR conditions.
  • Issue - The nested LiveData values for NotifyItemChangedEffect are not saved in the unit test, which update the view in each LCE condition. This code is executed inside of the LiveData saved for ContentToPlay. This pattern is logged and working in production.

PlayContentTests.kt


    @ExtendWith(InstantExecutorExtension::class)
    class PlayContentTests {
        @ParameterizedTest
        @MethodSource("FeedLoad")
        fun `Play Content`(test: PlayContentTest) = runBlocking {
            // ViewModel method included to initiate ContentSelected event.
            ...
            when (test.lceState) {
                LOADING ->
                    assertThat(contentViewModel.viewEffect.getOrAwaitValue().notifyItemChanged.getOrAwaitValue().peekEvent()).isEqualTo(
                            NotifyItemChangedEffect(...))
                CONTENT -> {
                    assertThat(contentViewModel.feedViewState.getOrAwaitValue().contentToPlay.getOrAwaitValue().peekEvent()).isEqualTo(
                            ContentToPlay(...))
                    assertThat(contentViewModel.viewEffect.getOrAwaitValue().notifyItemChanged.getOrAwaitValue().peekEvent()).isEqualTo(
                            NotifyItemChangedEffect(...))
                }
                ERROR -> {
                    assertThat(contentViewModel.feedViewState.getOrAwaitValue().contentToPlay.getOrAwaitValue().peekEvent()).isEqualTo(
                            ContentToPlay(...))
                    assertThat(contentViewModel.viewEffect.getOrAwaitValue().notifyItemChanged.getOrAwaitValue().peekEvent()).isEqualTo(
                            NotifyItemChangedEffect(...))
                }
            }
        }
    }

Potential Solutions

Out of the two potential solutions, 1. solves the testing issue described above, but adds unneeded production code.

  1. Saving LiveData in all LCE conditions - Saving a ContentToPlay LiveData value or null in all LCE conditions of the ViewModel to ensure a value is returned synchronously for local unit testing. However, this would be sending the ContentToPlay value unnecessarily to the view in production code.

ContentViewModel.kt

    _feedViewState.value = _feedViewState.value?.copy(contentToPlay =
    switchMap(getAudiocast(contentSelected)) { lce ->
        liveData {
            when (lce) {
                is Loading -> {
                    setContentLoadingStatus(contentSelected.content.id, View.VISIBLE)
                    _viewEffect.value = _viewEffect.value?.copy(
                            notifyItemChanged = liveData {
                                emit(Event(NotifyItemChangedEffect(contentSelected.position)))
                            })
                    // Empty ContentToPlay saved.
                    emit(Event(null))
                }
                is Lce.Content -> {
                    setContentLoadingStatus(contentSelected.content.id, View.GONE)
                    _viewEffect.value = _viewEffect.value?.copy(
                            notifyItemChanged = liveData {
                                emit(Event(NotifyItemChangedEffect(contentSelected.position)))
                            })
                    // ContentToPlay saved.
                    emit(Event(lce.packet))
                }
                is Error -> {
                    setContentLoadingStatus(contentSelected.content.id, View.GONE)
                    _viewEffect.value = _viewEffect.value?.copy(
                            notifyItemChanged = liveData {
                                emit(Event(NotifyItemChangedEffect(contentSelected.position)))
                            })
                    if (lce.packet.filePath.equals(TTS_CHAR_LIMIT_ERROR))
                        _viewEffect.value = _viewEffect.value?.copy(
                                snackBar = liveData {
                                    emit(Event(SnackBarEffect(TTS_CHAR_LIMIT_ERROR_MESSAGE)))
                                })
                    else _viewEffect.value = _viewEffect.value?.copy(
                            snackBar = liveData {
                                emit(Event(SnackBarEffect(CONTENT_PLAY_ERROR)))
                            })
                    // Empty ContentToPlay saved.
                    emit(Event(null))
                }
            }
        }
    })
  1. Executing the ViewModel and unit tests on a different thread with Coroutines' test utilities.

ContentViewModelTest.kt

    val event = launch(Dispatchers.IO) {
        contentViewModel.processEvent(event)
    }
    event.invokeOnCompletion {
         assertContentToPlay(test)
    }

adam-hurwitz avatar Sep 27 '19 07:09 adam-hurwitz