【问题标题】:Test CoroutineScope infrastructure in Kotlin在 Kotlin 中测试 CoroutineScope 基础设施
【发布时间】:2020-08-23 04:50:18
【问题描述】:

有人能告诉我如何使这个 viewModel 中的 getMovies 函数可测试吗?我无法让单元测试正确地等待协程..

(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“状态”为 DONE。

这里是Project Repository

【问题讨论】:

    标签: unit-testing android-studio kotlin testing kotlin-coroutines


    【解决方案1】:

    首先,您需要以某种方式使您的协程作用域可注入,或者通过手动为其创建提供程序,或者使用像 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 接口,因此您可以在不同情况下使用不同版本的范围,即应用程序 vs测试。

    【讨论】:

      【解决方案2】:

      好的,感谢Dapp's 的回答,我能够编写一些似乎正在等待功能正确的测试。

      这是我所做的副本:)

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

      使用 Scopes 进行了一些试验和错误。runBlockingTest{} 导致出现“异常:job() 未完成”问题..

      我还必须创建一个 viewModel 工厂,以便片段在应用正常运行时创建 viewModel。

      Project Repo

      【讨论】:

        猜你喜欢
        • 2010-11-01
        • 1970-01-01
        • 2016-04-26
        • 1970-01-01
        • 2023-03-20
        • 2011-06-19
        • 2013-03-07
        • 1970-01-01
        • 1970-01-01
        相关资源
        最近更新 更多