architecture-components-samples
architecture-components-samples copied to clipboard
LiveDataSample Unit Test - Unable to Observe Nested LiveData Values
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, andERROR(LCE) conditions for when a user selects content to open. - The LiveData
NotifyItemChangedEffectstate is saved in order to update the view. NotifyItemChangedEffectis saved inside of the function to save content sent to the view. Only in theCONTENTcondition, 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, andERROR, whileContentToPlayis only returned in the successfulCONTENTcondition.
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 -
ContentToPlayis not returned in theLOADINGandERRORconditions. - Issue - The nested LiveData values for
NotifyItemChangedEffectare not saved in the unit test, which update the view in each LCE condition. This code is executed inside of the LiveData saved forContentToPlay. 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.
- Saving LiveData in all LCE conditions - Saving a
ContentToPlayLiveData 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 theContentToPlayvalue 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))
}
}
}
})
- 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)
}