Протестируйте инфраструктуру CoroutineScope в Kotlin - PullRequest
0 голосов
/ 07 мая 2020

Может ли кто-нибудь показать мне, как сделать функцию getMovies в этой модели viewModel доступной для тестирования? Я не могу заставить модульные тесты правильно ожидать сопрограммы ..

(1) Я почти уверен, что мне нужно создать test-CoroutineScope и нормальный lifeCycle-CoroutineScope, как показано в this Medium Article .

(2) После того, как определения области видимости сделаны, я также не уверен, как сообщить getMovies (), какую область действия следует использовать, учитывая нормальный контекст приложения или тестовый контекст.

enum class MovieApiStatus { LOADING, ERROR, DONE }

class MovieListViewModel : ViewModel() {

    var pageCount = 1


    private val _status = MutableLiveData<MovieApiStatus>()
    val status: LiveData<MovieApiStatus>
        get() = _status    
    private val _movieList = MutableLiveData<List<Movie>>()
    val movieList: LiveData<List<Movie>>
        get() = _movieList    

    // allows easy update of the value of the MutableLiveData
    private var viewModelJob = Job()

    // the Coroutine runs using the Main (UI) dispatcher
    private val coroutineScope = CoroutineScope(
        viewModelJob + Dispatchers.Main
    )

    init {
        Log.d("list", "in init")
        getMovies(pageCount)
    }

    fun getMovies(pageNumber: Int) {

        coroutineScope.launch {
            val getMoviesDeferred =
                MovieApi.retrofitService.getMoviesAsync(page = pageNumber)
            try {
                _status.value = MovieApiStatus.LOADING
                val responseObject = getMoviesDeferred.await()
                _status.value = MovieApiStatus.DONE
               ............

            } catch (e: Exception) {
                _status.value = MovieApiStatus.ERROR
                ................
            }
        }
        pageCount = pageNumber.inc()
    }
...
}

он использует эту службу API ...

package com.example.themovieapp.network

import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.Deferred
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import retrofit2.http.GET
import retrofit2.http.Query

private const val BASE_URL = "https://api.themoviedb.org/3/"
private const val API_key  = ""

private val moshi = Moshi.Builder()
    .add(KotlinJsonAdapterFactory())
    .build()

private val retrofit = Retrofit.Builder()
    .addConverterFactory(MoshiConverterFactory.create(moshi))
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .baseUrl(BASE_URL)
    .build()


interface MovieApiService{
//https://developers.themoviedb.org/3/movies/get-top-rated-movies
//https://square.github.io/retrofit/2.x/retrofit/index.html?retrofit2/http/Query.html
    @GET("movie/top_rated")
    fun getMoviesAsync(
        @Query("api_key") apiKey: String = API_key,
        @Query("language") language: String = "en-US",
        @Query("page") page: Int
    ): Deferred<ResponseObject>
}


/*
Because this call is expensive, and the app only needs
one Retrofit service instance, you expose the service to the rest of the app using
a public object called MovieApi, and lazily initialize the Retrofit service there
*/
object MovieApi {
    val retrofitService: MovieApiService by lazy {
        retrofit.create(MovieApiService::class.java)
    }
}

Я просто пытаюсь создать тест, который утверждает, что liveData 'status' после функции ВЫПОЛНЕНО.

Вот репозиторий проекта

Ответы [ 2 ]

0 голосов
/ 11 мая 2020

Хорошо, благодаря ответу Dapp я смог написать несколько тестов, которые, похоже, ожидают функции должным образом.

Вот копия того, что я сделал:)

enum class MovieApiStatus { LOADING, ERROR, DONE }

class MovieListViewModel(val coroutineScope: ManagedCoroutineScope) : ViewModel() {
//....creating vars, livedata etc.

    init {
        getMovies(pageCount)
    }


    fun getMovies(pageNumber: Int) =

        coroutineScope.launch{
            val getMoviesDeferred =
                MovieApi.retrofitService.getMoviesAsync(page = pageNumber)
            try {
                _status.value = MovieApiStatus.LOADING
                val responseObject = getMoviesDeferred.await()
                _status.value = MovieApiStatus.DONE
                if (_movieList.value == null) {
                    _movieList.value = ArrayList()
                }
                pageCount = pageNumber.inc()
                _movieList.value = movieList.value!!.toList().plus(responseObject.results)
                    .sortedByDescending { it.vote_average }
            } catch (e: Exception) {
                _status.value = MovieApiStatus.ERROR
                _movieList.value = ArrayList()
            }
        }


    fun onLoadMoreMoviesClicked() =
        getMovies(pageCount)

//...nav functions, clearing functions etc.
}

и вот тестовые примеры

@ExperimentalCoroutinesApi
@RunWith(MockitoJUnitRunner::class)
class MovieListViewModelTest {

    @get:Rule
    var instantExecutorRule = InstantTaskExecutorRule()

    private val testDispatcher = TestCoroutineDispatcher()
    private val managedCoroutineScope: ManagedCoroutineScope = TestScope(testDispatcher)
    lateinit var viewModel: MovieListViewModel


    @Before
    fun setup() {
        //resProvider.mockColors()
        Dispatchers.setMain(testDispatcher)
        viewModel = MovieListViewModel(managedCoroutineScope)

    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
        testDispatcher.cleanupTestCoroutines()
    }

    @ExperimentalCoroutinesApi
    @Test
    fun getMoviesTest() {
        managedCoroutineScope.launch {
            assertTrue(
                "initial List, API status: ${viewModel.status.getOrAwaitValue()}",
                viewModel.status.getOrAwaitValue() == MovieApiStatus.DONE
            )
            assertTrue(
                "movieList has ${viewModel.movieList.value?.size}, != 20",
                viewModel.movieList.value?.size == 20
            )
            assertTrue(
                "pageCount = ${viewModel.pageCount}, != 2",
                viewModel.pageCount == 2
            )
            viewModel.onLoadMoreMoviesClicked()
            assertTrue(
                "added to list, API status: ${viewModel.status.getOrAwaitValue()}",
                viewModel.status.getOrAwaitValue() == MovieApiStatus.DONE
            )
            assertTrue(
                "movieList has ${viewModel.movieList.value?.size}, != 40",
                viewModel.movieList.value?.size == 40
            )

        }
    }
}

Потребовалось некоторое время проб и ошибок поиграть с областями действия .. runBlockingTest {} вызывал проблему «Исключение: задание () не завершено '..

Мне также пришлось создать фабрику viewModel, чтобы фрагмент создавал viewModel, когда приложение работает нормально ..

Project Repo

0 голосов
/ 09 мая 2020

Сначала вам нужно каким-то образом сделать вашу область сопрограммы инъекционной, либо создав для нее провайдер вручную, либо используя структуру для инъекций, такую ​​как dagger. Таким образом, когда вы тестируете свою ViewModel, вы можете переопределить область сопрограммы с помощью тестовой версии.

Для этого есть несколько вариантов, вы можете просто сделать саму ViewModel инъекционной (статья об этом здесь: https://medium.com/chili-labs/android-viewmodel-injection-with-dagger-f0061d3402ff)

Или вы можете вручную создать поставщика ViewModel и использовать его, где бы он ни создавался. Несмотря ни на что, я настоятельно рекомендую некоторую форму внедрения зависимостей для достижения реальной тестируемости.

Тем не менее, ваша ViewModel должна иметь свой CoroutineScope , предоставленный , а не создавать экземпляр самой области сопрограммы.

Другими словами, вы можете захотеть

class MovieListViewModel(val couroutineScope: YourCoroutineScope) : ViewModel() {}

или, возможно,

class MovieListViewModel @Inject constructor(val coroutineScope: YourCoroutineScope) : ViewModel() {}

Независимо от того, что вы делаете для инъекции, следующим шагом будет создание вашего собственного интерфейса CoroutineScope. которые вы можете переопределить в тестовом контексте. Например:

interface YourCoroutineScope : CoroutineScope {
    fun launch(block: suspend CoroutineScope.() -> Unit): Job
}

Таким образом, когда вы используете область видимости для своего приложения, вы можете использовать одну область, скажем, область сопрограммы жизненного цикла:

class LifecycleManagedCoroutineScope(
        private val lifecycleCoroutineScope: LifecycleCoroutineScope,
        override val coroutineContext: CoroutineContext = lifecycleCoroutineScope.coroutineContext) : YourCoroutineScope {
    override fun launch(block: suspend CoroutineScope.() -> Unit): Job = lifecycleCoroutineScope.launchWhenStarted(block)
}

И для вашего теста вы может использовать тестовую область:

class TestScope(override val coroutineContext: CoroutineContext) : YourCoroutineScope {
    val scope = TestCoroutineScope(coroutineContext)
    override fun launch(block: suspend CoroutineScope.() -> Unit): Job {
        return scope.launch {
            block.invoke(this)
        }
    }
}

Теперь, поскольку ваша ViewModel использует область видимости типа YourCoroutineScope, и поскольку в приведенных выше примерах и жизненный цикл, и тестовая версия реализуют интерфейс YourCoroutineScope, вы можете использовать разные версии прицела в разных ситуациях, например приложение или тест.

...