Test CoroutineScope infrastructure in Kotlin

冷暖自知 提交于 2020-05-17 06:45:27

问题


would someone be able to show me how to make the getMovies function in this viewModel testable? I can't get the unit tests to await the coroutines properly..

(1) I'm pretty sure I have to create a test-CoroutineScope and a normal lifeCycle-CoroutineScope, as seen in this Medium Article.

(2) Once the scope definitions are made, I'm also unsure how to tell getMovies() which scope it should be using given a normal app context or a test context.

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

it uses this API service...

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

I'm simply trying to create a test which asserts the liveData 'status' is DONE after the function.

Here is the Project Repository


回答1:


First you need to make your coroutine scope injectable somehow, either by creating a provider for it manually, or using an injection framework like dagger. That way, when you test your ViewModel, you can override the coroutine scope with a test version.

There are a few choices to do this, you can simply make the ViewModel itself injectable (article on that here: https://medium.com/chili-labs/android-viewmodel-injection-with-dagger-f0061d3402ff)

Or you can manually create a ViewModel provider and use that where ever it's created. No matter what, I would strongly advise some form of dependency injection in order to achieve real testability.

Regardless, your ViewModel needs to have its CoroutineScope provided, not instantiate the coroutine scope itself.

In other words you might want

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

or maybe

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

No matter what you do for injection, the next step is to create your own CoroutineScope interface that you can override in the test context. For example:

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

That way when you use the scope for your app, you can use one scope, say, lifecycle coroutine scope:

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

And for your test, you can use a test scope:

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

Now, since your ViewModel is using a scope of type YourCoroutineScope, and since, in the examples above, both the lifecycle and test version implement the YourCoroutineScope interface, you can use different versions of the scope in different situations, i.e. app vs test.




回答2:


Ok, thanks to Dapp's answer, I was able to write some tests which seem to be awaiting the function Properly.

Here is a copy of what I did :)

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

and here are the test cases

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

        }
    }
}

It took some trial and error playing around with the Scopes.. runBlockingTest{} was causing an issue 'Exception: job() not completed'..

I also had to create a viewModel factory in order for the fragment to create the viewModel for when the app is running normally..

Project Repo



来源:https://stackoverflow.com/questions/61665453/test-coroutinescope-infrastructure-in-kotlin

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