Тест JUnit5 с LiveData не выполняет обратный вызов абонента - PullRequest
2 голосов
/ 03 июня 2019

Справочная информация:

У меня есть простое приложение, которое выбирает список фильмов с помощью вызова API остатков. Структура проекта приведена ниже,

Activity -> ViewModel -> Repository -> ApiService (Retrofit Interface)
  1. Активность подписывается на LiveData и прослушивает изменения событий

  2. ViewModel содержит MediatorLiveData , наблюдаемый действием. Первоначально ViewModel устанавливает значение Resource.loading(..) в MediatorLiveData .

  3. ViewModel затем вызывает хранилище для получения списка фильмов из ApiService

  4. ApiService возвращает LiveData либо Resource.success(..), либо Resource.error(..)

  5. Затем ViewModel объединяет LiveData результат из ApiService в MediatorLiveData

Мои запросы:

Внутри модульного теста только первый излучатель Resource.loading(..) создается MediatorLiveData из ViewModel . MediatorLiveData никогда не отправляет данные из хранилища.

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

Модульный тест

@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 - это универсальный класс-обертка, который упаковывает данные для другого сценария, например загрузка, успех, ошибка.

class Resource<out T>(val status: Status, val data: T?, val message: String?) {
.......
}

РЕДАКТИРОВАТЬ: # 1

Для для целей тестирования я обновил свой поток rx в репозитории, чтобы запустить его в основном потоке. Это заканчивается исключением Looper .

fun fetchDiscoverMovies(page: Int): LiveData<Resource<DiscoverMovieResponse>> {
            return LiveDataReactiveStreams.fromPublisher(
                apiService.fetchDiscoverMovies(page)
                    .subscribeOn(AndroidSchedulers.mainThread())
                    .map {...}
                    .onErrorReturn {...}
            )
        }

В тестовом классе,

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

}

Ответы [ 2 ]

2 голосов
/ 14 июня 2019

Я думаю, все, что вам нужно сделать, это изменить

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

на

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

И использовать LiveDataUtil класс из компонентов архитектуры гугл образца.Поэтому вам нужно будет скопировать / вставить это в ваш проект.

Так что в конце дня ваш новый тест будет выглядеть следующим образом (при условии, что все ассоциации и макеты настроены правильно в верхней частитестового класса) .Кроме того, вы используете InstantExecutorExtension , как показал выше азизбекян.

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

Если этот тест пройден, это означает, что вы смогли успешно наблюдать результаты сетевого запроса и вернутьуспешный ответ.

2 голосов
/ 13 июня 2019

Указанный вами способ RxImmediateSchedulerRule не будет работать для JUnit5. Если вы поместите точку останова в метод apply(), вы увидите, что она не выполняется.

Вместо этого вы должны создать расширение, как указано здесь :


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

    }

Затем примените TestSchedulerExtension в аннотации тестового класса следующим образом:


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

    }

Теперь тест пройден. Теперь вы проверили, что наблюдатель был отправлен с ожидаемым значением.


С другой стороны: это юнит-тест? Конечно, это не так, потому что в этом тесте мы взаимодействуем с 2 единицами: MainViewModel и MovieRepository Это больше соответствует термину «интеграционный тест». Если бы вы прогнали MoviesRepository, тогда это будет действительный юнит-тест:


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

}

Обратите внимание, MovieRepository должен быть объявлен как open вместе с fetchDiscoverMovies(), чтобы иметь возможность его высмеивать. В качестве альтернативы вы можете рассмотреть возможность использования плагина kotlin-allopen.

...