Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

JUnit5 test with LiveData doesn't execute subscriber's callback

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)
  1. The activity subscribes to a LiveData and listens for events changes

  2. The ViewModel hosts the MediatorLiveData observed by the activity. Initially the ViewModel sets a Resource.loading(..) value in MediatorLiveData.

  3. The ViewModel then calls the repository to get the movie list from ApiService

  4. The ApiService returns a LiveData of either Resource.success(..) or Resource.error(..)

  5. 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)
    }

}
like image 990
Falling Into Infinity Avatar asked Jan 27 '23 05:01

Falling Into Infinity


1 Answers

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.

like image 112
azizbekian Avatar answered Feb 04 '23 04:02

azizbekian