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

Deadly 提交于 2020-07-06 17:48:34

问题


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

}

回答1:


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.




回答2:


I think all you need to do is change

val call: Flowable<DiscoverMovieResponse> = successCall(mockResponse)

to

val call: Flowable<DiscoverMovieResponse> = Flowable.just(mockResponse)

And make use of the LiveDataUtil class from the architecture components google sample. So you'll need to copy/paste that into your project.

So at the end of the day your new test would look like this (assuming all the associations and mocks are setup correctly at the top of the test class). Also you're using an InstantExecutorExtension like azizbekian showed you above.

@Test
fun loadMovieListFromNetwork() {
    val mockResponse = DiscoverMovieResponse(1, emptyList(), 100, 10)
    val call: Flowable<DiscoverMovieResponse> = Flowable.just(mockResponse)
    whenever(apiService.fetchDiscoverMovies(1)).thenReturn(call)

    viewModel.fetchDiscoverMovies(1)

    assertEquals(Resource.success(mockResponse), LiveDataTestUtil.getValue(viewModel.discoverMovieLiveData))
}

If that test passes, it means you were able to successfully observe the results of a network request and return a successful response.



来源:https://stackoverflow.com/questions/56422249/junit5-test-with-livedata-doesnt-execute-subscribers-callback

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!