问题
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(..)
orResource.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)
}
}
回答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