Background:
I have a simple application that fetches movie list using rests API call. The project structure is given below,
Activity -> ViewModel -> Repository -> ApiService (Retrofit Interface)
The activity subscribes to a LiveData and listens for events changes
The ViewModel hosts the MediatorLiveData observed by the activity. Initially the ViewModel sets a Resource.loading(..)
value in MediatorLiveData.
The ViewModel then calls the repository to get the movie list from ApiService
The ApiService returns a LiveData of either Resource.success(..)
or Resource.error(..)
The ViewModel then merges LiveData result from ApiService in MediatorLiveData
My Queries:
Inside the unit test, only the first emit Resource.loading(..)
is made by MediatorLiveData from ViewModel. The MediatorLiveData never emits any data from the repository.
ViewModel.class
private var discoverMovieLiveData: MediatorLiveData<Resource<DiscoverMovieResponse>> = MediatorLiveData()
fun observeDiscoverMovie(): LiveData<Resource<DiscoverMovieResponse>> {
return discoverMovieLiveData
}
fun fetchDiscoverMovies(page: Int) {
discoverMovieLiveData.value = Resource.loading(null) // this emit get observed immediately
val source = movieRepository.fetchDiscoverMovies(page)
discoverMovieLiveData.addSource(source) {
discoverMovieLiveData.value = it // never gets called
discoverMovieLiveData.removeSource(source)
}
}
Repository.class
fun fetchDiscoverMovies(page: Int): LiveData<Resource<DiscoverMovieResponse>> {
return LiveDataReactiveStreams.fromPublisher(
apiService.fetchDiscoverMovies(page)
.subscribeOn(Schedulers.io())
.map { d ->
Resource.success(d) // never gets called in unit test
}
.onErrorReturn { e ->
Resource.error(ApiErrorHandler.getErrorByThrowable(e), null) // // never gets called in unit test
}
)
}
Unit Test
@Test
fun loadMovieListFromNetwork() {
val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10)
val call: Flowable<DiscoverMovieResponse> = successCall(mockResponse) // wraps the retrofit result inside a Flowable<DiscoverMovieResponse>
whenever(apiService.fetchDiscoverMovies(1)).thenReturn(call)
viewModel.fetchDiscoverMovies(1)
verify(apiService).fetchDiscoverMovies(1)
verifyNoMoreInteractions(apiService)
val liveData = viewModel.observeDiscoverMovie()
val observer: Observer<Resource<DiscoverMovieResponse>> = mock()
liveData.observeForever(observer)
verify(observer).onChanged(
Resource.success(mockResponse) // TEST FAILS HERE AND GETS "Resource.loading(null)"
)
}
Resource is a generic wrapper class that wraps data for different scenario e.g. loading, success, error.
class Resource<out T>(val status: Status, val data: T?, val message: String?) {
.......
}
EDIT: #1
For testing purpose, I've updated my rx thread in repository to run it on main thread. This ends up with a Looper not mocked exception.
fun fetchDiscoverMovies(page: Int): LiveData<Resource<DiscoverMovieResponse>> {
return LiveDataReactiveStreams.fromPublisher(
apiService.fetchDiscoverMovies(page)
.subscribeOn(AndroidSchedulers.mainThread())
.map {...}
.onErrorReturn {...}
)
}
In test class,
@ExtendWith(InstantExecutorExtension::class)
class MainViewModelTest {
companion object {
@ClassRule
@JvmField
val schedulers = RxImmediateSchedulerRule()
}
@Test
fun loadMovieListFromNetwork() {
.....
}
}
}
RxImmediateSchedulerRule.class
class RxImmediateSchedulerRule : TestRule {
private val immediate = object : Scheduler() {
override fun createWorker(): Worker {
return ExecutorScheduler.ExecutorWorker(Executor { it.run() })
}
}
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
RxJavaPlugins.setInitIoSchedulerHandler { immediate }
RxJavaPlugins.setInitComputationSchedulerHandler { immediate }
RxJavaPlugins.setInitNewThreadSchedulerHandler { immediate }
RxJavaPlugins.setInitSingleSchedulerHandler { immediate }
RxAndroidPlugins.setInitMainThreadSchedulerHandler { immediate }
try {
base.evaluate()
} finally {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
}
}
}
}
}
InstantExecutorExtension.class
class InstantExecutorExtension : BeforeEachCallback, AfterEachCallback {
override fun beforeEach(context: ExtensionContext?) {
ArchTaskExecutor.getInstance().setDelegate(object : TaskExecutor() {
override fun executeOnDiskIO(runnable: Runnable) {
runnable.run()
}
override fun postToMainThread(runnable: Runnable) {
runnable.run()
}
override fun isMainThread(): Boolean {
return true
}
})
}
override fun afterEach(context: ExtensionContext?) {
ArchTaskExecutor.getInstance().setDelegate(null)
}
}
The way you specify RxImmediateSchedulerRule
won't work for JUnit5. If you place a breakpoint in apply()
method you'll see that it is not being executed.
Instead, you should create an extension as specified here:
class TestSchedulerExtension : BeforeTestExecutionCallback, AfterTestExecutionCallback {
override fun beforeTestExecution(context: ExtensionContext?) {
RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() }
RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() }
RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() }
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
override fun afterTestExecution(context: ExtensionContext?) {
RxJavaPlugins.reset()
RxAndroidPlugins.reset()
}
}
Then apply TestSchedulerExtension
in test class's annotation as such:
@ExtendWith(value = [InstantExecutorExtension::class, TestSchedulerExtension::class])
class MainViewModelTest {
private val apiService: ApiService = mock()
private lateinit var movieRepository: MovieRepository
private lateinit var viewModel: MainViewModel
@BeforeEach
fun init() {
movieRepository = MovieRepository(apiService)
viewModel = MainViewModel(movieRepository)
}
@Test
fun loadMovieListFromNetwork() {
val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10, 0, "", false)
val call: Flowable = Flowable.just(mockResponse)
whenever(apiService.fetchDiscoverMovies(1)).thenReturn(call)
viewModel.fetchDiscoverMovies(1)
assertEquals(Resource.success(mockResponse), LiveDataTestUtil.getValue(viewModel.discoverMovieLiveData))
}
}
Now the test will pass. Now you have tested, that observer has been dispatched with the expected value.
From another angle: is this a unit test? For sure it's not, because in this test we are interacting with 2 units: MainViewModel
and MovieRepository
. This more obeys to "integration test"'s term. Had you mocked out MoviesRepository
, then this will be a valid unit test:
@ExtendWith(value = [InstantExecutorExtension::class, TestSchedulerExtension::class])
class MainViewModelTest {
private val movieRepository: MovieRepository = mock()
private val viewModel = MainViewModel(movieRepository)
@Test
fun loadMovieListFromNetwork() {
val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10, 0, "", false)
val liveData =
MutableLiveData>().apply { value = Resource.success(mockResponse) }
whenever(movieRepository.fetchDiscoverMovies(1)).thenReturn(liveData)
viewModel.fetchDiscoverMovies(1)
assertEquals(Resource.success(mockResponse), getValue(viewModel.discoverMovieLiveData))
}
}
Note, MovieRepository
should be declared as open
alongside with fetchDiscoverMovies()
in order to be able to mock it. Alternatively you can consider using kotlin-allopen
plugin.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With